<?php
/**
 * Subscription Service
 * 
 * Handles recurring billing via Stripe Subscriptions API
 * - Creates Stripe customers and subscriptions
 * - Processes webhook events for renewals/failures
 * - Syncs subscription status
 * 
 * @package Instinctor
 * @since 1.4.0
 */

declare(strict_types=1);

namespace Instinctor\Services;

use Instinctor\Core\Config;
use Instinctor\Core\DB;
use Instinctor\Core\Router;

if ( ! defined( 'ABSPATH' ) ) exit;

class SubscriptionService
{
    private static ?self $instance = null;
    
    public static function instance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct()
    {
        // Register webhook endpoint.
        // rest_api_init never fires on standalone, so register directly there
        // (matches BookingService / ChatService) or the Stripe webhook 404s.
        add_action('rest_api_init', [$this, 'registerWebhook']);
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
            $this->registerWebhook();
        }

        // Cron for subscription renewals and status sync
        add_action('instinctor_cron_hourly', [$this, 'syncSubscriptionStatuses']);
        add_action('instinctor_process_subscription_renewal', [$this, 'processRenewal'], 10, 1);
    }

    /**
     * Register Stripe webhook endpoint
     */
    public function registerWebhook(): void
    {
        Router::restRaw('/stripe-webhook', [
            'methods' => 'POST',
            'callback' => [$this, 'handleWebhook'],
            'permission_callback' => '__return_true'
        ]);
    }

    /**
     * Get Stripe API key
     */
    private function getStripeKey(): ?string
    {
        $key = Config::get('instinctor_stripe_secret_key', '');
        return !empty($key) ? $key : null;
    }

    /**
     * Check if cart contains subscription products
     */
    public function cartHasSubscription(array $cart): bool
    {
        $p = DB::prefix();

        foreach ($cart['items'] as $item) {
            $ptype = DB::get_var(DB::prepare(
                "SELECT product_type FROM {$p}instinctor_products WHERE id = %d",
                $item['product_id']
            ));
            if ($ptype === 'subscription') {
                return true;
            }
        }

        return false;
    }

    /**
     * Get subscription items from cart
     */
    public function getSubscriptionItems(array $cart): array
    {
        $p = DB::prefix();
        $subscriptionItems = [];

        foreach ($cart['items'] as $item) {
            $product = DB::get_row(DB::prepare(
                "SELECT * FROM {$p}instinctor_products WHERE id = %d",
                $item['product_id']
            ));

            if ($product && $product->product_type === 'subscription') {
                $meta = json_decode($product->meta ?? '{}', true);
                $subscriptionItems[] = [
                    'product_id'     => $item['product_id'],
                    'product'        => $product,
                    'meta'           => $meta,
                    'quantity'       => $item['quantity'],
                    'price'          => (float) $product->price,
                    'period'         => $meta['subscription_interval'] ?? 'monthly',
                    'interval_count' => (int) ($meta['subscription_interval_count'] ?? 1),
                    'trial_days'     => (int) ($meta['trial_days'] ?? 0),
                ];
            }
        }

        return $subscriptionItems;
    }

    /**
     * Get or create Stripe customer
     * Stores Stripe customer ID in customer meta JSON
     */
    public function getOrCreateStripeCustomer(int $customerId, array $billing): ?string
    {
        $stripeKey = $this->getStripeKey();
        
        if (!$stripeKey) {
            return null;
        }

        // Check if customer already has Stripe ID in meta
        $customer = DB::get_row(DB::prepare(
            "SELECT meta FROM {$p}instinctor_customers WHERE id = %d",
            $customerId
        ));

        if ($customer) {
            $meta = json_decode($customer->meta ?? '{}', true);
            if (!empty($meta['stripe_customer_id'])) {
                return $meta['stripe_customer_id'];
            }
        }

        // Create Stripe customer
        $response = wp_remote_post('https://api.stripe.com/v1/customers', [
            'headers' => [
                'Authorization' => 'Bearer ' . $stripeKey,
                'Content-Type' => 'application/x-www-form-urlencoded'
            ],
            'body' => [
                'email' => sanitize_email($billing['email']),
                'name' => sanitize_text_field($billing['first_name'] . ' ' . $billing['last_name']),
                'metadata[instinctor_customer_id]' => $customerId
            ]
        ]);

        if (is_wp_error($response)) {
            return null;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        
        if (!empty($body['id'])) {
            // Save Stripe customer ID in meta
            $meta = json_decode($customer->meta ?? '{}', true);
            $meta['stripe_customer_id'] = $body['id'];
            
            DB::update(
                DB::prefix() . 'instinctor_customers',
                ['meta' => json_encode($meta)],
                ['id' => $customerId]
            );
            return $body['id'];
        }

        return null;
    }

    /**
     * Create or get Stripe Price for a product
     */
    public function getOrCreateStripePrice(int $productId, float $amount, string $period, int $intervalCount = 1): ?string
    {
        $stripeKey = $this->getStripeKey();

        if (!$stripeKey) {
            return null;
        }

        $product = DB::get_row(DB::prepare(
            "SELECT name, meta FROM {$p}instinctor_products WHERE id = %d",
            $productId
        ));

        if (!$product) {
            return null;
        }

        $meta = json_decode($product->meta ?? '{}', true);
        // Cache key includes interval_count so "every 3 months" gets its own Stripe price
        $priceKey = 'stripe_price_' . $period . ($intervalCount > 1 ? '_x' . $intervalCount : '');

        if (!empty($meta[$priceKey])) {
            return $meta[$priceKey];
        }

        $intervalMap = ['weekly' => 'week', 'monthly' => 'month', 'yearly' => 'year'];
        $stripeInterval = $intervalMap[$period] ?? 'month';
        $intervalData = ['interval' => $stripeInterval, 'interval_count' => max(1, $intervalCount)];
        $currency = strtolower(Config::get('instinctor_currency', 'USD'));

        // First create a Stripe Product (if not exists)
        $stripeProductId = $meta['stripe_product_id'] ?? null;
        
        if (!$stripeProductId) {
            $productResponse = wp_remote_post('https://api.stripe.com/v1/products', [
                'headers' => [
                    'Authorization' => 'Bearer ' . $stripeKey,
                    'Content-Type' => 'application/x-www-form-urlencoded'
                ],
                'body' => [
                    'name' => $product->name,
                    'metadata[instinctor_product_id]' => $productId
                ]
            ]);

            if (!is_wp_error($productResponse)) {
                $productBody = json_decode(wp_remote_retrieve_body($productResponse), true);
                if (!empty($productBody['id'])) {
                    $stripeProductId = $productBody['id'];
                    $meta['stripe_product_id'] = $stripeProductId;
                }
            }
        }

        if (!$stripeProductId) {
            return null;
        }

        // Create recurring price
        $priceResponse = wp_remote_post('https://api.stripe.com/v1/prices', [
            'headers' => [
                'Authorization' => 'Bearer ' . $stripeKey,
                'Content-Type' => 'application/x-www-form-urlencoded'
            ],
            'body' => [
                'product' => $stripeProductId,
                'unit_amount' => (int)($amount * 100), // Stripe uses cents
                'currency' => $currency,
                'recurring[interval]'       => $intervalData['interval'],
                'recurring[interval_count]' => (string) $intervalData['interval_count'],
                'metadata[instinctor_product_id]' => $productId,
                'metadata[period]' => $period
            ]
        ]);

        if (is_wp_error($priceResponse)) {
            return null;
        }

        $priceBody = json_decode(wp_remote_retrieve_body($priceResponse), true);
        
        if (!empty($priceBody['id'])) {
            // Save price ID to product meta
            $meta[$priceKey] = $priceBody['id'];
            DB::update(
                DB::prefix() . 'instinctor_products',
                ['meta' => json_encode($meta)],
                ['id' => $productId]
            );
            return $priceBody['id'];
        }

        return null;
    }

    /**
     * Create Stripe Checkout Session for subscription
     */
    public function createSubscriptionCheckout(
        string $stripeCustomerId,
        array $subscriptionItems,
        int $orderId,
        string $successUrl,
        string $cancelUrl
    ): array {
        $stripeKey = $this->getStripeKey();
        
        if (!$stripeKey) {
            return ['success' => false, 'error' => 'Stripe not configured'];
        }

        // Build line items
        $lineItems = [];
        $trialDays = 0;
        foreach ($subscriptionItems as $item) {
            $priceId = $this->getOrCreateStripePrice(
                $item['product_id'],
                $item['price'],
                $item['period'],
                $item['interval_count'] ?? 1
            );
            if (!empty($item['trial_days']) && $item['trial_days'] > $trialDays) {
                $trialDays = (int) $item['trial_days'];
            }
            
            if (!$priceId) {
                return ['success' => false, 'error' => 'Failed to create Stripe price'];
            }

            $lineItems[] = [
                'price' => $priceId,
                'quantity' => $item['quantity']
            ];
        }

        // Create checkout session
        $body = [
            'customer' => $stripeCustomerId,
            'mode' => 'subscription',
            'success_url' => $successUrl,
            'cancel_url' => $cancelUrl,
            'metadata[instinctor_order_id]' => $orderId,
            'subscription_data[metadata][instinctor_order_id]' => $orderId
        ];

        if ($trialDays > 0) {
            $body['subscription_data[trial_period_days]'] = (string) $trialDays;
        }

        // Add line items
        foreach ($lineItems as $i => $lineItem) {
            $body["line_items[$i][price]"] = $lineItem['price'];
            $body["line_items[$i][quantity]"] = $lineItem['quantity'];
        }

        $response = wp_remote_post('https://api.stripe.com/v1/checkout/sessions', [
            'headers' => [
                'Authorization' => 'Bearer ' . $stripeKey,
                'Content-Type' => 'application/x-www-form-urlencoded'
            ],
            'body' => $body
        ]);

        if (is_wp_error($response)) {
            return ['success' => false, 'error' => $response->get_error_message()];
        }

        $responseBody = json_decode(wp_remote_retrieve_body($response), true);
        
        if (!empty($responseBody['id']) && !empty($responseBody['url'])) {
            return [
                'success' => true,
                'session_id' => $responseBody['id'],
                'checkout_url' => $responseBody['url']
            ];
        }

        return [
            'success' => false,
            'error' => $responseBody['error']['message'] ?? 'Failed to create checkout session'
        ];
    }

    /**
     * Create subscription directly (for payment method already on file)
     */
    public function createSubscription(
        string $stripeCustomerId,
        string $stripePriceId,
        int $instinctorCustomerId,
        int $productId
    ): array {
        $stripeKey = $this->getStripeKey();
        
        if (!$stripeKey) {
            return ['success' => false, 'error' => 'Stripe not configured'];
        }

        $response = wp_remote_post('https://api.stripe.com/v1/subscriptions', [
            'headers' => [
                'Authorization' => 'Bearer ' . $stripeKey,
                'Content-Type' => 'application/x-www-form-urlencoded'
            ],
            'body' => [
                'customer' => $stripeCustomerId,
                'items[0][price]' => $stripePriceId,
                'metadata[instinctor_customer_id]' => $instinctorCustomerId,
                'metadata[instinctor_product_id]' => $productId,
                'payment_behavior' => 'default_incomplete',
                'expand[]' => 'latest_invoice.payment_intent'
            ]
        ]);

        if (is_wp_error($response)) {
            return ['success' => false, 'error' => $response->get_error_message()];
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        
        if (!empty($body['id'])) {
            // Get product details for period
            $product = DB::get_row(DB::prepare(
                "SELECT meta FROM {$p}instinctor_products WHERE id = %d",
                $productId
            ));
            $meta = json_decode($product->meta ?? '{}', true);
            $period = $meta['subscription_interval'] ?? 'monthly';
            $price = $body['items']['data'][0]['price']['unit_amount'] / 100;

            // Create local subscription record
            DB::insert(DB::prefix() . 'instinctor_subscriptions', [
                'customer_id' => $instinctorCustomerId,
                'product_id' => $productId,
                'external_id' => $body['id'],
                'stripe_customer_id' => $stripeCustomerId,
                'price' => $price,
                'period' => $period,
                'status' => $this->mapStripeStatus($body['status']),
                // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
                'current_period_start' => date('Y-m-d H:i:s', $body['current_period_start']),
                // phpcs:enable WordPress.DateTime.RestrictedFunctions
                // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
                'current_period_end' => date('Y-m-d H:i:s', $body['current_period_end']),
                // phpcs:enable WordPress.DateTime.RestrictedFunctions
                'created_at' => current_time('mysql')
            ]);

            $subscriptionId = DB::insert_id();

            // If subscription needs payment confirmation
            if ($body['status'] === 'incomplete' && !empty($body['latest_invoice']['payment_intent'])) {
                return [
                    'success' => true,
                    'subscription_id' => $subscriptionId,
                    'external_id' => $body['id'],
                    'requires_action' => true,
                    'client_secret' => $body['latest_invoice']['payment_intent']['client_secret']
                ];
            }

            return [
                'success' => true,
                'subscription_id' => $subscriptionId,
                'external_id' => $body['id']
            ];
        }

        return [
            'success' => false,
            'error' => $body['error']['message'] ?? 'Failed to create subscription'
        ];
    }

    /**
     * Handle Stripe webhook events
     */
    public function handleWebhook(\WP_REST_Request $request): \WP_REST_Response
    {
        $payload = $request->get_body();
        $sigHeader = $request->get_header('stripe-signature');
        $webhookSecret = Config::get('instinctor_stripe_webhook_secret', '');

        // Verify signature if webhook secret is configured
        if ($webhookSecret) {
            if (!$sigHeader) {
                // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture / one-time migration logging, not debug output.
                error_log('Instinctor Stripe webhook: missing signature header');
                // phpcs:enable WordPress.PHP.DevelopmentFunctions
                return new \WP_REST_Response(['error' => 'Missing signature'], 400);
            }
            
            $timestamp = null;
            $signature = null;
            
            foreach (explode(',', $sigHeader) as $part) {
                $eqPos = strpos($part, '=');
                if ($eqPos === false) continue;
                $key = substr($part, 0, $eqPos);
                $value = substr($part, $eqPos + 1);
                if ($key === 't') $timestamp = $value;
                if ($key === 'v1') $signature = $value;
            }

            if (!$timestamp || !$signature) {
                return new \WP_REST_Response(['error' => 'Invalid signature format'], 400);
            }
            
            // Replay protection: reject events older than 5 minutes
            if (abs(time() - (int) $timestamp) > 300) {
                // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture / one-time migration logging, not debug output.
                error_log('Instinctor Stripe webhook: timestamp too old');
                // phpcs:enable WordPress.PHP.DevelopmentFunctions
                return new \WP_REST_Response(['error' => 'Timestamp too old'], 400);
            }
            
            $signedPayload = $timestamp . '.' . $payload;
            $expectedSig = hash_hmac('sha256', $signedPayload, $webhookSecret);
            
            if (!hash_equals($expectedSig, $signature)) {
                // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture / one-time migration logging, not debug output.
                error_log('Instinctor Stripe webhook: signature mismatch');
                // phpcs:enable WordPress.PHP.DevelopmentFunctions
                return new \WP_REST_Response(['error' => 'Invalid signature'], 400);
            }
        }

        $event = json_decode($payload, true);
        
        if (!$event || empty($event['type'])) {
            return new \WP_REST_Response(['error' => 'Invalid payload'], 400);
        }

        $this->processWebhookEvent($event);

        return new \WP_REST_Response(['received' => true], 200);
    }

    /**
     * Process webhook event
     */
    private function processWebhookEvent(array $event): void
    {
        $table = DB::prefix() . 'instinctor_subscriptions';
        $object = $event['data']['object'] ?? [];

        switch ($event['type']) {
            case 'customer.subscription.created':
            case 'customer.subscription.updated':
                if (!empty($object['id'])) {
                    DB::update($table, [
                        'status' => $this->mapStripeStatus($object['status']),
                        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
                        'current_period_start' => date('Y-m-d H:i:s', $object['current_period_start']),
                        // phpcs:enable WordPress.DateTime.RestrictedFunctions
                        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
                        'current_period_end' => date('Y-m-d H:i:s', $object['current_period_end']),
                        // phpcs:enable WordPress.DateTime.RestrictedFunctions
                        'cancel_at_period_end' => !empty($object['cancel_at_period_end']) ? 1 : 0
                    ], ['external_id' => $object['id']]);
                }
                break;

            case 'customer.subscription.deleted':
                if (!empty($object['id'])) {
                    $cancelledSub = DB::get_row(DB::prepare(
                        "SELECT * FROM {$table} WHERE external_id = %s",
                        $object['id']
                    ));
                    DB::update($table, [
                        'status' => 'cancelled',
                        'cancelled_at' => current_time('mysql')
                    ], ['external_id' => $object['id']]);

                    // Send cancellation email — access_until = end of current period
                    if ($cancelledSub) {
                        $accessUntil = !empty($object['current_period_end'])
                            ? date_i18n(Config::get('date_format', 'Y-m-d'), $object['current_period_end'])
                            : '';
                        $this->sendSubscriptionEmail('subscription_cancelled', (int) $cancelledSub->id, [
                            'access_until' => $accessUntil,
                        ]);

                        // Revoke downloadable access tied to this subscription.
                        // Stripe fires customer.subscription.deleted when the
                        // subscription is actually terminated (either immediately,
                        // or at period end if cancel_at_period_end was set).
                        DigitalDownloadService::instance()->revokeSubscriptionAccess(
                            (int) $cancelledSub->id
                        );
                    }
                }
                break;

            case 'invoice.payment_succeeded':
                // Renewal payment succeeded
                if (!empty($object['subscription'])) {
                    $subscription = DB::get_row(DB::prepare(
                        "SELECT * FROM {$table} WHERE external_id = %s",
                        $object['subscription']
                    ));
                    
                    if ($subscription) {
                        // Update status to active
                        DB::update($table, [
                            'status' => 'active',
                            'last_payment_at' => current_time('mysql')
                        ], ['id' => $subscription->id]);

                        // Create renewal order
                        $this->createRenewalOrder($subscription, $object);

                        // Send renewal confirmation email
                        $this->sendSubscriptionEmail('subscription_renewal', (int) $subscription->id, [
                            'renewal_amount'    => '$' . number_format(($object['amount_paid'] ?? 0) / 100, 2),
                            'next_renewal_date' => isset($object['lines']['data'][0]['period']['end'])
                                ? date_i18n(Config::get('date_format', 'Y-m-d'), $object['lines']['data'][0]['period']['end'])
                                : '',
                        ]);
                    }
                }
                break;

            case 'invoice.payment_failed':
                // Payment failed
                if (!empty($object['subscription'])) {
                    DB::update($table, [
                        'status' => 'past_due'
                    ], ['external_id' => $object['subscription']]);

                    // Send dunning email via EmailService
                    $failedSub = DB::get_row(DB::prepare(
                        "SELECT * FROM {$table} WHERE external_id = %s",
                        $object['subscription']
                    ));
                    if ($failedSub) {
                        $this->sendSubscriptionEmail('payment_failed', (int) $failedSub->id, []);
                    }
                }
                break;

            case 'payment_intent.succeeded':
                // One-time payment confirmed by Stripe — ensure order is marked paid
                // This catches cases where browser closed after 3DS auth but before /checkout/confirm
                $piId = $object['id'] ?? '';
                if ($piId) {
                    $ordersTable = DB::prefix() . 'instinctor_orders';
                    $order = DB::get_row(DB::prepare(
                        "SELECT id, status, payment_status FROM {$ordersTable} WHERE transaction_id = %s",
                        $piId
                    ));
                    
                    if ($order && in_array($order->status, ['pending', 'failed'], true)) {
                        DB::update($ordersTable, [
                            'status' => 'processing',
                            'payment_status' => 'paid',
                        ], ['id' => $order->id]);
                        
                        // Run full post-payment finalization (stock, email, gift cards, etc.)
                        CheckoutService::finalize((int) $order->id);
                    }
                }
                break;

            case 'checkout.session.completed':
                // Subscription checkout completed — fallback if browser didn't redirect
                $sessionOrderId = (int) ($object['metadata']['instinctor_order_id'] ?? 0);
                $paymentStatus = $object['payment_status'] ?? '';
                
                if ($sessionOrderId && $paymentStatus === 'paid') {
                    $ordersTable = DB::prefix() . 'instinctor_orders';
                    $order = DB::get_row(DB::prepare(
                        "SELECT id, status, customer_id FROM {$ordersTable} WHERE id = %d",
                        $sessionOrderId
                    ));
                    
                    if ($order && in_array($order->status, ['pending', 'failed'], true)) {
                        $stripeSubscriptionId = $object['subscription'] ?? null;
                        
                        // Update order payment info
                        DB::update($ordersTable, [
                            'status' => 'processing',
                            'payment_status' => 'paid',
                            'transaction_id' => $stripeSubscriptionId ?? $object['id'],
                        ], ['id' => $sessionOrderId]);
                        
                        // Create local subscription record if not exists
                        if ($stripeSubscriptionId) {
                            $existing = DB::get_var(DB::prepare(
                                "SELECT id FROM {$table} WHERE external_id = %s",
                                $stripeSubscriptionId
                            ));
                            
                            if (!$existing) {
                                $p = DB::prefix();
                                // Fetch subscription details from Stripe
                                $stripeKey = $this->getStripeKey();
                                if ($stripeKey) {
                                    $subResponse = wp_remote_get(
                                        'https://api.stripe.com/v1/subscriptions/' . $stripeSubscriptionId,
                                        ['headers' => ['Authorization' => 'Bearer ' . $stripeKey]]
                                    );
                                    
                                    if (!is_wp_error($subResponse)) {
                                        $sub = json_decode(wp_remote_retrieve_body($subResponse), true);
                                        
                                        if (!empty($sub['id'])) {
                                            $orderItem = DB::get_row(DB::prepare(
                                                "SELECT product_id FROM {$p}instinctor_order_items WHERE order_id = %d LIMIT 1",
                                                $sessionOrderId
                                            ));
                                            
                                            DB::insert($table, [
                                                'customer_id' => $order->customer_id ?? 0,
                                                'product_id' => $orderItem->product_id ?? 0,
                                                'external_id' => $sub['id'],
                                                'price' => ($sub['items']['data'][0]['price']['unit_amount'] ?? 0) / 100,
                                                'period' => $sub['items']['data'][0]['price']['recurring']['interval'] ?? 'month',
                                                'status' => $this->mapStripeStatus($sub['status']),
                                                'payment_method' => 'stripe',
                                                // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
                                                'current_period_start' => date('Y-m-d H:i:s', $sub['current_period_start']),
                                                // phpcs:enable WordPress.DateTime.RestrictedFunctions
                                                // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
                                                'current_period_end' => date('Y-m-d H:i:s', $sub['current_period_end']),
                                                // phpcs:enable WordPress.DateTime.RestrictedFunctions
                                                'created_at' => current_time('mysql')
                                            ]);
                                        }
                                    }
                                }
                            }
                        }
                        
                        // Run full finalization
                        CheckoutService::finalize($sessionOrderId);
                    }
                }
                break;
        }
    }

    /**
     * Map Stripe status to Instinctor status
     */
    private function mapStripeStatus(string $stripeStatus): string
    {
        $map = [
            'active' => 'active',
            'past_due' => 'past_due',
            'unpaid' => 'past_due',
            'canceled' => 'cancelled',
            'incomplete' => 'pending',
            'incomplete_expired' => 'expired',
            'trialing' => 'active',
            'paused' => 'paused'
        ];

        return $map[$stripeStatus] ?? 'pending';
    }

    /**
     * Create a renewal order for successful subscription payment
     */
    private function createRenewalOrder(object $subscription, array $invoice): void
    {

        $orderNumber = 'SUB-' . strtoupper(wp_generate_password(8, false));
        
        DB::insert(DB::prefix() . 'instinctor_orders', [
            'order_number' => $orderNumber,
            'customer_id' => $subscription->customer_id,
            'status' => 'completed',
            'subtotal' => $invoice['amount_paid'] / 100,
            'total' => $invoice['amount_paid'] / 100,
            'payment_status' => 'paid',
            'payment_method' => 'stripe_subscription',
            'transaction_id' => $invoice['id'],
            'meta' => json_encode([
                'subscription_id' => $subscription->id,
                'external_id' => $subscription->external_id,
                'renewal' => true
            ]),
            'created_at' => current_time('mysql')
        ]);

        $orderId = DB::insert_id();

        // Add order item
        if ($subscription->product_id) {
            $product = DB::get_row(DB::prepare(
                "SELECT name FROM {$p}instinctor_products WHERE id = %d",
                $subscription->product_id
            ));

            DB::insert(DB::prefix() . 'instinctor_order_items', [
                'order_id' => $orderId,
                'product_id' => $subscription->product_id,
                'name' => ($product->name ?? 'Subscription') . ' (Renewal)',
                'price' => $subscription->price,
                'quantity' => 1,
                'total' => $subscription->price
            ]);
        }
        
        // Finalize renewal order (customer stats, confirmation email, etc.)
        if ($orderId) {
            CheckoutService::finalize($orderId);
        }
    }

    /**
     * Send a subscription lifecycle email via EmailService.
     * Looks up customer data from the subscription record.
     */
    private function sendSubscriptionEmail(string $type, int $subscriptionId, array $extra = []): void
    {

        $row = DB::get_row(DB::prepare(
            "SELECT s.*, c.email, c.first_name, c.last_name
             FROM {$p}instinctor_subscriptions s
             LEFT JOIN {$p}instinctor_customers c ON c.id = s.customer_id
             WHERE s.id = %d",
            $subscriptionId
        ));

        if (!$row || !$row->email) {
            return;
        }

        // Resolve license key for this customer
        $licenseKey = DB::get_var(DB::prepare(
            "SELECT license_key FROM {$p}instinctor_licenses
             WHERE customer_id = %d AND status = 'active'
             ORDER BY created_at DESC LIMIT 1",
            $row->customer_id
        )) ?? '';

        $pricingUrl = Config::get('instinctor_pricing_page_url', home_url('/pricing/'));
        $accountUrl = Config::get('instinctor_account_page_url', home_url('/my-account/'));
        $updatePaymentUrl = $accountUrl . '?tab=billing';
        $planName = $this->getPlanLabel((string) ($row->period ?? 'month'));

        $data = array_merge([
            '{customer_name}'      => trim(($row->first_name ?? '') . ' ' . ($row->last_name ?? '')) ?: $row->email,
            '{plan_name}'          => $planName,
            '{license_key}'        => $licenseKey,
            '{account_url}'        => $accountUrl,
            '{update_payment_url}' => $updatePaymentUrl,
            '{resubscribe_url}'    => $pricingUrl,
            '{upgrade_url}'        => $pricingUrl,
            '{whats_new}'          => Config::get('instinctor_whats_new', ''),
        ], $extra);

        EmailService::send($type, $row->email, $data);
    }

    /**
     * Human-readable plan label from billing period
     */
    private function getPlanLabel(string $period): string
    {
        $map = ['month' => 'Monthly', 'year' => 'Annual', 'week' => 'Weekly'];
        return $map[$period] ?? ucfirst($period);
    }

    /**
     * Update the stored card for an Authorize.net subscription. The new card
     * arrives as Accept.js opaque data; we update the existing CIM payment
     * profile, so the ARB subscription keeps billing the same profile with the
     * new card on the next cycle.
     */
    /**
     * Read the masked card Authorize.net has vaulted for this subscription.
     * Returns ['brand','last4','expiry'] or null. We never hold the card —
     * this asks Authorize.net (getCustomerPaymentProfile) where it's vaulted.
     */
    public function getCardOnFile(int $subscriptionId): ?array
    {
        list($profileId, $payProfileId, $auth, $endpoint) = $this->anProfileContext($subscriptionId);
        if (!$profileId || !$payProfileId) return null;
        $payload = ['getCustomerPaymentProfileRequest' => [
            'merchantAuthentication'  => $auth,
            'customerProfileId'       => (string) $profileId,
            'customerPaymentProfileId'=> (string) $payProfileId,
            'unmaskExpirationDate'    => true,
        ]];
        $data = $this->anRead($endpoint, $payload);
        if ($data === null) return null;
        $cc = $data['paymentProfile']['payment']['creditCard'] ?? [];
        if (empty($cc['cardNumber'])) return null;
        $exp = (string) ($cc['expirationDate'] ?? '');
        if ($exp && strpos($exp, '-') !== false) { $pp = explode('-', $exp); $exp = $pp[1] . '/' . substr($pp[0], -2); }
        return [
            'brand'  => (string) ($cc['cardType'] ?? 'Card'),
            'last4'  => substr(preg_replace('/[^0-9]/', '', (string) $cc['cardNumber']), -4),
            'expiry' => $exp && strpos($exp, 'X') === false ? $exp : '',
        ];
    }

    /**
     * Recent payments Authorize.net has taken for this subscription's customer
     * (signup + every recurring charge). Returns [['date','amount','status'], ...].
     */
    public function getPaymentHistory(int $subscriptionId, int $limit = 12): array
    {
        list($profileId, , $auth, $endpoint) = $this->anProfileContext($subscriptionId);
        if (!$profileId) return [];
        $payload = ['getTransactionListForCustomerRequest' => [
            'merchantAuthentication' => $auth,
            'customerProfileId'      => (string) $profileId,
            'sorting'                => ['orderBy' => 'submitTimeUTC', 'orderDescending' => true],
            'paging'                 => ['limit' => max(1, min(100, $limit)), 'offset' => 1],
        ]];
        $data = $this->anRead($endpoint, $payload);
        if ($data === null) return [];
        $txns = $data['transactions']['transaction'] ?? [];
        if (isset($txns['transId'])) $txns = [$txns]; // single result comes back as an object
        $out = [];
        foreach ($txns as $t) {
            $out[] = [
                'date'   => (string) ($t['submitTimeLocal'] ?? $t['submitTimeUTC'] ?? ''),
                'amount' => (float) ($t['settleAmount'] ?? $t['amount'] ?? 0),
                'status' => (string) ($t['transactionStatus'] ?? ''),
            ];
        }
        return $out;
    }

    /** Resolve [customerProfileId, paymentProfileId, auth[], endpoint] for an AN subscription. */
    private function anProfileContext(int $subscriptionId): array
    {
        $p = DB::prefix();
        $sub = DB::get_row(DB::prepare("SELECT * FROM `{$p}instinctor_subscriptions` WHERE id = %d", $subscriptionId));
        if (!$sub || ($sub->payment_method ?? '') !== 'authorizenet') return [null, null, [], ''];
        $meta = json_decode($sub->meta ?? '{}', true) ?: [];
        $sandbox = (bool) Config::get('instinctor_authorizenet_sandbox', true);
        return [
            $meta['an_customer_profile_id'] ?? '',
            $meta['an_payment_profile_id'] ?? '',
            ['name' => Config::get('instinctor_authorizenet_api_login_id'), 'transactionKey' => Config::get('instinctor_authorizenet_transaction_key')],
            $sandbox ? 'https://apitest.authorize.net/xml/v1/request.api' : 'https://api.authorize.net/xml/v1/request.api',
        ];
    }

    /** POST a read request to Authorize.net; return decoded data on Ok, else null. */
    private function anRead(string $endpoint, array $payload): ?array
    {
        $response = wp_remote_post($endpoint, [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode($payload),
            'timeout' => 20,
        ]);
        if (is_wp_error($response)) return null;
        $body = ltrim(wp_remote_retrieve_body($response), "\xEF\xBB\xBF");
        $data = json_decode($body, true) ?: [];
        return (($data['messages']['resultCode'] ?? '') === 'Ok') ? $data : null;
    }

    /* ── Authorize.net webhook reconciliation ─────────────────────────────── */

    /** Merchant auth + endpoint for non-profile AN calls (e.g. transaction details). */
    private function anAuthEndpoint(): array
    {
        $sandbox = (bool) Config::get('instinctor_authorizenet_sandbox', true);
        return [
            ['name' => Config::get('instinctor_authorizenet_api_login_id'), 'transactionKey' => Config::get('instinctor_authorizenet_transaction_key')],
            $sandbox ? 'https://apitest.authorize.net/xml/v1/request.api' : 'https://api.authorize.net/xml/v1/request.api',
        ];
    }

    /** Fetch a transaction's details (incl. its subscription reference) from Authorize.net. */
    public function getAnTransaction(string $transId): ?array
    {
        list($auth, $endpoint) = $this->anAuthEndpoint();
        if (empty($auth['name'])) return null;
        $data = $this->anRead($endpoint, ['getTransactionDetailsRequest' => [
            'merchantAuthentication' => $auth,
            'transId'                => $transId,
        ]]);
        return $data['transaction'] ?? null;
    }

    /**
     * Reconcile an Authorize.net webhook event. Idempotent (per transId, tracked in
     * subscription meta). Settled charges keep total_spent/orders accurate; lifecycle
     * events keep the local subscription status in sync.
     */
    public function handleAuthorizenetWebhook(string $eventType, array $payload): array
    {
        $p = DB::prefix();

        // Settled charge — including every recurring subscription payment.
        if (strpos($eventType, 'authcapture.created') !== false || strpos($eventType, 'priorAuthCapture.created') !== false) {
            $transId = (string) ($payload['id'] ?? '');
            if (!$transId) return ['ignored' => 'no transId'];
            $tx = $this->getAnTransaction($transId);
            $subAnId = $tx['subscription']['id'] ?? null;
            if (!$subAnId) return ['ignored' => 'not a subscription charge'];
            $payNum   = (int) ($tx['subscription']['payNum'] ?? 0);
            $amount   = (float) ($tx['settleAmount'] ?? $tx['authAmount'] ?? ($payload['authAmount'] ?? 0));
            $respCode = (string) ($tx['responseCode'] ?? '1');

            $sub = DB::get_row(DB::prepare("SELECT * FROM `{$p}instinctor_subscriptions` WHERE external_id = %s LIMIT 1", (string) $subAnId));
            if (!$sub) return ['ignored' => 'no local subscription'];
            $meta = json_decode($sub->meta ?: '{}', true) ?: [];
            $processed = (array) ($meta['processed_tx'] ?? []);
            if (in_array($transId, $processed, true)) return ['ok' => 'already processed', 'tx' => $transId];

            if ($respCode === '1') {
                $this->recordSubscriptionCharge($sub, $amount, $payNum);
                $statusLbl = 'recorded';
            } else {
                DB::update("{$p}instinctor_subscriptions", ['status' => 'past_due'], ['id' => $sub->id]);
                $statusLbl = 'declined';
            }
            $processed[] = $transId;
            $meta['processed_tx'] = array_slice($processed, -100);
            DB::update("{$p}instinctor_subscriptions", ['meta' => wp_json_encode($meta)], ['id' => $sub->id]);
            return ['ok' => true, 'tx' => $transId, 'payNum' => $payNum, 'amount' => $amount, 'status' => $statusLbl];
        }

        // Subscription lifecycle.
        if (strpos($eventType, 'customer.subscription.') !== false) {
            $subAnId = (string) ($payload['id'] ?? '');
            if (!$subAnId) return ['ignored' => 'no subscription id'];
            $sub = DB::get_row(DB::prepare("SELECT * FROM `{$p}instinctor_subscriptions` WHERE external_id = %s LIMIT 1", $subAnId));
            if (!$sub) return ['ignored' => 'no local subscription'];
            if (strpos($eventType, 'suspended') !== false) {
                DB::update("{$p}instinctor_subscriptions", ['status' => 'past_due'], ['id' => $sub->id]);
                return ['ok' => true, 'action' => 'past_due'];
            }
            if (strpos($eventType, 'terminated') !== false || strpos($eventType, 'cancelled') !== false || strpos($eventType, 'canceled') !== false) {
                DB::update("{$p}instinctor_subscriptions", ['status' => 'canceled', 'canceled_at' => current_time('mysql')], ['id' => $sub->id]);
                return ['ok' => true, 'action' => 'canceled'];
            }
        }
        return ['ignored' => $eventType];
    }

    /**
     * Record a successful subscription charge. First payment finalizes the original
     * order (updates total_spent + order_count via CheckoutService); renewals roll the
     * period forward and bump total_spent directly.
     */
    private function recordSubscriptionCharge(object $sub, float $amount, int $payNum): void
    {
        $p = DB::prefix();
        $meta = json_decode($sub->meta ?: '{}', true) ?: [];

        if ($payNum <= 1) {
            // First payment: complete the order placed at checkout (it was left "processing").
            if (!empty($meta['order_id'])) {
                try {
                    $checkout = new \Instinctor\Services\CheckoutService(new \Instinctor\Services\CartService());
                    $checkout->finalizeOrder((int) $meta['order_id']);
                } catch (\Throwable $e) { /* best effort */ }
                DB::update("{$p}instinctor_orders", ['status' => 'completed'], ['id' => (int) $meta['order_id']]);
            }
            DB::update("{$p}instinctor_subscriptions", ['status' => 'active'], ['id' => $sub->id]);
            return;
        }

        // Renewal: count the payment and roll the period forward.
        DB::query(DB::prepare(
            "UPDATE `{$p}instinctor_customers` SET total_spent = total_spent + %f WHERE id = %d",
            $amount, (int) $sub->customer_id
        ));
        $interval = (int) ($sub->interval_count ?: 1);
        $unitMap  = ['weekly' => 'week', 'monthly' => 'month', 'yearly' => 'year'];
        $unit     = $unitMap[$sub->period] ?? (string) $sub->period;
        $base     = $sub->current_period_end ? strtotime($sub->current_period_end) : time();
        if ($base < time()) $base = time();
        $newEnd   = strtotime("+{$interval} {$unit}", $base);
        DB::update("{$p}instinctor_subscriptions", [
            'status'               => 'active',
            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
            'current_period_start' => date('Y-m-d H:i:s', $base),
            // phpcs:enable WordPress.DateTime.RestrictedFunctions
            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
            'current_period_end'   => date('Y-m-d H:i:s', $newEnd),
            // phpcs:enable WordPress.DateTime.RestrictedFunctions
        ], ['id' => $sub->id]);
    }


    public function updateCardOnFile(int $subscriptionId, string $descriptor, string $nonce, array $billing): array
    {
        $p = DB::prefix();
        $sub = DB::get_row(DB::prepare("SELECT * FROM `{$p}instinctor_subscriptions` WHERE id = %d", $subscriptionId));
        if (!$sub) {
            return ['success' => false, 'error' => 'Subscription not found'];
        }
        if (($sub->payment_method ?? '') !== 'authorizenet') {
            return ['success' => false, 'error' => 'This membership is not billed through Authorize.net'];
        }
        $meta = json_decode($sub->meta ?? '{}', true) ?: [];
        $profileId    = $meta['an_customer_profile_id'] ?? '';
        $payProfileId = $meta['an_payment_profile_id'] ?? '';
        if (!$profileId || !$payProfileId) {
            return ['success' => false, 'error' => 'No stored card on file for this membership'];
        }

        $apiLoginId     = Config::get('instinctor_authorizenet_api_login_id');
        $transactionKey = Config::get('instinctor_authorizenet_transaction_key');
        $sandbox        = (bool) Config::get('instinctor_authorizenet_sandbox', true);
        $endpoint = $sandbox
            ? 'https://apitest.authorize.net/xml/v1/request.api'
            : 'https://api.authorize.net/xml/v1/request.api';

        $payload = ['updateCustomerPaymentProfileRequest' => [
            'merchantAuthentication' => ['name' => $apiLoginId, 'transactionKey' => $transactionKey],
            'customerProfileId'      => (string) $profileId,
            'paymentProfile'         => [
                'billTo' => [
                    'firstName' => sanitize_text_field($billing['first_name'] ?? ''),
                    'lastName'  => sanitize_text_field($billing['last_name']  ?? ''),
                    'zip'       => sanitize_text_field($billing['postcode']   ?? ''),
                ],
                'payment' => ['opaqueData' => ['dataDescriptor' => $descriptor, 'dataValue' => $nonce]],
                'customerPaymentProfileId' => (string) $payProfileId,
            ],
            'validationMode' => $sandbox ? 'testMode' : 'liveMode',
        ]];

        $response = wp_remote_post($endpoint, [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode($payload),
            'timeout' => 30,
        ]);
        if (is_wp_error($response)) {
            return ['success' => false, 'error' => $response->get_error_message()];
        }
        $body = ltrim(wp_remote_retrieve_body($response), "\xEF\xBB\xBF");
        $data = json_decode($body, true) ?: [];
        if (($data['messages']['resultCode'] ?? '') === 'Ok') {
            return ['success' => true];
        }
        return ['success' => false, 'error' => $data['messages']['message'][0]['text'] ?? 'Could not update card'];
    }

    /**
     * Cancel a subscription
     */
    public function cancelSubscription(int $subscriptionId, bool $immediately = false): array
    {
        $p = DB::prefix();
        $stripeKey = $this->getStripeKey();
        
        $subscription = DB::get_row(DB::prepare(
            "SELECT * FROM {$p}instinctor_subscriptions WHERE id = %d",
            $subscriptionId
        ));

        if (!$subscription) {
            return ['success' => false, 'error' => 'Subscription not found'];
        }

        // If Stripe subscription exists, cancel it there
        if ($stripeKey && !empty($subscription->external_id)) {
            if ($immediately) {
                // Cancel immediately
                $response = wp_remote_request(
                    'https://api.stripe.com/v1/subscriptions/' . $subscription->external_id,
                    [
                        'method' => 'DELETE',
                        'headers' => [
                            'Authorization' => 'Bearer ' . $stripeKey
                        ]
                    ]
                );
            } else {
                // Cancel at period end
                $response = wp_remote_post(
                    'https://api.stripe.com/v1/subscriptions/' . $subscription->external_id,
                    [
                        'headers' => [
                            'Authorization' => 'Bearer ' . $stripeKey,
                            'Content-Type' => 'application/x-www-form-urlencoded'
                        ],
                        'body' => ['cancel_at_period_end' => 'true']
                    ]
                );
            }
        }

        // Authorize.net ARB cancellation — stops all future charges immediately.
        // The member keeps access until current_period_end (already paid).
        if (($subscription->payment_method ?? '') === 'authorizenet' && !empty($subscription->external_id)) {
            $anLogin = Config::get('instinctor_authorizenet_api_login_id');
            $anKey   = Config::get('instinctor_authorizenet_transaction_key');
            $anSand  = (bool) Config::get('instinctor_authorizenet_sandbox', true);
            $anEnd   = $anSand
                ? 'https://apitest.authorize.net/xml/v1/request.api'
                : 'https://api.authorize.net/xml/v1/request.api';
            wp_remote_post($anEnd, [
                'headers' => ['Content-Type' => 'application/json'],
                'body'    => wp_json_encode(['ARBCancelSubscriptionRequest' => [
                    'merchantAuthentication' => ['name' => $anLogin, 'transactionKey' => $anKey],
                    'subscriptionId'         => (string) $subscription->external_id,
                ]]),
                'timeout' => 30,
            ]);
        }

        // Update local record
        $updateData = $immediately
            ? ['status' => 'cancelled', 'canceled_at' => current_time('mysql')]
            : ['cancel_at_period_end' => 1];

        DB::update(DB::prefix() . 'instinctor_subscriptions', $updateData, ['id' => $subscriptionId]);

        // Immediate cancel: revoke downloadable access now. End-of-period
        // cancel: skip — Stripe will fire customer.subscription.deleted at
        // the actual termination time and the webhook handler revokes there.
        if ($immediately) {
            DigitalDownloadService::instance()->revokeSubscriptionAccess($subscriptionId);
        }

        return ['success' => true];
    }

    /**
     * Update the recurring amount of an Authorize.net ARB subscription for all
     * FUTURE charges (ARBUpdateSubscription). The amount field IS updatable on
     * an active ARB; no cancel/recreate is needed. Does NOT touch the local DB
     * row — the caller syncs instinctor_subscriptions.price after a success.
     *
     * @return array{success:bool, error:?string}
     */
    public function updateAuthorizeNetAmount(string $arbSubscriptionId, float $amount): array
    {
        if ($arbSubscriptionId === '' || $amount <= 0) {
            return ['success' => false, 'error' => 'Invalid subscription or amount'];
        }
        $login = Config::get('instinctor_authorizenet_api_login_id');
        $key   = Config::get('instinctor_authorizenet_transaction_key');
        if (!$login || !$key) {
            return ['success' => false, 'error' => 'Authorize.net is not configured'];
        }
        $sand = (bool) Config::get('instinctor_authorizenet_sandbox', true);
        $end  = $sand
            ? 'https://apitest.authorize.net/xml/v1/request.api'
            : 'https://api.authorize.net/xml/v1/request.api';

        $resp = wp_remote_post($end, [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode(['ARBUpdateSubscriptionRequest' => [
                'merchantAuthentication' => ['name' => $login, 'transactionKey' => $key],
                'subscriptionId'         => $arbSubscriptionId,
                'subscription'           => ['amount' => number_format($amount, 2, '.', '')],
            ]]),
            'timeout' => 30,
        ]);
        if (is_wp_error($resp)) {
            return ['success' => false, 'error' => $resp->get_error_message()];
        }
        // Authorize.net JSON responses are prefixed with a UTF-8 BOM that breaks
        // json_decode — strip it before parsing.
        $body = trim(preg_replace('/^\xEF\xBB\xBF/', '', (string) wp_remote_retrieve_body($resp)));
        $j    = json_decode($body, true);
        if (!is_array($j)) {
            return ['success' => false, 'error' => 'Invalid gateway response'];
        }
        if (($j['messages']['resultCode'] ?? '') !== 'Ok') {
            $msg = $j['messages']['message'][0]['text'] ?? 'Authorize.net rejected the update';
            return ['success' => false, 'error' => $msg];
        }
        return ['success' => true, 'error' => null];
    }

    /**
     * Shared Authorize.net endpoint + auth. Returns [endpoint, authArray] or
     * [null, errorString] when credentials are missing.
     *
     * @return array{0:?string,1:mixed}
     */
    private function anEndpointAuth(): array
    {
        $login = Config::get('instinctor_authorizenet_api_login_id');
        $key   = Config::get('instinctor_authorizenet_transaction_key');
        if (!$login || !$key) return [null, 'Authorize.net is not configured'];
        $sand = (bool) Config::get('instinctor_authorizenet_sandbox', true);
        $end  = $sand
            ? 'https://apitest.authorize.net/xml/v1/request.api'
            : 'https://api.authorize.net/xml/v1/request.api';
        return [$end, ['name' => $login, 'transactionKey' => $key]];
    }

    /** POST a JSON payload to Authorize.net, strip the BOM, return decoded array or null. */
    private function anJsonPost(string $endpoint, array $payload): ?array
    {
        $resp = wp_remote_post($endpoint, [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode($payload),
            'timeout' => 30,
        ]);
        if (is_wp_error($resp)) return null;
        $body = trim(preg_replace('/^\xEF\xBB\xBF/', '', (string) wp_remote_retrieve_body($resp)));
        $j = json_decode($body, true);
        return is_array($j) ? $j : null;
    }

    /**
     * Cancel an Authorize.net ARB subscription (stops ALL future charges). This
     * is the ONLY way to actually suspend billing for an authorizenet member —
     * flipping the local status alone leaves the ARB charging. Does NOT touch the
     * local DB row; the caller updates instinctor_subscriptions.status.
     *
     * @return array{success:bool, error:?string}
     */
    public function cancelAuthorizeNetSubscription(string $arbSubscriptionId): array
    {
        if ($arbSubscriptionId === '') return ['success' => false, 'error' => 'No subscription id'];
        [$end, $auth] = $this->anEndpointAuth();
        if ($end === null) return ['success' => false, 'error' => (string) $auth];
        $j = $this->anJsonPost($end, ['ARBCancelSubscriptionRequest' => [
            'merchantAuthentication' => $auth,
            'subscriptionId'         => $arbSubscriptionId,
        ]]);
        if ($j === null) return ['success' => false, 'error' => 'Invalid gateway response'];
        if (($j['messages']['resultCode'] ?? '') !== 'Ok') {
            $msg = $j['messages']['message'][0]['text'] ?? 'Authorize.net rejected the cancel';
            // Already-cancelled/terminated ARBs are fine for a suspend — treat as success.
            $code = $j['messages']['message'][0]['code'] ?? '';
            if ($code === 'E00035') return ['success' => true, 'error' => null];
            return ['success' => false, 'error' => $msg];
        }
        return ['success' => true, 'error' => null];
    }

    /**
     * Create a NEW Authorize.net ARB from a stored CIM profile (card on file) —
     * used to REACTIVATE a suspended member without re-entering their card (ARB
     * cancel is one-way at Authorize.net, so resume = create a fresh ARB). Returns
     * the new ARB id on success. Period maps to ARB interval units (days|months).
     *
     * @return array{success:bool, arb_id:?string, error:?string}
     */
    public function createAuthorizeNetSubscriptionFromProfile(
        string $customerProfileId,
        string $paymentProfileId,
        float  $amount,
        string $period,
        int    $intervalCount,
        string $planName,
        string $startDate = ''
    ): array {
        if ($customerProfileId === '' || $paymentProfileId === '' || $amount <= 0) {
            return ['success' => false, 'arb_id' => null, 'error' => 'Missing card profile or amount'];
        }
        [$end, $auth] = $this->anEndpointAuth();
        if ($end === null) return ['success' => false, 'arb_id' => null, 'error' => (string) $auth];

        $n = max(1, $intervalCount);
        switch (strtolower($period)) {
            case 'year': case 'yearly':  $length = 12 * $n; $unit = 'months'; break;
            case 'week': case 'weekly':  $length = 7 * $n;  $unit = 'days';   break;
            case 'day':  case 'daily':   $length = $n;      $unit = 'days';   break;
            default:                     $length = $n;      $unit = 'months'; break; // monthly
        }
        $start = ($startDate !== '') ? $startDate : gmdate('Y-m-d');

        $req = ['ARBCreateSubscriptionRequest' => [
            'merchantAuthentication' => $auth,
            'subscription' => [
                'name' => substr($planName !== '' ? $planName : 'Membership', 0, 50),
                'paymentSchedule' => [
                    'interval'         => ['length' => $length, 'unit' => $unit],
                    'startDate'        => $start,
                    'totalOccurrences' => 9999,
                ],
                'amount'  => number_format($amount, 2, '.', ''),
                'profile' => [
                    'customerProfileId'        => $customerProfileId,
                    'customerPaymentProfileId' => $paymentProfileId,
                ],
            ],
        ]];
        $j = $this->anJsonPost($end, $req);
        if ($j === null) return ['success' => false, 'arb_id' => null, 'error' => 'Invalid gateway response'];
        if (($j['messages']['resultCode'] ?? '') !== 'Ok') {
            $msg = $j['messages']['message'][0]['text'] ?? 'Authorize.net rejected the subscription';
            return ['success' => false, 'arb_id' => null, 'error' => $msg];
        }
        return ['success' => true, 'arb_id' => (string) ($j['subscriptionId'] ?? ''), 'error' => null];
    }

    /**
     * Pause a subscription
     */
    public function pauseSubscription(int $subscriptionId): array
    {
        $stripeKey = $this->getStripeKey();
        
        $subscription = DB::get_row(DB::prepare(
            "SELECT * FROM {$p}instinctor_subscriptions WHERE id = %d",
            $subscriptionId
        ));

        if (!$subscription) {
            return ['success' => false, 'error' => 'Subscription not found'];
        }

        // Pause in Stripe
        if ($stripeKey && !empty($subscription->external_id)) {
            wp_remote_post(
                'https://api.stripe.com/v1/subscriptions/' . $subscription->external_id,
                [
                    'headers' => [
                        'Authorization' => 'Bearer ' . $stripeKey,
                        'Content-Type' => 'application/x-www-form-urlencoded'
                    ],
                    'body' => ['pause_collection[behavior]' => 'void']
                ]
            );
        }

        // Update local
        DB::update(
            DB::prefix() . 'instinctor_subscriptions',
            ['status' => 'paused', 'paused_at' => current_time('mysql')],
            ['id' => $subscriptionId]
        );

        return ['success' => true];
    }

    /**
     * Resume a paused subscription
     */
    public function resumeSubscription(int $subscriptionId): array
    {
        $stripeKey = $this->getStripeKey();
        
        $subscription = DB::get_row(DB::prepare(
            "SELECT * FROM {$p}instinctor_subscriptions WHERE id = %d",
            $subscriptionId
        ));

        if (!$subscription) {
            return ['success' => false, 'error' => 'Subscription not found'];
        }

        // Resume in Stripe
        if ($stripeKey && !empty($subscription->external_id)) {
            wp_remote_post(
                'https://api.stripe.com/v1/subscriptions/' . $subscription->external_id,
                [
                    'headers' => [
                        'Authorization' => 'Bearer ' . $stripeKey,
                        'Content-Type' => 'application/x-www-form-urlencoded'
                    ],
                    'body' => ['pause_collection' => '']
                ]
            );
        }

        // Update local - calculate new period end
        $period = $subscription->period ?? 'monthly';
        $periodEnd = new \DateTime();
        
        switch ($period) {
            case 'weekly': $periodEnd->modify('+1 week'); break;
            case 'yearly': $periodEnd->modify('+1 year'); break;
            default: $periodEnd->modify('+1 month');
        }

        DB::update(
            DB::prefix() . 'instinctor_subscriptions',
            [
                'status' => 'active',
                'paused_at' => null,
                'current_period_start' => current_time('mysql'),
                'current_period_end' => $periodEnd->format('Y-m-d H:i:s')
            ],
            ['id' => $subscriptionId]
        );

        return ['success' => true];
    }

    /**
     * Sync subscription statuses from Stripe
     */
    public function syncSubscriptionStatuses(): void
    {
        $stripeKey = $this->getStripeKey();
        
        if (!$stripeKey) {
            return;
        }

        // Get active subscriptions with Stripe IDs
        $subscriptions = DB::get_results(
            "SELECT id, external_id FROM {$p}instinctor_subscriptions 
             WHERE external_id IS NOT NULL 
             AND status IN ('active', 'past_due', 'pending')
             LIMIT 50"
        );

        foreach ($subscriptions as $sub) {
            $response = wp_remote_get(
                'https://api.stripe.com/v1/subscriptions/' . $sub->external_id,
                [
                    'headers' => ['Authorization' => 'Bearer ' . $stripeKey]
                ]
            );

            if (!is_wp_error($response)) {
                $body = json_decode(wp_remote_retrieve_body($response), true);
                
                if (!empty($body['status'])) {
                    DB::update(
                        DB::prefix() . 'instinctor_subscriptions',
                        [
                            'status' => $this->mapStripeStatus($body['status']),
                            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used intentionally for local-timezone formatting; gmdate() would force UTC and change behavior.
                            'current_period_end' => date('Y-m-d H:i:s', $body['current_period_end'])
                            // phpcs:enable WordPress.DateTime.RestrictedFunctions
                        ],
                        ['id' => $sub->id]
                    );
                }
            }
        }
    }
}
