<?php

namespace App\Actions;

use App\Models\DunningCase;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * What Stripe tells us about the billing cycle, applied to the contract.
 *
 * The division of labour: Stripe owns the money — retries, dunning, off-session
 * SCA — and we own capability, because Stripe does not know how big the VM
 * should be. So these handlers move period boundaries and status, and write the
 * register. They never re-derive an amount: the invoice is the authority for
 * what was charged, and recomputing it here from our own catalogue would produce
 * a second, disagreeing answer.
 *
 * The one thing Stripe does NOT own is the document. Their invoice numbering is
 * theirs and is not a lawful Austrian invoice series; ours is, and it is gapless
 * because it has to be. So EVERY invoice Stripe reports as paid produces a
 * document of our own and one mail carrying it — see App\Actions\IssueStripeInvoice,
 * and invoicePaid() below for the two exceptions: the checkout's own invoice,
 * which the purchase already has a document for, and a charge for a term that
 * begins after the contract ended, which owes nobody anything — see
 * owesADocument().
 *
 * Every handler is idempotent. Stripe retries a webhook until it gets a 2xx,
 * and it also sends the same event to several endpoints — so "already applied"
 * is the normal case, not the exception.
 */
class ApplyStripeBillingEvent
{
    /**
     * Precedence when two events share a second — highest wins.
     *
     * Ordered by how much the event settles: a payment that went through is the
     * last word, a snapshot of the subscription is a running picture, and a
     * failed attempt is the least final of the three, since a retry may already
     * have succeeded.
     */
    private const RANK_PAID = 3;

    private const RANK_UPDATED = 2;

    private const RANK_FAILED = 1;

    public function __construct(
        private RecordCommercialEvent $record,
        private IssueStripeInvoice $issueStripeInvoice,
    ) {}

    /**
     * A renewal was paid. Move the period on and enter it in the register.
     *
     * @param  array<string, mixed>  $invoice
     */
    public function invoicePaid(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
    {
        $subscription = $this->resolve($invoice['subscription'] ?? null);

        if ($subscription === null) {
            return null;
        }

        // Ein Mahnfall dieses Vertrags kann damit erledigt sein — geprüft
        // wird aber gegen Stripes offene Rechnungen, nicht gegen diese eine:
        // die Mahnspesen liegen auf eigenen Rechnungen daneben.
        foreach (DunningCase::query()
            ->where('subscription_id', $subscription->id)
            ->whereNull('settled_at')
            ->get() as $case) {
            app(SettleDunningCase::class)($case);
        }

        $reason = (string) ($invoice['billing_reason'] ?? '');

        // The checkout's own invoice is not a renewal — it is the purchase, and
        // OpenSubscription has already entered it. Recording it again here
        // would double every customer's first payment in the register.
        if ($reason === 'subscription_create') {
            return null;
        }

        // Only a cycle renewal moves the term on. Stripe also sends paid
        // invoices for prorations and for charges raised by hand, and those are
        // money received without being the start of a new month — calling them
        // renewals would push the period forward on a top-up.
        $isRenewal = $reason === 'subscription_cycle';
        $invoiceId = (string) ($invoice['id'] ?? '');

        [$start, $end] = $this->period($invoice);

        // A contract that has ended stays ended (mutateInOrder refuses one), so
        // a final invoice arriving after the deletion cannot hand a departed
        // customer their service back. The payment is still entered in the
        // register below; it did happen. Whether it also owes a DOCUMENT is a
        // separate question with a separate answer — see owesADocument().
        if ($isRenewal && $start !== null && $end !== null) {
            $this->mutateInOrder($subscription, $eventAt, self::RANK_PAID, fn (Subscription $fresh) => $end
                // Never backwards: a renewal delayed behind the next one would
                // otherwise shorten the term the customer has already paid for.
                ->greaterThanOrEqualTo($fresh->current_period_end)
                    // Period boundaries are not part of the frozen snapshot:
                    // what the customer is owed does not change, only which
                    // month it is.
                    ? [
                        'current_period_start' => $start,
                        'current_period_end' => $end,
                        'status' => 'active',
                        'stripe_status' => 'active',
                    ]
                    : null);
        }

        // One invoice, one entry — enforced by the unique index rather than by
        // a check, because Stripe can deliver the same invoice twice at once
        // and both deliveries would pass a check.
        $event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;

        $record = $this->recordOnce(
            fn () => ($this->record)(
                event: $event,
                subscription: $subscription->refresh(),
                // The whole term, modules included. A cycle invoice bills the
                // package item AND one item per booked module, so what Stripe
                // took is the gross of all of them — held against `price_cents`
                // alone, every renewal on a contract with a single module was
                // filed as charged at the wrong amount. Nothing is agreed for a
                // proration or a hand-raised charge: what those are worth is
                // whatever Stripe worked out, and it arrives as the charged
                // figure below.
                netCents: $isRenewal ? $subscription->termNetCents() : 0,
                stripe: [
                    'invoice' => $invoiceId ?: null,
                    'subscription' => $subscription->stripe_subscription_id,
                ],
                // The exact kind, kept so a proration or a manual charge stays
                // distinguishable from an ordinary renewal.
                extra: ['billing_reason' => $reason ?: null],
                // Stripe's total is what was actually taken, tax and all. Ours
                // is what was agreed; the register states both.
                chargedGrossCents: isset($invoice['amount_paid']) ? (int) $invoice['amount_paid'] : null,
                eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
            )
        );

        if ($this->owesADocument($subscription->refresh(), $invoice, $start)) {
            $this->invoicePayment($subscription, $invoice, $start, $end);
        }

        return $record;
    }

    /**
     * Whether this charge is one the customer is still owed a document for.
     *
     * Nothing in this application could cancel a Stripe subscription, so a
     * customer who cancelled and a customer who withdrew both stayed subscribed —
     * and every month Stripe took another payment, `invoice.paid` arrived here,
     * and a real invoice was drawn out of the gapless Austrian series and mailed,
     * for a service that no longer existed. Cancelling the subscription is the
     * first half of the fix; this is the second, because a subscription somebody
     * failed to cancel, or one cancelled by hand in Stripe's dashboard, must not
     * be able to mint numbers here either.
     *
     * ## The rule, and why it is not simply "refuse a cancelled contract"
     *
     * A perfectly legitimate FINAL invoice can arrive after the contract has
     * ended, and refusing every one of them would lose a document the customer is
     * legally owed. The commonest case: the cycle invoice for the term they were
     * in went unpaid, Stripe's dunning ran for three weeks, the contract ended in
     * the middle of it, and then the customer paid. That money is for a term they
     * really did have a running cloud in.
     *
     * So the question is not WHEN the payment landed — Stripe's dunning makes that
     * arbitrary — but WHAT it is for:
     *
     *   the period this invoice bills STARTED before the contract ended
     *     → a term the customer was actually in. Document it.
     *   the period begins at or after the moment the contract ended
     *     → a term that does not exist. No document, no number, and an error in
     *       the log, because a charge for a service that has ended means Stripe
     *       was never stopped and somebody has to send that money back.
     *
     * The boundary case — a period starting exactly at `cancelled_at` — is the
     * first month the customer does NOT have, so it is refused.
     *
     * A contract marked cancelled with no date on it, or an invoice carrying no
     * period, cannot be judged by that rule at all, and both are refused. The
     * asymmetry is deliberate and it is the same one IssueInvoice is built on: a
     * missing document is recoverable — an operator issues it from the console —
     * while a number handed out in error can never be withdrawn, only corrected by
     * a cancellation document plus a new one.
     *
     * The register entry is written either way, above. The money moved; refusing
     * to record that would hide it, and the register is where an operator finds
     * the payments that need sending back.
     *
     * @param  array<string, mixed>  $invoice
     */
    private function owesADocument(Subscription $subscription, array $invoice, ?Carbon $periodStart): bool
    {
        if (! $subscription->hasEnded()) {
            return true;
        }

        $endedAt = $subscription->cancelled_at;

        if ($endedAt !== null && $periodStart !== null && $periodStart->lessThan($endedAt)) {
            return true;
        }

        Log::error('Stripe charged for a contract that has ended, so no invoice was issued and no number was used. This money has to go back.', [
            'subscription' => $subscription->uuid,
            'stripe_subscription' => $subscription->stripe_subscription_id,
            'stripe_invoice' => $invoice['id'] ?? null,
            'billing_reason' => $invoice['billing_reason'] ?? null,
            'amount_paid' => $invoice['amount_paid'] ?? null,
            'contract_ended_at' => $endedAt?->toIso8601String(),
            'billed_period_start' => $periodStart?->toIso8601String(),
        ]);

        return false;
    }

    /**
     * The document this payment owes the customer, and the one mail that carries
     * it.
     *
     * **Every paid invoice, not only a renewal.** The rule is one document per
     * charge, so a proration, a module booked in the middle of a term and a
     * charge raised by hand in Stripe's dashboard each get one — built from
     * Stripe's own lines, which is what actually took the money. The single
     * exception is the checkout's own invoice, which returns before this line:
     * that purchase already has a document, and a second would put two invoices
     * on one sale.
     *
     * Outside recordOnce() on purpose. The register entry and the invoice are
     * two different obligations, and tying the second to the first succeeding
     * would mean that a delivery which recorded the payment but could not issue
     * the document — an incomplete company profile, a mail server that was down
     * — could never be finished: every retry would find the register entry
     * already there, return, and the customer would never get an invoice. The
     * issuing is idempotent on its own, keyed on the Stripe invoice id.
     *
     * Caught here as well as inside the action. The action guards what it does;
     * this guards the rest, because the rule is that no paperwork of ours may
     * ever cost Stripe a 2xx — a retried webhook is a second attempt at
     * everything, and one of those things moves the customer's term.
     *
     * @param  array<string, mixed>  $invoice
     */
    private function invoicePayment(Subscription $subscription, array $invoice, ?Carbon $start, ?Carbon $end): void
    {
        try {
            ($this->issueStripeInvoice)($subscription->refresh(), $invoice, $start, $end);
        } catch (Throwable $e) {
            Log::error('Failed to invoice a Stripe payment that went through.', [
                'subscription' => $subscription->uuid,
                'stripe_invoice' => $invoice['id'] ?? null,
                'error' => $e->getMessage(),
            ]);
        }
    }

    /**
     * A renewal failed. Recorded, and nothing else: Stripe runs the dunning
     * schedule, and cutting a customer off on the first failed attempt would
     * punish an expired card as though it were a refusal to pay.
     *
     * @param  array<string, mixed>  $invoice
     */
    public function invoicePaymentFailed(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
    {
        $subscription = $this->resolve($invoice['subscription'] ?? null);

        if ($subscription === null) {
            return null;
        }

        $invoiceId = (string) ($invoice['id'] ?? '');

        // Only if this is the newest word we have. A failed attempt delayed
        // behind the successful retry would otherwise flip a customer who has
        // since paid back to past_due.
        $this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);

        // Der Mahnfall. Bis hierher endete es an dieser Stelle: `past_due`
        // gesetzt, Datensatz geschrieben, und niemand erfuhr davon — nicht der
        // Kunde, nicht der Betreiber. Je Rechnung genau einmal, siehe
        // OpenDunningCase.
        if ($invoiceId !== '') {
            app(OpenDunningCase::class)($subscription, $invoiceId);
        }

        return $this->recordOnce(
            fn () => ($this->record)(
                event: SubscriptionRecord::EVENT_PAYMENT_FAILED,
                subscription: $subscription->refresh(),
                netCents: 0,
                stripe: [
                    'invoice' => $invoiceId ?: null,
                    'subscription' => $subscription->stripe_subscription_id,
                ],
                extra: ['attempt' => $invoice['attempt_count'] ?? null],
                chargedGrossCents: 0,
                // Per attempt, not per invoice: Stripe retries a failing
                // payment on a schedule, and each attempt is its own event.
                eventKey: $invoiceId !== ''
                    ? 'payment_failed:'.$invoiceId.':'.($invoice['attempt_count'] ?? 0)
                    : null,
            )
        );
    }

    /**
     * Stripe's own status changed. Theirs is the authority on whether the money
     * is arriving; we copy it, and leave what the customer may USE to our own
     * status, which only a cancellation or an operator changes.
     *
     * @param  array<string, mixed>  $object
     */
    public function subscriptionUpdated(array $object, ?Carbon $eventAt = null): ?Subscription
    {
        $subscription = $this->resolve($object['id'] ?? null);

        if ($subscription === null) {
            return null;
        }

        [$start, $end] = $this->period($object);

        $this->rememberItem($subscription, $object);

        // Ended is ended, whatever order events arrive in — and an older
        // snapshot must never overwrite a newer one it was delivered behind.
        $this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
            'stripe_status' => $object['status'] ?? null,
            'current_period_start' => $start,
            'current_period_end' => $end,
        ], fn ($value) => $value !== null));

        return $subscription;
    }

    /**
     * Keep the id of the item this subscription bills through.
     *
     * Stripe names it only on the subscription object, and moving a contract
     * onto another price is impossible without it — the price hangs off the
     * item, not off the subscription. Learnt here because this event arrives on
     * its own for every status change and every renewal, so the ordinary running
     * of a contract fills it in without anyone having to ask Stripe.
     *
     * Written outside the ordering guard on purpose: this is not part of the
     * running picture. An item id does not go stale, does not change when the
     * price on it does, and a stale event carries exactly the same one.
     *
     * @param  array<string, mixed>  $object
     */
    private function rememberItem(Subscription $subscription, array $object): void
    {
        if ($subscription->stripe_item_id !== null) {
            return;
        }

        $itemId = $object['items']['data'][0]['id'] ?? null;

        // One item, because one contract bills one package. A subscription with
        // several would need a rule for which of them the package is, and there
        // is nothing here to build one from — better to have none than to swap
        // the price on whichever happened to be first.
        if (is_string($itemId) && $itemId !== '' && count($object['items']['data'] ?? []) === 1) {
            $subscription->update(['stripe_item_id' => $itemId]);
        }
    }

    /**
     * The subscription has ended at Stripe. That is the end of the contract,
     * so it goes in the register — a cancellation nobody recorded is exactly
     * the kind of gap the register exists to prevent.
     *
     * @param  array<string, mixed>  $object
     */
    public function subscriptionDeleted(array $object, ?Carbon $eventAt = null): ?SubscriptionRecord
    {
        $subscription = $this->resolve($object['id'] ?? null);

        if ($subscription === null) {
            return null;
        }

        $endedAt = isset($object['ended_at'])
            ? Carbon::createFromTimestamp((int) $object['ended_at'])
            : now();

        // The ending and its entry in the register commit together. Marking the
        // contract cancelled first and failing before the record would leave a
        // retry seeing "already cancelled" and returning — and the end of a
        // contract would never be entered at all.
        return $this->recordOnce(fn () => DB::transaction(function () use ($subscription, $object, $endedAt, $eventAt) {
            // Held while we decide, so a renewal arriving at the same moment
            // cannot reactivate the contract between this and the write.
            $subscription = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->firstOrFail();

            // No ordering guard: an ending is final, so a late delivery of it
            // is still correct. Only the running picture can go stale.
            //
            // A booked plan change goes with the contract it was booked on. It
            // could never be carried out — clupilot:apply-due-plan-changes only
            // touches active contracts — but leaving the stamp behind would have
            // the portal and the console go on announcing that a package which
            // no longer exists is about to shrink.
            $subscription->update([
                'status' => 'cancelled',
                'stripe_status' => $object['status'] ?? 'canceled',
                'cancelled_at' => $endedAt,
                'stripe_event_at' => $eventAt,
                'pending_plan' => null,
                'pending_effective_at' => null,
            ]);

            return ($this->record)(
                event: SubscriptionRecord::EVENT_CANCELLATION,
                subscription: $subscription->refresh(),
                netCents: 0,
                at: $endedAt,
                stripe: ['subscription' => $subscription->stripe_subscription_id],
                chargedGrossCents: 0,
                // A contract ends once. Two deliveries arriving together would
                // both pass a status check; only one can take this key.
                eventKey: 'cancellation:'.$subscription->id,
            );
        }));
    }

    /**
     * Whether this event is the newest word we have about the contract.
     *
     * Stripe does not guarantee delivery order, and the state here is a
     * running picture rather than a log: applying a stale snapshot on top of a
     * fresher one is how a paid-up customer ends up looking overdue. The
     * register is unaffected — each entry is keyed by what it is about, so a
     * late arrival still lands once, in its proper place.
     */
    /**
     * Decide and write in one step, with the contract's row held.
     *
     * The ordering guard is only worth as much as its atomicity: two
     * deliveries for the same contract can both read a stale copy, both pass
     * the check, and the loser then overwrites the winner — a renewal
     * reactivating a contract a deletion had just ended, or an old failure
     * burying a payment that went through. So the row is re-read under a lock
     * and re-judged inside the transaction that writes it.
     *
     * `$changes` returns the columns to set, or null to decline.
     *
     * @param  callable(Subscription): ?array<string, mixed>  $changes
     */
    private function mutateInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank, callable $changes): void
    {
        DB::transaction(function () use ($subscription, $eventAt, $rank, $changes) {
            $fresh = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();

            if ($fresh === null
                || $fresh->status === 'cancelled'
                || ! $this->appliesInOrder($fresh, $eventAt, $rank)) {
                return;
            }

            $values = $changes($fresh);

            if ($values === null) {
                return;
            }

            if ($eventAt !== null) {
                $values['stripe_event_at'] = $eventAt;
                $values['stripe_event_rank'] = $rank;
            }

            $fresh->update($values);
        });

        $subscription->refresh();
    }

    private function appliesInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank): bool
    {
        if ($eventAt === null || $subscription->stripe_event_at === null) {
            return true;   // nothing to compare against
        }

        if ($eventAt->greaterThan($subscription->stripe_event_at)) {
            return true;
        }

        if ($eventAt->lessThan($subscription->stripe_event_at)) {
            return false;
        }

        // Same second, which Stripe's one-second timestamps make common enough
        // to matter: a failed attempt and the retry that succeeded can share
        // one. Decide by what the event says about the money.
        return $rank >= (int) ($subscription->stripe_event_rank ?? 0);
    }

    /**
     * Write a register entry, unless one already exists for this invoice and
     * event.
     *
     * The uniqueness lives in the database, on (stripe_invoice_id, event).
     * Checking first and inserting afterwards leaves a window that Stripe
     * delivering the same invoice twice at once walks straight through — and a
     * register that counts a payment twice is worse than one that is late.
     *
     * @param  callable(): SubscriptionRecord  $write
     */
    private function recordOnce(callable $write): ?SubscriptionRecord
    {
        try {
            return $write();
        } catch (UniqueConstraintViolationException) {
            return null; // already recorded by a concurrent delivery
        }
    }

    /**
     * Hold an event whose contract does not exist yet, so it is not lost.
     *
     * @param  array<string, mixed>  $event
     */
    public function hold(array $event): void
    {
        $subscriptionId = $event['data']['object']['subscription']
            ?? $event['data']['object']['id']
            ?? null;

        if (! is_string($subscriptionId) || ! is_string($event['id'] ?? null)) {
            return;
        }

        // Only what we could not match. A handler returns null for several
        // reasons — already recorded, or deliberately skipped, like every
        // checkout's own invoice — and holding those would fill the table with
        // rows that replayHeldFor() can never revisit, because their contract
        // is right there. Nothing to wait for means nothing to hold.
        if (Subscription::query()->where('stripe_subscription_id', $subscriptionId)->exists()) {
            // Unless it appeared just now: the contract can be created between
            // the handler missing it and this check, and the replay that would
            // have collected it has then already been and gone. Apply it here
            // instead of dropping it. Safe to repeat — the handlers are
            // idempotent, so an event that was a no-op stays one.
            $this->dispatch($event);

            return;
        }

        StripePendingEvent::query()->updateOrCreate(
            ['stripe_event_id' => $event['id']],
            [
                'stripe_subscription_id' => $subscriptionId,
                'type' => (string) ($event['type'] ?? ''),
                'raised_at' => isset($event['created']) && is_numeric($event['created'])
                    ? Carbon::createFromTimestamp((int) $event['created'])
                    : null,
                'payload' => $event,
            ],
        );
    }

    /**
     * Replay everything held for a contract that has just appeared.
     *
     * In their original order, so the ordering guard sees them as Stripe raised
     * them rather than as they happened to be stored.
     */
    public function replayHeldFor(Subscription $subscription): int
    {
        if ($subscription->stripe_subscription_id === null) {
            return 0;
        }

        $held = StripePendingEvent::query()
            ->where('stripe_subscription_id', $subscription->stripe_subscription_id)
            ->orderBy('raised_at')
            ->orderBy('id')
            ->get();

        foreach ($held as $pending) {
            $this->dispatch($pending->payload ?? []);
            $pending->delete();
        }

        return $held->count();
    }

    /**
     * Route one event to its handler. Returns false for a type we do not
     * handle, so the caller can tell "not ours" from "nothing to do".
     *
     * @param  array<string, mixed>  $event
     */
    public function dispatch(array $event): mixed
    {
        $object = $event['data']['object'] ?? [];
        $raisedAt = isset($event['created']) && is_numeric($event['created'])
            ? Carbon::createFromTimestamp((int) $event['created'])
            : null;

        return match ($event['type'] ?? '') {
            'invoice.paid' => $this->invoicePaid($object, $raisedAt),
            'invoice.payment_failed' => $this->invoicePaymentFailed($object, $raisedAt),
            'customer.subscription.updated' => $this->subscriptionUpdated($object, $raisedAt),
            'customer.subscription.deleted' => $this->subscriptionDeleted($object, $raisedAt),
            default => false,
        };
    }

    /**
     * Find the contract a Stripe event is about.
     *
     * An unknown id is logged and dropped rather than raised: Stripe delivers
     * events for objects created by hand in the dashboard, and by other
     * environments pointed at the same endpoint. Failing the webhook for those
     * would have Stripe retry something that can never succeed.
     */
    private function resolve(mixed $stripeSubscriptionId): ?Subscription
    {
        $id = is_string($stripeSubscriptionId) ? $stripeSubscriptionId : null;

        if ($id === null) {
            return null;
        }

        return Subscription::query()->where('stripe_subscription_id', $id)->first();
    }

    /**
     * @param  array<string, mixed>  $object
     * @return array{0: ?Carbon, 1: ?Carbon}
     */
    private function period(array $object): array
    {
        $start = $object['period_start'] ?? $object['current_period_start'] ?? null;
        $end = $object['period_end'] ?? $object['current_period_end'] ?? null;

        return [
            is_numeric($start) ? Carbon::createFromTimestamp((int) $start) : null,
            is_numeric($end) ? Carbon::createFromTimestamp((int) $end) : null,
        ];
    }
}
