<?php

namespace App\Services\Billing\Providers;

use App\Contracts\BillingProvider;
use App\Contracts\CheckoutSession;
use App\Exceptions\InvalidWebhookSignature;
use App\Models\Currency;
use App\Models\Invoice;
use App\Models\PaymentProvider;
use App\Models\Plan;
use App\Models\PlanPrice;
use App\Models\ProviderCustomer;
use App\Models\Subscription;
use App\Models\User;
use App\Models\WebhookEvent;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;

class StripeProvider implements BillingProvider
{
    protected ?PaymentProvider $row = null;

    public function withRow(PaymentProvider $row): static
    {
        $this->row = $row;

        return $this;
    }

    public function key(): string
    {
        return 'stripe';
    }

    public function label(): string
    {
        return 'Stripe';
    }

    public function matchesWebhook(Request $request): bool
    {
        return $request->hasHeader('Stripe-Signature');
    }

    public function verifyWebhook(string $payload, Request $request): array
    {
        $secret = $this->config('webhook_secret');
        if (! $secret) {
            throw new InvalidWebhookSignature('stripe', 'webhook_secret missing');
        }

        $signature = $request->header('Stripe-Signature') ?? '';

        try {
            $event = \Stripe\Webhook::constructEvent(
                $payload,
                $signature,
                $secret,
                config('billing.webhook_tolerance', 300),
            );
        } catch (\Stripe\Exception\SignatureVerificationException $e) {
            throw new InvalidWebhookSignature('stripe', $e->getMessage());
        }

        return $event->toArray();
    }

    /**
     * Idempotency-first dispatch. Wrapping the whole switch in a transaction
     * guarantees that the dedup-row insert at the end either lands together
     * with the side-effect writes or rolls back together — without that, a
     * mid-handler exception would leave the dedup row absent (correct, so
     * Stripe retries) BUT the side-effect writes committed (wrong, so the
     * retry double-applies). Inverting that is worse: dedup-row first +
     * side-effects second would silently drop events that crashed the
     * handler since the retry would short-circuit on the dedup hit.
     */
    public function dispatchEvent(array $event): void
    {
        $eventId = $event['id'] ?? null;
        if (! $eventId) {
            throw new RuntimeException('Stripe event missing id');
        }

        $alreadyProcessed = WebhookEvent::query()
            ->where('provider_key', 'stripe')
            ->where('provider_event_id', $eventId)
            ->exists();

        if ($alreadyProcessed) {
            return;
        }

        DB::transaction(function () use ($event, $eventId) {
            $type = $event['type'] ?? '';
            $object = $event['data']['object'] ?? [];

            switch ($type) {
                case 'checkout.session.completed':
                    $this->handleCheckoutSessionCompleted($object);
                    break;

                case 'customer.subscription.created':
                case 'customer.subscription.updated':
                    $this->handleSubscriptionUpsert($object);
                    break;

                case 'customer.subscription.deleted':
                    $this->handleSubscriptionDeleted($object);
                    break;

                case 'invoice.paid':
                case 'invoice.payment_succeeded':
                    $this->handleInvoicePaid($object);
                    break;

                case 'invoice.payment_failed':
                    $this->handleInvoicePaymentFailed($object);
                    break;

                case 'charge.refunded':
                    // Refund processed — could have come from our admin
                    // refund flow OR from Stripe's dashboard. Mirror to
                    // local invoice row regardless so dashboard-initiated
                    // refunds don't leave us with stale 'paid' status.
                    // (Stripe equivalent of Razorpay's refund.created.)
                    $this->handleChargeRefunded($object);
                    break;

                case 'charge.dispute.created':
                    // Customer (or their bank) opened a chargeback.
                    // Stripe holds the disputed funds until resolution;
                    // we mark the invoice 'disputed' so admin sees it
                    // in the invoice list with the right status.
                    $this->handleDisputeCreated($object);
                    break;

                case 'charge.dispute.closed':
                    // Resolution — won (funds returned), lost (funds
                    // gone), or warning_closed (handled outside the
                    // dispute system). Update the invoice accordingly.
                    $this->handleDisputeClosed($object);
                    break;

                default:
                    Log::info('stripe webhook: ignored event type '.$type);
                    break;
            }

            WebhookEvent::create([
                'provider_key' => 'stripe',
                'provider_event_id' => $eventId,
                'event_type' => $event['type'] ?? '',
                'processed_at' => now(),
                'payload' => $event,
            ]);
        });
    }

    public function createCheckoutSession(
        User $user,
        Plan $plan,
        string $period,
        ?int $currencyId = null,
    ): CheckoutSession {
        $apiKey = $this->config('api_key');
        if (! $apiKey) {
            throw new RuntimeException('StripeProvider not configured: provider_customers row missing or config empty');
        }

        $priceId = $this->resolvePriceId($plan, $period, $currencyId);

        if (! $priceId) {
            throw new RuntimeException("Stripe price id missing for plan [{$plan->key}] period [{$period}]");
        }

        \Stripe\Stripe::setApiKey($apiKey);

        $customerId = $this->resolveOrCreateStripeCustomer($user);

        // Stripe Tax is admin-toggleable via `tax_enabled` in the provider's
        // config (root-level flag, surfaced as an "Enable tax" checkbox on
        // /admin/billing's gateways tab). When ON, we pass `automatic_tax`
        // + `customer_update` so Checkout collects the buyer's address and
        // Stripe computes tax per their jurisdiction — requires the merchant
        // to have configured their origin address + registrations in the
        // Stripe dashboard (https://dashboard.stripe.com/settings/tax).
        // When OFF (default), the keys are omitted entirely — Stripe charges
        // the flat plan amount, no tax line, no address collection.
        $params = [
            'mode' => 'subscription',
            'customer' => $customerId,
            'client_reference_id' => (string) $user->id,
            'line_items' => [[
                'price' => $priceId,
                'quantity' => 1,
            ]],
            'success_url' => route('checkout.success').'?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => route('checkout.cancel'),
            'allow_promotion_codes' => true,
        ];
        if ($this->taxEnabled()) {
            $params['automatic_tax'] = ['enabled' => true];
            $params['customer_update'] = ['address' => 'auto', 'name' => 'auto'];
        }
        $session = \Stripe\Checkout\Session::create($params);

        return new CheckoutSession(url: $session->url, providerSessionId: $session->id);
    }

    public function cancelSubscription(Subscription $subscription, bool $atPeriodEnd = true): void
    {
        \Stripe\Stripe::setApiKey($this->requireApiKey());

        if ($atPeriodEnd) {
            \Stripe\Subscription::update($subscription->provider_subscription_id, [
                'cancel_at_period_end' => true,
            ]);
            $subscription->update(['cancels_at_period_end' => true]);

            return;
        }

        \Stripe\Subscription::retrieve($subscription->provider_subscription_id)->cancel();
        $subscription->update([
            'status' => 'canceled',
            'canceled_at' => now(),
        ]);
    }

    public function fetchSubscriptionIsLive(Subscription $subscription): ?bool
    {
        if (! $subscription->provider_subscription_id) {
            return null;
        }

        try {
            \Stripe\Stripe::setApiKey($this->requireApiKey());
            $sub = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
        } catch (\Throwable) {
            return null; // can't determine → caller must not cancel
        }

        // Active OR recoverable states — never cancel these. past_due/unpaid are
        // mid-dunning and may recover; paused/trialing are live. Everything else
        // (canceled, incomplete, incomplete_expired) is terminal → safe to sync.
        return in_array((string) ($sub->status ?? ''), ['active', 'trialing', 'past_due', 'unpaid', 'paused'], true);
    }

    public function resumeSubscription(Subscription $subscription): void
    {
        \Stripe\Stripe::setApiKey($this->requireApiKey());

        \Stripe\Subscription::update($subscription->provider_subscription_id, [
            'cancel_at_period_end' => false,
        ]);

        $subscription->update([
            'cancels_at_period_end' => false,
            'canceled_at' => null,
        ]);
    }

    public function changePlan(
        Subscription $subscription,
        Plan $newPlan,
        string $newPeriod,
        ?int $currencyId = null,
    ): ?string {
        \Stripe\Stripe::setApiKey($this->requireApiKey());

        $resolvedCurrencyId = $currencyId ?? $subscription->currency_id ?? $this->defaultCurrencyId();
        $newPriceId = $this->resolvePriceId($newPlan, $newPeriod, $resolvedCurrencyId);

        if (! $newPriceId) {
            throw new RuntimeException("Stripe price id missing for plan [{$newPlan->key}] period [{$newPeriod}]");
        }

        $stripeSub = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
        $itemId = $stripeSub->items->data[0]->id;

        $updateParams = [
            'items' => [[
                'id' => $itemId,
                'price' => $newPriceId,
            ]],
            'proration_behavior' => 'always_invoice',
            'expand' => ['latest_invoice.payments'],
        ];
        if ($this->taxEnabled()) {
            $updateParams['automatic_tax'] = ['enabled' => true];
        }
        $updated = \Stripe\Subscription::update($subscription->provider_subscription_id, $updateParams);

        $subscription->update([
            'plan_id' => $newPlan->id,
            'currency_id' => $resolvedCurrencyId,
            'period' => $newPeriod,
        ]);

        // 3DS / SCA flow: when the prorated invoice's payment_intent needs
        // customer authentication, Stripe leaves it in `requires_action`.
        // Hand back the hosted invoice URL so the frontend can redirect the
        // user to confirm the charge. Stripe API 2025+ moved payment refs
        // to invoice.payments[] (each carries a payment_intent ref); the
        // old invoice.payment_intent field is gone.
        $invoice = $updated->latest_invoice ?? null;
        $payments = is_object($invoice?->payments ?? null)
            ? ($invoice->payments->data ?? [])
            : [];
        foreach ($payments as $p) {
            $payment = $p->payment ?? null;
            $intent = $payment?->payment_intent ?? null;
            if (! is_object($intent)) {
                continue;
            }
            if (in_array($intent->status ?? null, ['requires_action', 'requires_payment_method'], true)) {
                return $invoice->hosted_invoice_url ?? null;
            }
        }

        return null;
    }

    public function refundInvoice(Invoice $invoice, ?int $amountMinor = null): string
    {
        \Stripe\Stripe::setApiKey($this->requireApiKey());

        // Stripe API 2025+ moved invoice payment refs to the `payments`
        // sub-collection (Stripe\InvoicePayment objects). The legacy
        // `charge` and `payment_intent` fields on the Invoice may be
        // absent. Refund::create accepts either `charge` OR
        // `payment_intent`, so try both. Expanding `payments` here avoids
        // a second round-trip to InvoicePayment::all.
        $stripeInvoice = \Stripe\Invoice::retrieve([
            'id' => $invoice->provider_invoice_id,
            'expand' => ['charge', 'payments'],
        ]);

        $chargeId = is_object($stripeInvoice->charge ?? null)
            ? $stripeInvoice->charge->id
            : ($stripeInvoice->charge ?? null);

        $paymentIntentId = null;

        // Walk invoice.payments — modern API surfaces them here. Each
        // InvoicePayment.payment is `{type, payment_intent?, charge?,
        // payment_record?}`. Prefer payment_intent (refundable in current
        // API), fall back to charge for older payments.
        if (! $chargeId) {
            $payments = $stripeInvoice->payments ?? null;
            $payments = is_object($payments) ? ($payments->data ?? []) : [];
            foreach ($payments as $p) {
                $payment = $p->payment ?? null;
                if (! is_object($payment)) {
                    continue;
                }
                $piCandidate = $payment->payment_intent ?? null;
                if (is_object($piCandidate)) {
                    $piCandidate = $piCandidate->id;
                }
                if ($piCandidate) {
                    $paymentIntentId = $piCandidate;
                    break;
                }
                $chargeCandidate = $payment->charge ?? null;
                if (is_object($chargeCandidate)) {
                    $chargeCandidate = $chargeCandidate->id;
                }
                if ($chargeCandidate) {
                    $chargeId = $chargeCandidate;
                    break;
                }
            }

            // Fallback: if expand=payments didn't populate (rare — older
            // accounts on the legacy invoice schema), hit the dedicated
            // InvoicePayment list endpoint.
            if (! $chargeId && ! $paymentIntentId) {
                try {
                    $list = \Stripe\InvoicePayment::all([
                        'invoice' => $invoice->provider_invoice_id,
                        'limit' => 10,
                    ]);
                    foreach (($list->data ?? []) as $p) {
                        $payment = $p->payment ?? null;
                        if (! is_object($payment)) {
                            continue;
                        }
                        $piCandidate = $payment->payment_intent ?? null;
                        if (is_object($piCandidate)) {
                            $piCandidate = $piCandidate->id;
                        }
                        if ($piCandidate) {
                            $paymentIntentId = $piCandidate;
                            break;
                        }
                        $chargeCandidate = $payment->charge ?? null;
                        if (is_object($chargeCandidate)) {
                            $chargeCandidate = $chargeCandidate->id;
                        }
                        if ($chargeCandidate) {
                            $chargeId = $chargeCandidate;
                            break;
                        }
                    }
                } catch (\Throwable) {
                    // Stripe::InvoicePayment unavailable on this account —
                    // fall through to the not-found error below.
                }
            }
        }

        if (! $chargeId && ! $paymentIntentId) {
            throw new RuntimeException(
                "Cannot refund: Stripe invoice [{$invoice->provider_invoice_id}] has no charge or payment_intent. "
                .'The invoice is likely unpaid or already refunded.'
            );
        }

        // Metadata surfaces in Stripe's dashboard — admins searching
        // "appshot-studio-admin" find every refund our system initiated.
        $params = [
            'amount' => $amountMinor ?? (int) $invoice->amount_cents,
            'metadata' => [
                'invoice_id' => (string) $invoice->id,
                'subscription_id' => (string) ($invoice->subscription_id ?? ''),
                'source' => 'appshot-studio-admin',
            ],
        ];
        if ($chargeId) {
            $params['charge'] = $chargeId;
        } else {
            $params['payment_intent'] = $paymentIntentId;
        }

        $refund = \Stripe\Refund::create($params);

        return (string) ($refund->id ?? '');
    }

    public function ensureWebhookEndpoint(string $publicUrl): ?array
    {
        if ($this->isLocalUrl($publicUrl)) {
            return null;
        }

        \Stripe\Stripe::setApiKey($this->requireApiKey());

        // Events list mirrors the dispatchEvent switch — keep in sync when
        // adding new handlers. Stripe rejects unknown event names so we
        // can't pre-register events we don't yet handle.
        $events = [
            'checkout.session.completed',
            'customer.subscription.created',
            'customer.subscription.updated',
            'customer.subscription.deleted',
            'invoice.paid',
            'invoice.payment_succeeded',
            'invoice.payment_failed',
            'charge.refunded',
            'charge.dispute.created',
            'charge.dispute.closed',
        ];

        // Find an existing endpoint pointing at our URL. Stripe paginates
        // to 100 by default; merchants with more than 100 endpoints are
        // outside our normal use case (we'd just create another anyway).
        $endpoints = \Stripe\WebhookEndpoint::all(['limit' => 100]);
        $existing = null;
        foreach (($endpoints->data ?? []) as $ep) {
            if (($ep->url ?? null) === $publicUrl) {
                $existing = $ep;
                break;
            }
        }

        $hasStoredSecret = ! empty($this->config('webhook_secret'));

        if ($existing && $hasStoredSecret) {
            // Have a secret + existing endpoint — refresh events only. Stripe
            // doesn't re-expose secrets after creation, so leave the saved
            // value in place.
            \Stripe\WebhookEndpoint::update($existing->id, [
                'enabled_events' => $events,
                'disabled' => false,
            ]);

            return [
                'secret' => null,
                'provider_webhook_id' => (string) $existing->id,
                'created' => false,
            ];
        }

        if ($existing && ! $hasStoredSecret) {
            // Endpoint exists in Stripe but we don't have its secret (manual
            // dashboard setup, wiped-then-restored config, etc.). Delete
            // and recreate to get a fresh secret — there's nothing to lose
            // since we couldn't verify signatures without the secret anyway.
            \Stripe\WebhookEndpoint::retrieve($existing->id)->delete();
        }

        $endpoint = \Stripe\WebhookEndpoint::create([
            'url' => $publicUrl,
            'enabled_events' => $events,
        ]);

        return [
            'secret' => (string) ($endpoint->secret ?? ''),
            'provider_webhook_id' => (string) $endpoint->id,
            'created' => true,
        ];
    }

    /**
     * Detects URLs that providers can't reach (localhost, private IP
     * ranges, RFC1918, link-local). Mirrors the official woo-razorpay
     * plugin's check — uses gethostbyname + filter_var with the
     * NO_PRIV_RANGE / NO_RES_RANGE flags. Hostnames that don't resolve
     * via DNS are treated as non-local — calling the provider will
     * surface a clearer error than "looks like localhost."
     */
    protected function isLocalUrl(string $url): bool
    {
        $host = parse_url($url, PHP_URL_HOST);
        if (! $host) {
            return true;
        }

        $ip = gethostbyname($host);

        // gethostbyname returns the input unchanged on DNS failure. If
        // we got back a non-IP string, treat as non-local.
        if (! filter_var($ip, FILTER_VALIDATE_IP)) {
            return false;
        }

        return ! filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        );
    }

    /**
     * Mode-aware credential lookup. Reads from `config.{mode}.{key}` where
     * `mode` is 'test' or 'live' (default 'live' when unset). Non-credential
     * settings (anything not under a mode bucket) read from root.
     *
     * Pre-migration installs may not have the mode/test/live shape — falls
     * back to root-level lookup so the provider keeps working through the
     * deploy window before the migration runs (defensive; the migration
     * runs before any HTTP traffic on the upgrade path).
     */
    protected function config(string $key): ?string
    {
        if (! $this->row) {
            return null;
        }
        $config = $this->row->config ?? [];
        $mode = is_string($config['mode'] ?? null) ? $config['mode'] : 'live';
        $bucket = $config[$mode] ?? null;
        if (is_array($bucket) && array_key_exists($key, $bucket)) {
            return $bucket[$key];
        }

        // Backward compat / non-credential keys (e.g. future global flags
        // stored at root, or pre-migration flat configs).
        return $config[$key] ?? null;
    }

    /**
     * Admin-toggled tax flag. Lives at config root (not mode bucket) so the
     * same setting applies to test + live — merchant tax registrations are
     * per-account, not per-mode. Defaults to false: new installs don't ship
     * with Stripe Tax on (which would require dashboard configuration before
     * the first checkout could succeed) or Razorpay/PayPal breakdown
     * surfacing.
     */
    protected function taxEnabled(): bool
    {
        if (! $this->row) {
            return false;
        }

        return (bool) ($this->row->config['tax_enabled'] ?? false);
    }

    /**
     * Currently active mode for this provider row. Used by callers that
     * need to disambiguate test vs live (e.g. webhook auto-register).
     */
    public function activeMode(): string
    {
        if (! $this->row) {
            return 'live';
        }
        $mode = $this->row->config['mode'] ?? 'live';

        return $mode === 'test' ? 'test' : 'live';
    }

    public function isTestMode(): bool
    {
        return $this->activeMode() === 'test';
    }

    protected function requireApiKey(): string
    {
        $apiKey = $this->config('api_key');
        if (! $apiKey) {
            throw new RuntimeException('StripeProvider not configured: provider_customers row missing or config empty');
        }

        return $apiKey;
    }

    protected function resolveOrCreateStripeCustomer(User $user): string
    {
        $customerRow = ProviderCustomer::query()
            ->where('user_id', $user->id)
            ->where('provider_key', 'stripe')
            ->first();

        if ($customerRow) {
            return $customerRow->provider_customer_id;
        }

        $customer = \Stripe\Customer::create([
            'email' => $user->email,
            'name' => $user->name,
            'metadata' => ['user_id' => (string) $user->id],
        ]);

        ProviderCustomer::create([
            'user_id' => $user->id,
            'provider_key' => 'stripe',
            'provider_customer_id' => $customer->id,
        ]);

        return $customer->id;
    }

    /**
     * Synchronously verify a Stripe Checkout Session at the success-redirect
     * boundary and provision the local subscription, *without* waiting on
     * the `checkout.session.completed` webhook. Solves two problems:
     *
     *  1. Local dev where Stripe webhooks can't reach localhost — the
     *     happy path provisions on customer return without needing
     *     `stripe listen` running.
     *  2. Production race — customer's browser can land on /checkout/success
     *     a few hundred ms before the webhook arrives. Sync-verify means
     *     they see their plan immediately on next page load instead of
     *     "still on Free."
     *
     * Webhook stays as the canonical source — this just front-runs it for
     * the happy path. Idempotent (handleSubscriptionUpsert is upsert), so
     * a webhook arriving after we've already provisioned is a no-op.
     *
     * Returns true when provisioning ran, false when the session wasn't
     * complete yet (e.g. payment still processing).
     */
    public function verifyAndProvisionCheckoutSession(string $sessionId): bool
    {
        \Stripe\Stripe::setApiKey($this->requireApiKey());

        $session = \Stripe\Checkout\Session::retrieve([
            'id' => $sessionId,
            'expand' => ['subscription'],
        ]);

        // `payment_status === 'paid'` covers card flows; `status === 'complete'`
        // covers async-payment-method flows where payment is still
        // processing but the session is finalised. Either way, run the
        // upsert; the row's `status` will reflect Stripe's actual state.
        $sessionPaid = ($session->payment_status ?? null) === 'paid'
            || ($session->status ?? null) === 'complete';
        if (! $sessionPaid) {
            return false;
        }

        // Mirror the webhook switch: ProviderCustomer first, then the
        // subscription row. Both calls are idempotent so a webhook
        // arriving post-redirect is a no-op.
        $this->handleCheckoutSessionCompleted($session->toArray());

        $sub = $session->subscription ?? null;
        if (is_string($sub)) {
            $sub = \Stripe\Subscription::retrieve($sub);
        }
        if ($sub) {
            $this->handleSubscriptionUpsert($sub->toArray());
        }

        return true;
    }

    protected function handleCheckoutSessionCompleted(array $session): void
    {
        $userId = $session['client_reference_id'] ?? null;
        $customerId = $session['customer'] ?? null;
        if (! $userId || ! $customerId) {
            return;
        }

        ProviderCustomer::query()->updateOrCreate(
            ['user_id' => (int) $userId, 'provider_key' => 'stripe'],
            ['provider_customer_id' => $customerId],
        );
    }

    protected function handleSubscriptionUpsert(array $sub): void
    {
        $customerId = $sub['customer'] ?? null;
        $providerSubId = $sub['id'] ?? null;
        if (! $customerId || ! $providerSubId) {
            return;
        }

        $customerRow = ProviderCustomer::query()
            ->where('provider_key', 'stripe')
            ->where('provider_customer_id', $customerId)
            ->first();

        if (! $customerRow) {
            return;
        }

        $priceId = $sub['items']['data'][0]['price']['id'] ?? null;
        if (! $priceId) {
            return;
        }

        $price = PlanPrice::query()
            ->where('gateway_key', 'stripe')
            ->where('provider_price_id', $priceId)
            ->first();

        if (! $price) {
            return;
        }

        // Capture pre-update state so we can detect lifecycle transitions
        // (activation / plan change) after the upsert and dispatch the
        // matching customer notification. null = first ever upsert.
        $existing = Subscription::query()
            ->where('provider_key', 'stripe')
            ->where('provider_subscription_id', $providerSubId)
            ->first();
        $previousStatus = $existing?->status;
        $previousPlanId = $existing?->plan_id;

        $newStatus = $this->mapStripeStatus($sub['status'] ?? 'incomplete');

        $local = Subscription::query()->updateOrCreate(
            [
                'user_id' => $customerRow->user_id,
                'provider_key' => 'stripe',
                'provider_subscription_id' => $providerSubId,
            ],
            [
                'plan_id' => $price->plan_id,
                'currency_id' => $price->currency_id,
                'status' => $newStatus,
                'period' => $price->period,
                'trial_ends_at' => $this->unixToCarbon($sub['trial_end'] ?? null),
                'current_period_starts_at' => $this->unixToCarbon($sub['current_period_start'] ?? null),
                'current_period_ends_at' => $this->unixToCarbon($sub['current_period_end'] ?? null),
                'canceled_at' => $this->unixToCarbon($sub['canceled_at'] ?? null),
                'cancels_at_period_end' => (bool) ($sub['cancel_at_period_end'] ?? false),
                'is_test' => $this->isTestMode(),
            ],
        );

        // Activation = transition into active/trialing from a non-active
        // state (or first creation). Subsequent renewal upserts stay
        // in 'active' and won't re-trigger. Loaded fresh so the relations
        // (plan / user / currency) hydrate for the Mailable.
        $becameActive = in_array($newStatus, ['active', 'trialing'], true)
            && (! in_array((string) $previousStatus, ['active', 'trialing'], true));
        if ($becameActive) {
            $fresh = $local->fresh(['plan', 'user', 'currency']);
            \App\Services\NotificationDispatcher::subscriptionActivated($fresh);
            \App\Services\NotificationDispatcher::adminNewSubscription($fresh);
        }

        // Plan change = same sub row, different plan_id. We surface this
        // even when paired with the activation transition above is rare
        // (plans don't usually change on first activation) — gate to
        // avoid double-firing on first creation.
        if ($previousPlanId !== null && $previousPlanId !== $price->plan_id) {
            $previousPlan = \App\Models\Plan::query()->find($previousPlanId);
            \App\Services\NotificationDispatcher::planChanged($local->fresh(['plan', 'user', 'currency']), $previousPlan);
        }
    }

    /**
     * Look up the gateway price-id for a (plan, period, currency) tuple.
     * `$currencyId === null` falls back to the deployment's default currency,
     * resolved via `Currency::default()`. The default currency is used when:
     *   - The Pricing page hasn't yet wired a currency selector.
     *   - A subscriber's existing sub row has no `currency_id` (history rows
     *     created before the `plan_prices` rebuild back-filled currencies).
     */
    protected function resolvePriceId(Plan $plan, string $period, ?int $currencyId = null): ?string
    {
        $currencyId = $currencyId ?? $this->defaultCurrencyId();

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

        return PlanPrice::query()
            ->where('plan_id', $plan->id)
            ->where('currency_id', $currencyId)
            ->where('gateway_key', 'stripe')
            ->where('period', $period)
            ->where('is_active', true)
            ->value('provider_price_id');
    }

    protected function defaultCurrencyId(): ?int
    {
        return Currency::default()?->id;
    }

    protected function handleSubscriptionDeleted(array $sub): void
    {
        $providerSubId = $sub['id'] ?? null;
        if (! $providerSubId) {
            return;
        }

        $local = Subscription::query()
            ->where('provider_key', 'stripe')
            ->where('provider_subscription_id', $providerSubId)
            ->first();

        if (! $local) {
            return;
        }

        $wasNotCanceled = $local->status !== 'canceled';

        $local->update([
            'status' => 'canceled',
            'canceled_at' => $local->canceled_at ?? now(),
        ]);

        // Only dispatch on the cancellation transition — avoids
        // double-firing if Stripe replays the same delete event.
        if ($wasNotCanceled) {
            $fresh = $local->fresh(['plan', 'user', 'currency']);
            \App\Services\NotificationDispatcher::subscriptionCanceled($fresh);
            \App\Services\NotificationDispatcher::adminSubscriptionCanceled($fresh);
        }
    }

    protected function handleInvoicePaid(array $inv): void
    {
        $providerInvoiceId = $inv['id'] ?? null;
        if (! $providerInvoiceId) {
            return;
        }

        $subscriptionId = $this->resolveLocalSubscriptionId($inv);
        if (! $subscriptionId) {
            return;
        }

        Invoice::query()->updateOrCreate(
            [
                'provider_key' => 'stripe',
                'provider_invoice_id' => $providerInvoiceId,
            ],
            [
                'subscription_id' => $subscriptionId,
                'amount_cents' => (int) ($inv['amount_paid'] ?? 0),
                'currency' => strtoupper((string) ($inv['currency'] ?? 'usd')),
                'status' => (string) ($inv['status'] ?? 'paid'),
                'paid_at' => $this->unixToCarbon($inv['status_transitions']['paid_at'] ?? null) ?? now(),
                'hosted_url' => $inv['hosted_invoice_url'] ?? null,
            ] + $this->extractTaxFields($inv),
        );
    }

    protected function handleInvoicePaymentFailed(array $inv): void
    {
        $providerInvoiceId = $inv['id'] ?? null;
        if (! $providerInvoiceId) {
            return;
        }

        $subscriptionId = $this->resolveLocalSubscriptionId($inv);
        if (! $subscriptionId) {
            return;
        }

        $invoice = Invoice::query()->updateOrCreate(
            [
                'provider_key' => 'stripe',
                'provider_invoice_id' => $providerInvoiceId,
            ],
            [
                'subscription_id' => $subscriptionId,
                'amount_cents' => (int) ($inv['amount_due'] ?? $inv['amount_paid'] ?? 0),
                'currency' => strtoupper((string) ($inv['currency'] ?? 'usd')),
                'status' => (string) ($inv['status'] ?? 'open'),
                'paid_at' => null,
                'hosted_url' => $inv['hosted_invoice_url'] ?? null,
            ] + $this->extractTaxFields($inv),
        );

        // Admin-only notification — Stripe sends its own dunning emails
        // to the customer; admin signal is purely operational so they
        // see churn risk in their own inbox without leaving the panel.
        \App\Services\NotificationDispatcher::adminPaymentFailed($invoice->fresh('subscription'));
    }

    protected function resolveLocalSubscriptionId(array $inv): ?int
    {
        // Stripe API 2025+ removed Invoice.subscription in favor of
        // Invoice.parent.subscription_details.subscription. Try both —
        // the legacy field is still populated by older webhooks (replays,
        // older sandbox accounts), so reading it as a fallback keeps
        // pre-2025 events working.
        $stripeSubId = $inv['parent']['subscription_details']['subscription']
            ?? $inv['subscription']
            ?? null;
        if (! $stripeSubId) {
            return null;
        }

        return Subscription::query()
            ->where('provider_key', 'stripe')
            ->where('provider_subscription_id', $stripeSubId)
            ->value('id');
    }

    protected function unixToCarbon(?int $timestamp): ?Carbon
    {
        if (! $timestamp) {
            return null;
        }

        return Carbon::createFromTimestamp($timestamp);
    }

    /**
     * Pull tax fields off a Stripe invoice payload (webhook or API). When
     * Stripe Tax is enabled merchant-side, every invoice carries
     * `total_tax_amounts[]` — sum the amounts and read the first applicable
     * `tax_rate` for the rate / label / jurisdiction. When Stripe Tax is
     * NOT enabled, the field is absent or empty and we return zero defaults
     * so the row still writes cleanly.
     *
     * @return array{tax_cents: int, tax_rate_bps: ?int, tax_label: ?string, tax_country: ?string}
     */
    protected function extractTaxFields(array $inv): array
    {
        // Admin toggle off → never surface tax breakdown locally, even if
        // Stripe's invoice somehow carries tax amounts (e.g. an admin
        // flipped the toggle off after a subscription was already running
        // with tax). The hosted invoice on Stripe still shows the real
        // breakdown; we just stop mirroring it.
        if (! $this->taxEnabled()) {
            return ['tax_cents' => 0, 'tax_rate_bps' => null, 'tax_label' => null, 'tax_country' => null];
        }
        $taxAmounts = $inv['total_tax_amounts'] ?? [];
        if (! is_array($taxAmounts) || $taxAmounts === []) {
            return ['tax_cents' => 0, 'tax_rate_bps' => null, 'tax_label' => null, 'tax_country' => null];
        }

        $taxCents = 0;
        foreach ($taxAmounts as $row) {
            $taxCents += (int) ($row['amount'] ?? 0);
        }

        $first = $taxAmounts[0] ?? [];
        $rate = $first['tax_rate'] ?? null;
        // tax_rate is delivered as either an id (string, needs expand) or
        // the inline object — we only consume the inline shape so the
        // breakdown stays optional when the merchant hasn't expanded it.
        $rateObj = is_array($rate) ? $rate : null;
        $percentage = $rateObj['percentage'] ?? null;
        $rateBps = is_numeric($percentage) ? (int) round(((float) $percentage) * 100) : null;
        $label = is_string($rateObj['display_name'] ?? null) ? substr($rateObj['display_name'], 0, 32) : null;
        $country = is_string($rateObj['country'] ?? null) ? strtoupper($rateObj['country']) : null;

        return [
            'tax_cents' => $taxCents,
            'tax_rate_bps' => $rateBps,
            'tax_label' => $label,
            'tax_country' => $country,
        ];
    }

    /**
     * Stripe statuses → local statuses. `unpaid` and `incomplete_expired`
     * collapse to nearest local enum because the local set is intentionally
     * narrower (the editor / UI only branch on a small handful of states).
     *
     * - `unpaid` (Stripe gave up retrying) → `past_due` so the
     *   gating UI keeps treating the user as a delinquent payer rather
     *   than as a brand-new lapsed/canceled one.
     * - `incomplete_expired` (initial checkout never paid through) → `expired`.
     */
    protected function mapStripeStatus(string $stripeStatus): string
    {
        return match ($stripeStatus) {
            'active' => 'active',
            'trialing' => 'trialing',
            'past_due' => 'past_due',
            'canceled' => 'canceled',
            'incomplete' => 'incomplete',
            'incomplete_expired' => 'expired',
            'unpaid' => 'past_due',
            default => $stripeStatus,
        };
    }

    /**
     * Persist a Stripe refund event to the local invoice row, regardless
     * of whether the refund originated from our admin flow or from
     * Stripe's own dashboard. The webhook payload's `object` is the
     * **charge** (not the refund), with `refunds.data[]` containing the
     * actual refund records and `amount_refunded` for total-so-far.
     *
     * Idempotent — repeated webhooks (or admin-flow + webhook race) just
     * re-mark the row as 'refunded'. Partial refunds also flip the local
     * row to 'refunded' on first webhook because our schema is one-status-
     * per-invoice; multi-partial-refund granularity isn't tracked yet.
     */
    protected function handleChargeRefunded(array $charge): void
    {
        $invoiceId = $charge['invoice'] ?? null;
        if (! $invoiceId) {
            // Standalone charge refund (not subscription-tied) —
            // we don't track those locally yet.
            Log::info('stripe: charge.refunded for non-invoice charge', [
                'charge_id' => $charge['id'] ?? null,
            ]);

            return;
        }

        $local = Invoice::query()
            ->where('provider_key', 'stripe')
            ->where('provider_invoice_id', $invoiceId)
            ->first();

        if (! $local) {
            Log::info('stripe: charge.refunded for untracked invoice', [
                'stripe_invoice_id' => $invoiceId,
                'charge_id' => $charge['id'] ?? null,
            ]);

            return;
        }

        if ($local->status === 'refunded') {
            return;
        }

        $local->update(['status' => 'refunded']);

        // Webhook payload carries the running total refunded so far.
        // Idempotent — repeated webhooks for partial refunds would
        // dispatch again, but Stripe coalesces partial-refund webhooks
        // so this fires once per refund event in practice.
        $amountRefundedMinor = (int) ($charge['amount_refunded'] ?? $local->amount_cents);
        $freshInvoice = $local->fresh('subscription');
        \App\Services\NotificationDispatcher::refundIssued($freshInvoice, $amountRefundedMinor);
        \App\Services\NotificationDispatcher::adminRefundIssued($freshInvoice, $amountRefundedMinor);

        Log::info('stripe: invoice marked refunded via webhook', [
            'invoice_id' => $local->id,
            'stripe_charge_id' => $charge['id'] ?? null,
            'amount_refunded' => $charge['amount_refunded'] ?? null,
        ]);
    }

    /**
     * Mark an invoice as 'disputed' when a customer (or their bank)
     * opens a chargeback. Stripe holds the funds; visibility in the
     * admin invoice list keeps fraud awareness honest.
     */
    protected function handleDisputeCreated(array $dispute): void
    {
        $chargeId = $dispute['charge'] ?? null;
        if (! $chargeId) {
            return;
        }

        // Charge → Invoice lookup: the charge object's `invoice` field
        // links back to the Stripe invoice id. We need to fetch the
        // charge to get this since the dispute payload doesn't carry it.
        try {
            \Stripe\Stripe::setApiKey($this->requireApiKey());
            $charge = \Stripe\Charge::retrieve($chargeId);
            $stripeInvoiceId = $charge->invoice ?? null;
        } catch (Throwable $e) {
            Log::warning('stripe: could not fetch charge for dispute', [
                'charge_id' => $chargeId,
                'error' => $e->getMessage(),
            ]);

            return;
        }

        if (! $stripeInvoiceId) {
            return;
        }

        $local = Invoice::query()
            ->where('provider_key', 'stripe')
            ->where('provider_invoice_id', $stripeInvoiceId)
            ->first();

        if (! $local) {
            return;
        }

        $local->update(['status' => 'disputed']);

        Log::warning('stripe: invoice marked disputed (chargeback opened)', [
            'invoice_id' => $local->id,
            'stripe_dispute_id' => $dispute['id'] ?? null,
            'reason' => $dispute['reason'] ?? null,
            'amount' => $dispute['amount'] ?? null,
        ]);
    }

    /**
     * Resolve a dispute. Stripe's `status` field on the dispute object
     * is the outcome:
     *   - `won`           → funds returned to merchant; flip to 'paid'
     *   - `lost`          → funds gone; keep 'disputed' for visibility
     *   - `warning_closed` → handled outside the dispute system; back to 'paid'
     */
    protected function handleDisputeClosed(array $dispute): void
    {
        $chargeId = $dispute['charge'] ?? null;
        if (! $chargeId) {
            return;
        }

        try {
            \Stripe\Stripe::setApiKey($this->requireApiKey());
            $charge = \Stripe\Charge::retrieve($chargeId);
            $stripeInvoiceId = $charge->invoice ?? null;
        } catch (Throwable $e) {
            return;
        }

        if (! $stripeInvoiceId) {
            return;
        }

        $local = Invoice::query()
            ->where('provider_key', 'stripe')
            ->where('provider_invoice_id', $stripeInvoiceId)
            ->first();

        if (! $local) {
            return;
        }

        $newStatus = match ($dispute['status'] ?? '') {
            'won', 'warning_closed' => 'paid',
            default => 'disputed',
        };

        $local->update(['status' => $newStatus]);

        Log::info('stripe: dispute closed', [
            'invoice_id' => $local->id,
            'stripe_dispute_id' => $dispute['id'] ?? null,
            'outcome' => $dispute['status'] ?? null,
            'local_status' => $newStatus,
        ]);
    }
}
