<?php

namespace App\Actions;

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;

/**
 * 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, invoice numbering — 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.
 *
 * 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) {}

    /**
     * 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;
        }

        $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.
        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;

        return $this->recordOnce(
            fn () => ($this->record)(
                event: $event,
                subscription: $subscription->refresh(),
                netCents: $isRenewal ? $subscription->price_cents : 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,
            )
        );
    }

    /**
     * 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']);

        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);

        // 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;
    }

    /**
     * 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.
            $subscription->update([
                'status' => 'cancelled',
                'stripe_status' => $object['status'] ?? 'canceled',
                'cancelled_at' => $endedAt,
                'stripe_event_at' => $eventAt,
            ]);

            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,
        ];
    }
}
