<?php

namespace App\Actions;

use App\Models\Subscription;
use App\Services\Billing\PlanPrices;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * Telling Stripe that the contract now bills for a different package.
 *
 * A plan change moved the contract, the entitlements and the machine, and left
 * Stripe billing the old price — so a customer who upgraded in March went on
 * paying for the smaller package every month afterwards, and one who downgraded
 * went on paying for the bigger one. This is what closes that.
 *
 * **Proration, per direction, chosen here and nowhere else.**
 *
 *  - An **upgrade** is settled immediately (`always_invoice`). PlanChange's own
 *    docblock already says Stripe's proration invoice is the authority for what
 *    an upgrade costs, and that is only true if there IS one — the alternative,
 *    letting the proration ride along on the next cycle invoice, would make the
 *    cycle charge bigger than the renewal document we issue from the contract's
 *    frozen price, and a document that does not match the money taken is not a
 *    document. Immediate also matches what the customer was shown: they get the
 *    bigger package now and pay for the days that are left.
 *  - A **downgrade** settles nothing (`none`). It lands exactly at the period
 *    end, so there is nothing left of the term to prorate; anything other than
 *    `none` at a boundary risks a stray credit line for a fraction of a day.
 *    From the next cycle Stripe simply bills the smaller price, which is what
 *    the renewal document will say.
 *
 * Between them the register and Stripe cannot claim different money: the upgrade
 * row records what was AGREED (with nothing charged), Stripe's proration invoice
 * comes back through the webhook as an `invoice_paid` row recording what was
 * TAKEN, and the cycle invoice stays exactly the contract price on both sides.
 *
 * **Never throws.** The change has already been applied to the contract and to
 * the machine by the time this runs, and Stripe being unreachable, unconfigured
 * or out of step with the catalogue must not undo any of that. What it must not
 * do either is disappear: the failure is parked on the contract in
 * `stripe_price_sync`, logged as an error, and retried hourly by
 * clupilot:sync-stripe-subscriptions.
 */
class MoveStripeSubscriptionPrice
{
    public function __construct(
        private StripeClient $stripe,
        private PlanPrices $prices,
    ) {}

    /**
     * @param  string  $behaviour  one of StripeClient::PRORATE_*
     * @return bool whether Stripe now agrees with the contract
     */
    public function __invoke(Subscription $subscription, string $behaviour): bool
    {
        // A granted package was never sold through Stripe — GrantSubscription
        // leaves stripe_subscription_id null on purpose — so there is nothing
        // there to move and nothing has gone wrong. Answered before anything
        // else so that no call is made and no failure is recorded.
        if ($subscription->stripe_subscription_id === null) {
            return true;
        }

        $priceId = null;

        try {
            $priceId = $this->targetPrice($subscription);

            if ($priceId === null) {
                throw new RuntimeException(
                    'The catalogue has no Stripe price for this plan version on this term. Run stripe:sync-catalogue.'
                );
            }

            // Already billing it. A retry of a swap that in fact went through,
            // or a second change back onto the price Stripe is on: either way
            // asking again would raise a proration for a move that is not one.
            if ($subscription->stripe_price_id === $priceId) {
                $this->settled($subscription, $priceId);

                return true;
            }

            if (! $this->stripe->isConfigured()) {
                throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
            }

            $itemId = $this->itemId($subscription);

            if ($itemId === null) {
                throw new RuntimeException(
                    'Stripe reports no item on this subscription, so there is nothing to put another price on.'
                );
            }

            $this->stripe->updateSubscriptionPrice(
                $subscription->stripe_subscription_id,
                $itemId,
                $priceId,
                $behaviour,
            );

            $this->settled($subscription, $priceId);

            return true;
        } catch (Throwable $e) {
            $this->park($subscription, $priceId, $behaviour, $e);

            return false;
        }
    }

    /**
     * Try a parked swap again.
     *
     * The target price is worked out afresh from the contract rather than read
     * back from the parked record: another change may have landed in between,
     * and the contract is the authority on which package the customer is on.
     * The BEHAVIOUR is the one that was chosen at the time, because it belongs
     * to the direction the move went, which cannot be read off the contract
     * once it has moved.
     *
     * A retried upgrade prorates from the moment Stripe finally hears about it,
     * not from the moment the change landed here — so a long outage charges the
     * customer for slightly fewer days than they had the bigger package. In
     * their favour, and preferable to the alternative of back-dating a charge.
     */
    public function retry(Subscription $subscription): bool
    {
        $parked = (array) ($subscription->stripe_price_sync ?? []);

        return $this($subscription, (string) ($parked['behaviour'] ?? StripeClient::PRORATE_NONE));
    }

    /**
     * The Stripe Price for the package the contract is on now, for the customer
     * who is paying for it.
     *
     * By plan VERSION and term, which is what a Stripe Price is: one per priced
     * row and per tax treatment, written by stripe:sync-catalogue. Resolving it by
     * plan name would hand back today's terms for a contract that is
     * grandfathered on older ones.
     *
     * The treatment is part of the answer, which is what makes a verification
     * converge: a business whose VAT id is confirmed the week after they bought
     * belongs on the net Price from that moment, and one whose registration has
     * lapsed belongs back on the gross one. Neither is a change of package, so
     * nothing here looks like one — the move is made with PRORATE_NONE by the
     * sweep that finds it, because the term is already paid for.
     */
    private function targetPrice(Subscription $subscription): ?string
    {
        return $this->prices->liveForSubscription($subscription);
    }

    /**
     * The item the subscription bills through.
     *
     * Stored when we have it — a `customer.subscription.updated` event carries
     * it and ApplyStripeBillingEvent keeps it. A contract older than that column
     * has none, so Stripe is asked once and the answer is kept: the item id
     * survives a price swap, so this happens at most once per contract.
     */
    private function itemId(Subscription $subscription): ?string
    {
        if ($subscription->stripe_item_id !== null) {
            return $subscription->stripe_item_id;
        }

        $itemId = $this->stripe->subscriptionItemId((string) $subscription->stripe_subscription_id);

        if ($itemId !== null) {
            $subscription->update(['stripe_item_id' => $itemId]);
        }

        return $itemId;
    }

    /** Stripe and the contract agree; nothing is outstanding. */
    private function settled(Subscription $subscription, string $priceId): void
    {
        $subscription->update([
            'stripe_price_id' => $priceId,
            'stripe_price_synced_at' => now(),
            'stripe_price_sync' => null,
        ]);
    }

    /**
     * The change landed here and did not reach Stripe.
     *
     * Recorded on the contract rather than only logged: a log line scrolls away,
     * and what this describes is a customer being charged for a package they are
     * no longer on. The row is what the retry sweep reads and what an operator
     * can be shown.
     */
    private function park(Subscription $subscription, ?string $priceId, string $behaviour, Throwable $e): void
    {
        $parked = (array) ($subscription->stripe_price_sync ?? []);
        $attempts = (int) ($parked['attempts'] ?? 0) + 1;

        Log::error('A plan change landed but did not reach Stripe: this contract is still being billed at the old price.', [
            'subscription' => $subscription->uuid,
            'plan' => $subscription->plan,
            'stripe_subscription' => $subscription->stripe_subscription_id,
            'stripe_price' => $priceId,
            'attempts' => $attempts,
            'error' => $e->getMessage(),
        ]);

        $subscription->update(['stripe_price_sync' => [
            'price' => $priceId,
            'behaviour' => $behaviour,
            'error' => mb_substr($e->getMessage(), 0, 250),
            'failed_at' => now()->toIso8601String(),
            'attempts' => $attempts,
        ]]);
    }
}
