<?php

namespace App\Actions;

use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * Telling Stripe which modules a contract is paying for.
 *
 * A booked module was a row here and a line on the customer's first invoice, and
 * nothing at all on the Stripe subscription — so it was charged once, in the
 * month it was booked, and never again. This is what closes that: every module
 * still running becomes an item on the subscription, and Stripe bills it beside
 * the package on the same invoice, every cycle, for as long as the customer
 * keeps it.
 *
 * **It reconciles rather than replays.** Not "booking adds an item, cancelling
 * removes one": the desired state is derivable from the bookings themselves —
 * which modules are running, at which frozen price, how many of each — and the
 * state Stripe is in is stamped on those same rows. So this can be run at any
 * moment, from a booking, from a cancellation or from the hourly sweep, and it
 * settles on the same answer. That is also what makes the retry honest: a
 * booking that could not reach Stripe leaves an active row with no item id, and
 * there is nothing else to remember.
 *
 * **Proration, per direction, chosen here and nowhere else.**
 *
 *  - **Booking** — and taking more of a pack — settles immediately
 *    (`always_invoice`). The owner has decided a mid-period booking is prorated
 *    by days, and `always_invoice` is the behaviour that does exactly that AND
 *    charges it there and then: Stripe raises its own invoice for the days that
 *    are left and takes the money. That invoice comes back as `invoice.paid`
 *    and is the fourth document in the owner's example. The alternative,
 *    `create_prorations`, would park the amount as a credit line on the next
 *    cycle invoice, and the customer would get one document a month later
 *    carrying two different things.
 *  - **Cancelling** — and taking fewer of a pack — settles nothing (`none`).
 *    The customer keeps the module to the end of the term they have already paid
 *    for and gets no credit, so there is nothing to settle; the item comes off
 *    now and the next cycle is simply smaller.
 *
 * **Why the item comes off at cancellation rather than at the period end.**
 * Stripe can cancel a SUBSCRIPTION at a period end; an item has no such thing.
 * Waiting for the boundary and removing it then is a race against Stripe's own
 * invoice generation, which happens at that same instant — lose it and the
 * customer is charged a full month for a module they cancelled, which is the one
 * mistake that costs real money. Removing it now with `none` moves no money at
 * all: the current term is already paid for, no credit is raised, and the next
 * cycle invoice simply has no such line. What the customer KEEPS until the
 * period end is the module itself, and that is held on the booking as
 * `cancels_at` and ended by clupilot:end-cancelled-addons.
 *
 * **Never throws.** The booking has already been made and delivered to the
 * machine by the time this runs — a storage pack is a bigger disk before Stripe
 * hears a word about it — and Stripe being unreachable must not undo any of
 * that. The failure is parked on the contract in `stripe_addon_sync`, logged,
 * and retried by clupilot:sync-stripe-subscriptions beside the plan swaps.
 */
class SyncStripeAddonItems
{
    public function __construct(
        private StripeClient $stripe,
        private AddonPrices $prices,
    ) {}

    /**
     * @param  bool  $chargeForAdditions  false when an item is being PUT BACK rather than
     *                                    bought: a cancellation taken back inside the term
     *                                    restores something the customer has already paid
     *                                    for, and charging the proration again would bill
     *                                    them twice for the same days. See
     *                                    App\Actions\BookAddon::undoCancelAtPeriodEnd().
     * @return bool whether Stripe now bills exactly the modules this contract holds
     */
    public function __invoke(Subscription $subscription, bool $chargeForAdditions = true): bool
    {
        // A granted package was never sold through Stripe — GrantSubscription
        // leaves stripe_subscription_id null on purpose — so there is nothing
        // there to put an item on and nothing has gone wrong. Answered before
        // anything else, so no call is made and no failure is recorded.
        if ($subscription->stripe_subscription_id === null) {
            $this->settled($subscription);

            return true;
        }

        try {
            foreach ($this->groups($subscription) as $group) {
                $this->reconcile($subscription, $group, $chargeForAdditions);
            }

            $this->settled($subscription);

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

            return false;
        }
    }

    /**
     * Move every module item this contract carries onto the Price that charges
     * today's figure for it, to the customer who is paying for it.
     *
     * Two reasons an item is on the wrong Price, and __invoke() above finds
     * neither of them: the item exists and the quantity is right, so it has
     * nothing to reconcile. This is the deliberate second pass, run by
     * stripe:reprice-subscriptions.
     *
     * The first is a change of figure — the catalogue moved from pushing NET
     * amounts to Stripe to pushing the amount actually charged, and a Stripe Price
     * cannot be edited, so every running module was on a Price that takes too
     * little. The second is a change of CUSTOMER: a business whose VAT id is
     * verified after they booked owes no VAT on the module either, and belongs on
     * its net Price from then on — the reverse too, when a registration lapses.
     *
     * PRORATE_NONE throughout. The customer has already paid for the term they
     * are in, at whatever was taken then; charging the difference for days
     * already served would be a bill nobody agreed to. From the next cycle
     * Stripe simply takes the right amount.
     *
     * The module's own frozen `price_cents` is untouched, because that is what
     * the customer agreed to pay net and it is what every pro-rata sum reads.
     *
     * @return array<int, array{addon: string, from: ?string, to: ?string}> what moved, for the command's output
     */
    public function reprice(Subscription $subscription, bool $dryRun = false): array
    {
        if ($subscription->stripe_subscription_id === null) {
            return [];
        }

        $moved = [];

        foreach ($this->groups($subscription) as $group) {
            $billable = $group->filter(
                fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null
            );

            $stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null);
            $itemId = $stamped->first()?->stripe_item_id;
            $first = $billable->first();

            // Nothing Stripe is billing, or nothing it should be: __invoke()
            // owns both of those cases and would undo whatever this did.
            if ($itemId === null || $first === null || (int) $first->price_cents <= 0) {
                continue;
            }

            $target = $this->prices->liveFor(
                (string) $first->addon_key,
                (int) $first->price_cents,
                (string) $first->currency,
                (string) $subscription->term,
                TaxTreatment::for($subscription->customer),
            );

            $current = $stamped->first()?->stripe_price_id;

            if ($target !== null && $target === $current) {
                continue;
            }

            $moved[] = ['addon' => (string) $first->addon_key, 'from' => $current, 'to' => $target];

            if ($dryRun) {
                continue;
            }

            $this->guardConfigured();

            // Minted here where the sweep left a figure the catalogue no longer
            // pre-creates — a grandfathered booking, or a discounted grant.
            $target ??= $this->prices->ensure(
                (string) $first->addon_key,
                (int) $first->price_cents,
                (string) $first->currency,
                (string) $subscription->term,
                TaxTreatment::for($subscription->customer),
            );

            if ($target === null) {
                // Nothing to bill for it after all — a module given away. Taken
                // back out of the report rather than announced as a move.
                array_pop($moved);

                continue;
            }

            $moved[count($moved) - 1]['to'] = $target;

            $this->stripe->updateSubscriptionPrice(
                (string) $subscription->stripe_subscription_id,
                $itemId,
                $target,
                StripeClient::PRORATE_NONE,
            );

            $this->stamp($group, $itemId, $target);
        }

        return $moved;
    }

    /**
     * The bookings that matter, grouped into the items they should be.
     *
     * By module AND by frozen price, because that pair is what a Stripe Price
     * is: two packs booked at the same money are one item with a quantity of
     * two, and a third booked after the catalogue moved is a second item at the
     * price that customer agreed to. Grouping by module alone would have to pick
     * one of two prices and charge everybody that.
     *
     * Cancelled bookings are in here as long as they still carry an item id —
     * that is the only record of what Stripe was last told, and without it the
     * quantity could never be brought back down.
     *
     * @return Collection<string, Collection<int, SubscriptionAddon>>
     */
    private function groups(Subscription $subscription): Collection
    {
        return SubscriptionAddon::query()
            ->where('subscription_id', $subscription->id)
            ->where(fn ($q) => $q->whereNull('cancelled_at')->orWhereNotNull('stripe_item_id'))
            ->orderBy('id')
            ->get()
            ->groupBy(fn (SubscriptionAddon $addon) => $addon->addon_key.'@'.$addon->price_cents);
    }

    /**
     * Bring one module's item into step with what the contract holds.
     *
     * @param  Collection<int, SubscriptionAddon>  $group
     */
    private function reconcile(Subscription $subscription, Collection $group, bool $chargeForAdditions = true): void
    {
        // What Stripe should bill: every booking still running and not already
        // on its way out. A module with `cancels_at` set is deliberately absent
        // — the customer keeps it, nobody is billed for it again.
        $billable = $group->filter(
            fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null
        );

        // What Stripe was last told, read off the rows that carry the item id.
        $stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null);

        $desired = (int) $billable->sum('quantity');
        $known = (int) $stamped->sum('quantity');
        $itemId = $stamped->first()?->stripe_item_id;

        $first = $group->first();

        // A module given away costs nothing, so there is nothing for Stripe to
        // bill — and a zero-amount item would put a line saying so on every
        // invoice the customer ever gets. Treated exactly like a module that is
        // no longer held: if one was ever billed, it comes off.
        if ($desired === 0 || (int) $first->price_cents <= 0) {
            if ($itemId !== null) {
                $this->guardConfigured();
                $this->stripe->removeSubscriptionItem($itemId, StripeClient::PRORATE_NONE);
            }

            $this->stamp($group, null, null);

            return;
        }

        // Stripe already bills exactly this. Only the bookings are restamped —
        // one may have been cancelled while another was booked in the same
        // breath, which leaves the quantity right and the rows out of date.
        if ($itemId !== null && $known === $desired) {
            $this->stamp($group, $itemId, $stamped->first()?->stripe_price_id);

            return;
        }

        // Asked for only now that something is going to be said to Stripe: an
        // unconfigured account must fail here, where it is parked and retried,
        // rather than while resolving a Price nobody is going to use.
        $this->guardConfigured();

        $priceId = $this->prices->ensure(
            (string) $first->addon_key,
            (int) $first->price_cents,
            (string) $first->currency,
            (string) $subscription->term,
            // Whoever holds the contract decides which of the module's Prices its
            // item bills through: the domestic gross, or the bare net where the
            // customer is a verified business in another member state. A module is
            // a supply like the package beside it and cannot be taxed differently
            // from it on the same invoice.
            TaxTreatment::for($subscription->customer),
        );

        if ($priceId === null) {
            $this->stamp($group, null, null);

            return;
        }

        if ($itemId === null) {
            $itemId = $this->stripe->addSubscriptionItem(
                (string) $subscription->stripe_subscription_id,
                $priceId,
                $desired,
                $chargeForAdditions ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
                // Keyed on the contract, the module and how many of it: a retry
                // after a timeout that in fact went through replays Stripe's
                // first answer instead of adding the module a second time.
                idempotencyKey: sprintf(
                    'clupilot-addon-item-%d-%s-%d-%d',
                    $subscription->id,
                    $first->addon_key,
                    $first->price_cents,
                    $desired,
                ),
            );
        } else {
            $this->stripe->updateSubscriptionItemQuantity(
                $itemId,
                $desired,
                // More of a pack is bought now and paid for the days that are
                // left; fewer is a cancellation, which earns no credit. A
                // restoration is neither and settles nothing.
                $desired > $known && $chargeForAdditions
                    ? StripeClient::PRORATE_IMMEDIATELY
                    : StripeClient::PRORATE_NONE,
            );
        }

        $this->stamp($group, $itemId, $priceId);
    }

    /**
     * Write down what Stripe now bills, on the bookings it bills.
     *
     * Every running booking in the group carries the item id, so a second pack
     * booked tomorrow can see that it is joining an item rather than starting
     * one. A booking that has stopped gives its id back, because the quantity
     * has already been taken down by it and counting it again would take the
     * same pack off twice.
     *
     * Through the query builder: none of these columns is part of what the
     * booking froze, and the model would otherwise have to be re-read first.
     *
     * @param  Collection<int, SubscriptionAddon>  $group
     */
    private function stamp(Collection $group, ?string $itemId, ?string $priceId): void
    {
        foreach ($group as $addon) {
            $billed = $itemId !== null && $addon->cancelled_at === null && $addon->cancels_at === null;

            $values = [
                'stripe_item_id' => $billed ? $itemId : null,
                'stripe_price_id' => $billed ? $priceId : null,
            ];

            if ($addon->stripe_item_id === $values['stripe_item_id']
                && $addon->stripe_price_id === $values['stripe_price_id']) {
                continue;
            }

            SubscriptionAddon::query()->whereKey($addon->getKey())->update($values + ['updated_at' => now()]);

            $addon->forceFill($values)->syncOriginal();
        }
    }

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

    /** Stripe bills exactly what the contract holds; nothing is outstanding. */
    private function settled(Subscription $subscription): void
    {
        if ($subscription->stripe_addon_sync === null) {
            return;
        }

        $subscription->update(['stripe_addon_sync' => null]);
    }

    /**
     * A module was booked or cancelled here and Stripe was not told.
     *
     * On the contract rather than only in a log line, for the same reason as
     * `stripe_price_sync` beside it: what this describes is a customer holding a
     * module nobody is billing them for — or being billed for one they gave up —
     * and a log line scrolls away.
     */
    private function park(Subscription $subscription, Throwable $e): void
    {
        $parked = (array) ($subscription->stripe_addon_sync ?? []);
        $attempts = (int) ($parked['attempts'] ?? 0) + 1;

        Log::error('A module booking landed but did not reach Stripe: this contract is not billed for what it holds.', [
            'subscription' => $subscription->uuid,
            'stripe_subscription' => $subscription->stripe_subscription_id,
            'attempts' => $attempts,
            'error' => $e->getMessage(),
        ]);

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