<?php

/**
 * WooCommerce Order Integration
 *
 * Listens to WooCommerce order hooks and subscription hooks to sync state.
 * - When order status changes, update related subscription
 * - When subscription status changes, can trigger order actions
 *
 * Custom Hooks Fired:
 * - arraysubs_order_created (order_id, order)
 * - arraysubs_order_status_changed (order_id, new_status, old_status, order)
 * - arraysubs_order_paid (order_id, order) - for completed/processing
 *
 * ## Order ↔ Subscription meta contract (canonical)
 *
 * - Initial parent order (the WooCommerce order created at checkout):
 *     order meta `_subscription_ids`  = array<int> of subscription IDs created from the order
 *     order item meta `_subscription_id` = subscription ID for that line item
 * - Renewal order (created by RecurringBilling\OrderCreation):
 *     order meta `_subscription_id`     = single subscription ID this renewal belongs to
 *     order meta `_is_renewal_order`    = 'yes'
 *     order meta `_renewal_scheduled_date` = original due date (anchors next-payment calc)
 * - Subscription post (CPT `arraysubs_data`):
 *     post meta `_parent_order_id`     = the initial WC order ID
 *     post meta `_pending_renewal_order_id` = the latest unpaid renewal order, if any
 *
 * Helpers must use these keys exclusively. Do not introduce alternate names
 * like `_arraysubs_subscription_id` — they will silently miss subscriptions.
 *
 * @package ArraySubs\Features\Subscriptions\Services
 */

namespace ArraySubs\Features\Subscriptions\Services;

defined('ABSPATH') || exit;

use ArraySubs\Supports\ActionScheduler;
use ArraySubs\Features\RecurringBilling\Services\RenewalScheduler;
use ArraySubs\Features\PlanSwitching\Services\PlanManager;

/**
 * Order integration hooks
 */
class OrderIntegration
{
    /**
     * Enable auto-loading
     *
     * @var bool
     */
    public static $loadable = true;

    /**
     * Pending changed order props collected during the current admin save request.
     *
     * @var array<int, string[]>
     */
    private array $pending_order_audit_props = [];

    /**
     * Pending order snapshots before admin save.
     *
     * @var array<int, \WC_Order>
     */
    private array $pending_order_audit_snapshots = [];

    /**
     * Constructor
     */
    public function __construct()
    {
        // WooCommerce order created
        add_action('woocommerce_new_order', [$this, 'onOrderCreated'], 10, 2);
        add_action('woocommerce_before_order_object_save', [$this, 'captureOrderSnapshot'], 20, 1);
        add_action('woocommerce_order_object_updated_props', [$this, 'captureOrderUpdatedProps'], 20, 2);
        add_action('woocommerce_process_shop_order_meta', [$this, 'onOrderAdminUpdated'], 60, 2);

        // WooCommerce order status changes
        add_action('woocommerce_order_status_changed', [$this, 'onOrderStatusChanged'], 10, 4);

        // Listen to our custom subscription hooks
        add_action('arraysubs_data_created', [$this, 'onSubscriptionCreated'], 10, 2);
        add_action('arraysubs_data_status_changed', [$this, 'onSubscriptionStatusChanged'], 10, 3);

        // Listen to order paid event (completed/processing) to activate subscriptions
        add_action('arraysubs_order_paid', [$this, 'activateSubscriptionsFromOrder'], 10, 2);
    }

    /**
     * Fire custom hook when WooCommerce order is created
     *
     * @param int       $order_id Order ID
     * @param \WC_Order $order Order object
     */
    public function onOrderCreated(int $order_id, $order = null): void
    {
        if (!$order) {
            $order = wc_get_order($order_id);
        }

        if (!$order) {
            return;
        }

        /**
         * Fires when a WooCommerce order is created
         *
         * @param int       $order_id The order ID
         * @param \WC_Order $order The order object
         */
        do_action('arraysubs_order_created', $order_id, $order);
    }

    /**
     * Collect changed order props during WooCommerce order saves.
     *
     * @param \WC_Order|mixed $order         Order object.
     * @param array           $updated_props Updated order props.
     * @return void
     */
    public function captureOrderUpdatedProps($order, array $updated_props): void
    {
        if (!$order instanceof \WC_Order) {
            return;
        }

        $order_id = $order->get_id();

        if ($order_id <= 0 || empty($updated_props)) {
            return;
        }

        $existing = $this->pending_order_audit_props[$order_id] ?? [];
        $merged   = array_merge($existing, $updated_props);

        $this->pending_order_audit_props[$order_id] = array_values(array_unique($merged));
    }

    /**
     * Capture a clean order snapshot before save for audit diffs.
     *
     * @param \WC_Order|mixed $order Order object.
     * @return void
     */
    public function captureOrderSnapshot($order): void
    {
        if (!$order instanceof \WC_Order) {
            return;
        }

        $order_id = $order->get_id();

        if ($order_id <= 0) {
            return;
        }

        $this->pending_order_audit_snapshots[$order_id] = new \WC_Order($order_id);
    }

    /**
     * Log admin-side order updates for audits.
     *
     * @param int             $order_id Order ID.
     * @param \WC_Order|mixed $order   Order object.
     * @return void
     */
    public function onOrderAdminUpdated(int $order_id, $order): void
    {
        if (!function_exists('arraysubs_add_global_note')) {
            return;
        }

        if (!$order instanceof \WC_Order) {
            $order = wc_get_order($order_id);
        }

        if (!$order instanceof \WC_Order) {
            return;
        }

        $updated_props  = $this->pending_order_audit_props[$order_id] ?? [];
        $before_order   = $this->pending_order_audit_snapshots[$order_id] ?? null;
        $audit_changes  = $before_order instanceof \WC_Order
            ? $this->buildOrderAuditChanges($updated_props, $before_order, $order)
            : [];
        $changed_fields = !empty($audit_changes)
            ? array_map(static fn(array $change): string => $change['label'], $audit_changes)
            : $this->getChangedOrderProps($updated_props);

        unset($this->pending_order_audit_props[$order_id]);
        unset($this->pending_order_audit_snapshots[$order_id]);

        if (empty($changed_fields)) {
            return;
        }

        $actor_id = get_current_user_id();

        arraysubs_add_global_note(
            sprintf(
                /* translators: 1: order number, 2: changed field labels. */
                __('Order updated: #%1$s — changed %2$s.', 'arraysubs'),
                esc_html($order->get_order_number()),
                esc_html(implode(', ', $changed_fields))
            ),
            'private',
            $actor_id > 0 ? $actor_id : 0,
            $audit_changes,
            'order'
        );
    }

    /**
     * Get changed WooCommerce order props as readable labels.
     *
     * @param array $updated_props Updated order props.
     * @return string[]
     */
    private function getChangedOrderProps(array $updated_props): array
    {
        if (empty($updated_props)) {
            return [];
        }

        $skip_keys = [
            'billing',
            'shipping',
            'order_key',
            'cart_hash',
            'version',
            'created_via',
            'customer_ip_address',
            'customer_user_agent',
            'recorded_sales',
            'recorded_coupon_usage_counts',
            'new_order_email_sent',
            'order_stock_reduced',
            'download_permissions_granted',
            'cogs_total_value',
        ];

        $labels = [
            'customer_id' => __('customer', 'arraysubs'),
            'payment_method' => __('payment method', 'arraysubs'),
            'payment_method_title' => __('payment method', 'arraysubs'),
            'transaction_id' => __('transaction ID', 'arraysubs'),
            'date_paid' => __('paid date', 'arraysubs'),
            'date_completed' => __('completed date', 'arraysubs'),
            'currency' => __('currency', 'arraysubs'),
            'discount_total' => __('discount total', 'arraysubs'),
            'discount_tax' => __('discount tax', 'arraysubs'),
            'shipping_total' => __('shipping total', 'arraysubs'),
            'shipping_tax' => __('shipping tax', 'arraysubs'),
            'cart_tax' => __('cart tax', 'arraysubs'),
            'total' => __('order total', 'arraysubs'),
            'prices_include_tax' => __('prices include tax', 'arraysubs'),
            'billing_first_name' => __('billing first name', 'arraysubs'),
            'billing_last_name' => __('billing last name', 'arraysubs'),
            'billing_company' => __('billing company', 'arraysubs'),
            'billing_address_1' => __('billing address line 1', 'arraysubs'),
            'billing_address_2' => __('billing address line 2', 'arraysubs'),
            'billing_city' => __('billing city', 'arraysubs'),
            'billing_state' => __('billing state', 'arraysubs'),
            'billing_postcode' => __('billing postcode', 'arraysubs'),
            'billing_country' => __('billing country', 'arraysubs'),
            'billing_email' => __('billing email', 'arraysubs'),
            'billing_phone' => __('billing phone', 'arraysubs'),
            'shipping_first_name' => __('shipping first name', 'arraysubs'),
            'shipping_last_name' => __('shipping last name', 'arraysubs'),
            'shipping_company' => __('shipping company', 'arraysubs'),
            'shipping_address_1' => __('shipping address line 1', 'arraysubs'),
            'shipping_address_2' => __('shipping address line 2', 'arraysubs'),
            'shipping_city' => __('shipping city', 'arraysubs'),
            'shipping_state' => __('shipping state', 'arraysubs'),
            'shipping_postcode' => __('shipping postcode', 'arraysubs'),
            'shipping_country' => __('shipping country', 'arraysubs'),
            'shipping_phone' => __('shipping phone', 'arraysubs'),
        ];

        $changed = [];

        foreach ($updated_props as $key) {
            if (in_array($key, $skip_keys, true)) {
                continue;
            }

            $changed[] = $labels[$key] ?? ucwords(str_replace('_', ' ', (string) $key));
        }

        return array_values(array_unique($changed));
    }

    /**
     * Build structured order audit changes from before/after snapshots.
     *
     * @param array     $updated_props Updated order props.
     * @param \WC_Order $before       Previous order snapshot.
     * @param \WC_Order $after        Current order object.
     * @return array<int, array<string, string>>
     */
    private function buildOrderAuditChanges(array $updated_props, \WC_Order $before, \WC_Order $after): array
    {
        $labels = [
            'customer_id' => __('Customer', 'arraysubs'),
            'payment_method' => __('Payment method', 'arraysubs'),
            'payment_method_title' => __('Payment method title', 'arraysubs'),
            'transaction_id' => __('Transaction ID', 'arraysubs'),
            'date_paid' => __('Paid date', 'arraysubs'),
            'date_completed' => __('Completed date', 'arraysubs'),
            'currency' => __('Currency', 'arraysubs'),
            'discount_total' => __('Discount total', 'arraysubs'),
            'discount_tax' => __('Discount tax', 'arraysubs'),
            'shipping_total' => __('Shipping total', 'arraysubs'),
            'shipping_tax' => __('Shipping tax', 'arraysubs'),
            'cart_tax' => __('Cart tax', 'arraysubs'),
            'total' => __('Order total', 'arraysubs'),
            'prices_include_tax' => __('Prices include tax', 'arraysubs'),
            'billing_first_name' => __('Billing first name', 'arraysubs'),
            'billing_last_name' => __('Billing last name', 'arraysubs'),
            'billing_company' => __('Billing company', 'arraysubs'),
            'billing_address_1' => __('Billing address line 1', 'arraysubs'),
            'billing_address_2' => __('Billing address line 2', 'arraysubs'),
            'billing_city' => __('Billing city', 'arraysubs'),
            'billing_state' => __('Billing state', 'arraysubs'),
            'billing_postcode' => __('Billing postcode', 'arraysubs'),
            'billing_country' => __('Billing country', 'arraysubs'),
            'billing_email' => __('Billing email', 'arraysubs'),
            'billing_phone' => __('Billing phone', 'arraysubs'),
            'shipping_first_name' => __('Shipping first name', 'arraysubs'),
            'shipping_last_name' => __('Shipping last name', 'arraysubs'),
            'shipping_company' => __('Shipping company', 'arraysubs'),
            'shipping_address_1' => __('Shipping address line 1', 'arraysubs'),
            'shipping_address_2' => __('Shipping address line 2', 'arraysubs'),
            'shipping_city' => __('Shipping city', 'arraysubs'),
            'shipping_state' => __('Shipping state', 'arraysubs'),
            'shipping_postcode' => __('Shipping postcode', 'arraysubs'),
            'shipping_country' => __('Shipping country', 'arraysubs'),
            'shipping_phone' => __('Shipping phone', 'arraysubs'),
        ];

        $changes  = [];
        $currency = $after->get_currency();

        foreach ($updated_props as $key) {
            $old_value = $this->getOrderAuditPropValue($before, $key, $currency);
            $new_value = $this->getOrderAuditPropValue($after, $key, $currency);

            if ($old_value === $new_value) {
                continue;
            }

            $changes[] = arraysubs_create_audit_change_entry(
                $labels[$key] ?? ucwords(str_replace('_', ' ', (string) $key)),
                $old_value,
                $new_value
            );
        }

        return $changes;
    }

    /**
     * Get a formatted audit value for an order property.
     *
     * @param \WC_Order $order    Order object.
     * @param string    $key      Property key.
     * @param string    $currency Currency code.
     * @return string
     */
    private function getOrderAuditPropValue(\WC_Order $order, string $key, string $currency): string
    {
        switch ($key) {
            case 'customer_id':
                return arraysubs_format_audit_value($order->get_customer_id());
            case 'payment_method':
                return arraysubs_format_audit_value($order->get_payment_method());
            case 'payment_method_title':
                return arraysubs_format_audit_value($order->get_payment_method_title());
            case 'transaction_id':
                return arraysubs_format_audit_value($order->get_transaction_id());
            case 'date_paid':
                return $this->formatOrderDateAuditValue($order->get_date_paid());
            case 'date_completed':
                return $this->formatOrderDateAuditValue($order->get_date_completed());
            case 'currency':
                return arraysubs_format_audit_value($order->get_currency());
            case 'discount_total':
            case 'discount_tax':
            case 'shipping_total':
            case 'shipping_tax':
            case 'cart_tax':
            case 'total':
                $getter = 'get_' . $key;
                return arraysubs_format_audit_value($order->{$getter}(), 'money', ['currency' => $currency]);
            case 'prices_include_tax':
                return arraysubs_format_audit_value($order->get_prices_include_tax());
            default:
                if (str_starts_with($key, 'billing_') || str_starts_with($key, 'shipping_')) {
                    $getter = 'get_' . $key;
                    return arraysubs_format_audit_value($order->{$getter}());
                }
        }

        return '—';
    }

    /**
     * Format an order date object for audits.
     *
     * @param \WC_DateTime|mixed $date Date object.
     * @return string
     */
    private function formatOrderDateAuditValue($date): string
    {
        if (!$date instanceof \WC_DateTime) {
            return '—';
        }

        return arraysubs_format_timestamp_local($date->getTimestamp());
    }

    /**
     * Handle WooCommerce order status change
     *
     * @param int       $order_id Order ID
     * @param string    $old_status Old status (without wc- prefix)
     * @param string    $new_status New status (without wc- prefix)
     * @param \WC_Order $order Order object
     */
    public function onOrderStatusChanged(int $order_id, string $old_status, string $new_status, $order): void
    {
        if (function_exists('arraysubs_add_global_note') && $order instanceof \WC_Order) {
            $actor_id = get_current_user_id();

            arraysubs_add_global_note(
                sprintf(
                    /* translators: 1: order number, 2: old status, 3: new status. */
                    __('Order #%1$s status changed from %2$s to %3$s.', 'arraysubs'),
                    esc_html($order->get_order_number()),
                    esc_html(wc_get_order_status_name($old_status)),
                    esc_html(wc_get_order_status_name($new_status))
                ),
                'private',
                $actor_id > 0 ? $actor_id : 0,
                [
                    arraysubs_create_audit_change_entry(
                        __('Order status', 'arraysubs'),
                        wc_get_order_status_name($old_status),
                        wc_get_order_status_name($new_status)
                    ),
                ],
                'order'
            );
        }

        /**
         * Fires when WooCommerce order status changes
         *
         * @param int       $order_id The order ID
         * @param string    $new_status The new status
         * @param string    $old_status The old status
         * @param \WC_Order $order The order object
         */
        do_action('arraysubs_order_status_changed', $order_id, $new_status, $old_status, $order);
        // Fire order paid event for completed/processing
        if (in_array($new_status, ['completed', 'processing'], true)) {
            /**
             * Fires when order is considered paid
             *
             * @param int       $order_id The order ID
             * @param \WC_Order $order The order object
             */
            do_action('arraysubs_order_paid', $order_id, $order);
        }

        // Handle order failure/cancellation
        if (in_array($new_status, ['failed', 'cancelled'], true)) {
            $this->handleOrderFailure($order_id, $order);
        }

        // Handle refund
        if ($new_status === 'refunded') {
            $this->handleOrderRefund($order_id, $order);
        }
    }

    /**
     * Handle subscription created event
     * 
     * This method handles both:
     * - arraysubs_data_created from subscription-helpers.php (passes $args array)
     * - arraysubs_data_created from Subscriptions/Hooks.php (passes WP_Post)
     *
     * @param int            $subscription_id Subscription ID
     * @param array|\WP_Post $data Subscription creation arguments (array) or Post object
     */
    public function onSubscriptionCreated(int $subscription_id, $data): void
    {
        // Calculate and set next payment date if not set
        $next_payment_date = get_post_meta($subscription_id, '_next_payment_date', true);

        if (empty($next_payment_date)) {
            $this->calculateAndSetNextPaymentDate($subscription_id);
        }

        // Get parent order ID - from args array or from post meta
        $parent_order_id = null;
        if (is_array($data) && isset($data['parent_order_id'])) {
            $parent_order_id = $data['parent_order_id'];
        } else {
            $parent_order_id = get_post_meta($subscription_id, '_parent_order_id', true);
        }

        // Add to parent order's subscription list if parent order exists
        if ($parent_order_id) {
            $subscription_ids = get_post_meta($parent_order_id, '_subscription_ids', true);
            if (!is_array($subscription_ids)) {
                $subscription_ids = [];
            }
            if (!in_array($subscription_id, $subscription_ids, true)) {
                $subscription_ids[] = $subscription_id;
                update_post_meta($parent_order_id, '_subscription_ids', $subscription_ids);
            }
        }
    }

    /**
     * Handle subscription status changed event
     *
     * Status transitions and their effects on Action Scheduler:
     * - Active/Trial: Schedule renewal (if not already scheduled)
     * - Pending-Cancel: Keep renewal scheduled (subscription stays active until end of period)
     * - On-Hold: Unschedule renewal (paused, no automatic billing)
     * - Cancelled/Expired: Unschedule ALL actions + clear next payment date
     *
     * @param int    $subscription_id Subscription ID
     * @param string $new_status New status
     * @param string $old_status Old status
     */
    public function onSubscriptionStatusChanged(int $subscription_id, string $new_status, string $old_status): void
    {
        $reactivation_statuses = ['arraysubs-cancelled', 'arraysubs-expired', 'arraysubs-on-hold'];
        $status_context        = (string) get_post_meta($subscription_id, '_arraysubs_status_change_context', true);
        $is_initial_payment    = 'initial_payment' === $status_context;

        // When subscription becomes active, recalculate next payment if needed and schedule renewal
        if ($new_status === 'arraysubs-active' && $old_status !== 'arraysubs-active') {
            $this->recalculateNextPaymentOnReactivation($subscription_id, $old_status);

            if (!$is_initial_payment) {
                $this->validateGatewayOnReactivation($subscription_id, $old_status);
            }

            $this->scheduleSubscriptionRenewal($subscription_id);

            if (!$is_initial_payment && in_array($old_status, $reactivation_statuses, true)) {
                do_action('arraysubs_data_reactivated', $subscription_id, $old_status);
            }
        }

        // When subscription becomes trial (and wasn't already active/trial), schedule renewal
        if ($new_status === 'arraysubs-trial' && !in_array($old_status, ['arraysubs-active', 'arraysubs-trial'], true)) {
            $this->scheduleSubscriptionRenewal($subscription_id);
        }

        // When subscription goes on-hold, unschedule renewals (paused billing)
        // Note: This is different from cancelled - on-hold can be reactivated
        if ($new_status === 'arraysubs-on-hold') {
            $this->unscheduleSubscriptionRenewal($subscription_id);
        }

        // When subscription is cancelled or expired, unschedule ALL future actions
        // and clear the next payment date so it does not display stale data
        if (in_array($new_status, ['arraysubs-cancelled', 'arraysubs-expired'], true)) {
            ActionScheduler::unscheduleAllForSubscription($subscription_id);
            update_post_meta($subscription_id, '_next_payment_date', '');
        }
    }

    /**
     * Recalculate next payment date when a subscription is reactivated
     *
     * Handles:
     * - Cancelled/Expired/On-Hold → Active: recalculate from now using billing period
     * - Trial → Active (admin manual activation): recalculate from now using billing period
     * - Pending → Active: keep the pre-calculated next payment date from checkout
     *
     * @param int    $subscription_id Subscription ID
     * @param string $old_status Previous status
     */
    private function recalculateNextPaymentOnReactivation(int $subscription_id, string $old_status): void
    {
        $needs_recalculation = in_array($old_status, ['arraysubs-cancelled', 'arraysubs-expired', 'arraysubs-on-hold', 'arraysubs-trial'], true);

        if (!$needs_recalculation) {
            return;
        }

        // Clear stale lifecycle meta that no longer applies after reactivation
        if (in_array($old_status, ['arraysubs-cancelled', 'arraysubs-expired'], true)) {
            delete_post_meta($subscription_id, '_end_date');
            delete_post_meta($subscription_id, '_waiting_cancellation');
            delete_post_meta($subscription_id, '_cancellation_scheduled_date');
            delete_post_meta($subscription_id, '_on_hold_date');
        }

        $billing_interval = (int) get_post_meta($subscription_id, '_billing_interval', true) ?: 1;
        $billing_period   = get_post_meta($subscription_id, '_billing_period', true) ?: 'month';

        // Lifetime subscriptions do not have recurring payments
        if ($billing_period === 'lifetime') {
            update_post_meta($subscription_id, '_next_payment_date', '');
            return;
        }

        $existing_next_payment = get_post_meta($subscription_id, '_next_payment_date', true);

        if (!empty($existing_next_payment) && strtotime($existing_next_payment) > time()) {
            return;
        }

        $next_payment_date = arraysubs_calculate_next_payment_date(
            current_time('mysql', true),
            $billing_interval,
            $billing_period
        );

        update_post_meta($subscription_id, '_next_payment_date', $next_payment_date);
    }

    /**
     * Validate the payment gateway state when a subscription is reactivated
     *
     * Checks whether the subscription's original payment gateway is still
     * available and properly configured. Adds subscription notes to inform
     * the admin about the gateway state after reactivation:
     * - Manual payment: simple reactivation note
     * - Automatic gateway available + payment method valid: confirms auto-billing
     * - Automatic gateway available but payment method missing/expired: warning note
     * - Automatic gateway no longer available: warning that billing will be manual
     *
     * @param int    $subscription_id Subscription ID
     * @param string $old_status Previous status before reactivation
     */
    private function validateGatewayOnReactivation(int $subscription_id, string $old_status): void
    {
        $reactivation_statuses = ['arraysubs-cancelled', 'arraysubs-expired', 'arraysubs-on-hold'];

        if (!in_array($old_status, $reactivation_statuses, true)) {
            return;
        }

        if (!function_exists('arraysubs_add_subscription_note')) {
            return;
        }

        $payment_method       = get_post_meta($subscription_id, '_payment_method', true);
        $payment_method_title = get_post_meta($subscription_id, '_payment_method_title', true);
        $payment_gateway      = get_post_meta($subscription_id, '_payment_gateway', true);

        $old_status_label = str_replace('arraysubs-', '', $old_status);

        // Manual payment subscription — no gateway checks needed
        if (empty($payment_gateway)) {
            arraysubs_add_subscription_note(
                $subscription_id,
                sprintf(
                    /* translators: 1: old status, 2: payment method title */
                    __('Subscription reactivated from %1$s. Payment method: %2$s (manual billing).', 'arraysubs'),
                    $old_status_label,
                    !empty($payment_method_title) ? $payment_method_title : __('Manual', 'arraysubs')
                ),
                'private',
                0,
                [],
                'subscription'
            );
            return;
        }

        // Automatic payment subscription — validate gateway availability
        $is_gateway_available = function_exists('arraysubs_is_automatic_payment_subscription')
            && arraysubs_is_automatic_payment_subscription($subscription_id);

        if (!$is_gateway_available) {
            // Gateway was set but is no longer registered/enabled (Pro deactivated or gateway disabled)
            update_post_meta($subscription_id, '_gateway_status', 'detached');

            arraysubs_add_subscription_note(
                $subscription_id,
                sprintf(
                    /* translators: 1: old status, 2: gateway slug */
                    __('Subscription reactivated from %1$s. Warning: The automatic payment gateway (%2$s) is no longer available. Future renewals will use manual billing (invoice). The customer will need to pay renewal invoices manually, or the payment method must be updated.', 'arraysubs'),
                    $old_status_label,
                    $payment_gateway
                ),
                'private',
                0,
                [],
                'subscription'
            );
            return;
        }

        // Gateway is available — check payment method and gateway status
        $gateway_status    = get_post_meta($subscription_id, '_gateway_status', true);
        $gateway_pm_id     = get_post_meta($subscription_id, '_gateway_payment_method_id', true);
        $auto_renew        = function_exists('arraysubs_is_auto_renew_enabled')
            ? arraysubs_is_auto_renew_enabled($subscription_id)
            : true;

        // Check for payment method issues
        if (empty($gateway_pm_id)) {
            arraysubs_add_subscription_note(
                $subscription_id,
                sprintf(
                    /* translators: 1: old status, 2: payment method title */
                    __('Subscription reactivated from %1$s. Warning: No stored payment method found for the automatic gateway (%2$s). The customer must update their payment method before the next renewal, otherwise a manual invoice will be generated.', 'arraysubs'),
                    $old_status_label,
                    !empty($payment_method_title) ? $payment_method_title : $payment_gateway
                ),
                'private',
                0,
                [],
                'subscription'
            );
            return;
        }

        // Check gateway status
        if (!empty($gateway_status) && !in_array($gateway_status, ['active', ''], true)) {
            arraysubs_add_subscription_note(
                $subscription_id,
                sprintf(
                    /* translators: 1: old status, 2: payment method title, 3: gateway status */
                    __('Subscription reactivated from %1$s. Warning: The automatic payment gateway (%2$s) status is "%3$s". Automatic billing may not process until the gateway status is restored. If the gateway cannot be resumed, a manual invoice will be generated for the next renewal.', 'arraysubs'),
                    $old_status_label,
                    !empty($payment_method_title) ? $payment_method_title : $payment_gateway,
                    $gateway_status
                ),
                'private',
                0,
                [],
                'subscription'
            );
            return;
        }

        // Check auto-renew toggle
        if (!$auto_renew) {
            arraysubs_add_subscription_note(
                $subscription_id,
                sprintf(
                    /* translators: 1: old status, 2: payment method title */
                    __('Subscription reactivated from %1$s. Gateway: %2$s. Note: Auto-renew is currently disabled by the customer. The next renewal will generate a manual invoice unless auto-renew is re-enabled.', 'arraysubs'),
                    $old_status_label,
                    !empty($payment_method_title) ? $payment_method_title : $payment_gateway
                ),
                'private',
                0,
                [],
                'subscription'
            );
            return;
        }

        // All checks passed — gateway is healthy
        arraysubs_add_subscription_note(
            $subscription_id,
            sprintf(
                /* translators: 1: old status, 2: payment method title */
                __('Subscription reactivated from %1$s. Automatic billing will continue via %2$s.', 'arraysubs'),
                $old_status_label,
                !empty($payment_method_title) ? $payment_method_title : $payment_gateway
            ),
            'private',
            0,
            [],
            'subscription'
        );
    }

    /**
     * Activate subscriptions when order is paid
     *
     * This method can be called multiple times if an order transitions through
     * multiple paid statuses (e.g., pending → processing → completed).
     * We prevent duplicate processing by checking order meta.
     *
     * @param int       $order_id Order ID
     * @param \WC_Order $order Order object
     */
    public function activateSubscriptionsFromOrder(int $order_id, $order): void
    {
        if ($order->get_meta('_arraysubs_order_type', true) === 'plan_switch_checkout') {
            return;
        }

        // Prevent duplicate processing
        // This can happen if order goes: pending → processing → completed
        // Both 'processing' and 'completed' trigger arraysubs_order_paid
        $already_processed = $order->get_meta('_subscription_payment_processed', true);
        if ($already_processed === 'yes') {
            return;
        }
        // Check if this is a renewal order first (uses _subscription_id singular)
        $is_renewal = $order->get_meta('_is_renewal_order');
        $subscription_ids = [];

        if ($is_renewal === 'yes') {
            // Renewal orders store subscription ID in _subscription_id (singular)
            $subscription_id = (int) $order->get_meta('_subscription_id', true);
            if ($subscription_id) {
                $subscription_ids = [$subscription_id];
            }
        }

        // If not a renewal or no subscription found, check _subscription_ids (plural, for initial orders)
        if (empty($subscription_ids)) {
            $subscription_ids = get_post_meta($order_id, '_subscription_ids', true);
        }

        if (empty($subscription_ids) || !is_array($subscription_ids)) {
            // No direct subscription link, check order items for subscription info
            $subscription_ids = $this->findSubscriptionsInOrder($order);
        }

        if (empty($subscription_ids) && function_exists('arraysubs_get_subscription_ids_for_order')) {
            $subscription_ids = arraysubs_get_subscription_ids_for_order($order);
        }

        if (empty($subscription_ids)) {
            return;
        }

        $subscription_ids = array_values(array_unique(array_filter(array_map('absint', $subscription_ids))));

        if (empty($subscription_ids)) {
            return;
        }

        if ($is_renewal !== 'yes') {
            update_post_meta($order_id, '_subscription_ids', $subscription_ids);
        }

        foreach ($subscription_ids as $subscription_id) {
            $subscription = get_post($subscription_id);

            if (!$subscription || $subscription->post_type !== 'arraysubs_data') {
                continue;
            }

            if ($is_renewal) {
                $this->processRenewalPayment($subscription_id, $order_id, $order);
            } else {
                $this->processInitialPayment($subscription_id, $order_id, $order);
            }
        }

        // Mark order as processed to prevent duplicate processing
        // (order might transition: pending → processing → completed)
        $order->update_meta_data('_subscription_payment_processed', 'yes');
        $order->save();
    }

    /**
     * Process initial payment for subscription
     *
     * @param int       $subscription_id Subscription ID
     * @param int       $order_id Order ID
     * @param \WC_Order $order Order object
     */
    private function processInitialPayment(int $subscription_id, int $order_id, $order): void
    {
        $current_status = get_post_status($subscription_id);

        // Allow on-hold here because an initial payment can fail first and then
        // succeed on the same order after the customer retries with a new method.
        $allowed = ['arraysubs-pending', 'arraysubs-on-hold', 'auto-draft', 'draft', 'arraysubs-trial'];
        if (!in_array($current_status, $allowed, true)) {
            return;
        }

        // Check for trial
        $trial_length = (int) get_post_meta($subscription_id, '_trial_length', true);
        $has_trial = $trial_length > 0;

        // Determine the correct status.  If the subscription was already created
        // with arraysubs-trial we keep it; otherwise transition accordingly.
        $new_status = $has_trial ? 'arraysubs-trial' : 'arraysubs-active';

        if ($current_status !== $new_status) {
            update_post_meta($subscription_id, '_arraysubs_status_change_context', 'initial_payment');

            wp_update_post([
                'ID'          => $subscription_id,
                'post_status' => $new_status,
            ]);

            delete_post_meta($subscription_id, '_arraysubs_status_change_context');

            if ($has_trial) {
                do_action('arraysubs_trial_started', $subscription_id);
            }
        }

        // Update payment tracking
        update_post_meta($subscription_id, '_last_payment_date', current_time('mysql', true));
        delete_post_meta($subscription_id, '_on_hold_date');

        // Set start date only if not already set
        $start_date = get_post_meta($subscription_id, '_start_date', true);
        if (empty($start_date)) {
            update_post_meta($subscription_id, '_start_date', current_time('mysql', true));
        }

        if ((float) $order->get_total() > 0) {
            $completed_payments = (int) get_post_meta($subscription_id, '_completed_payments', true);
            update_post_meta($subscription_id, '_completed_payments', $completed_payments + 1);
        }

        // Add order to subscription's order list (stored as JSON)
        $order_ids_json = get_post_meta($subscription_id, '_order_ids', true);
        $order_ids = !empty($order_ids_json) ? json_decode($order_ids_json, true) : [];
        if (!is_array($order_ids)) {
            $order_ids = [];
        }
        if (!in_array($order_id, $order_ids, true)) {
            $order_ids[] = $order_id;
            update_post_meta($subscription_id, '_order_ids', \wp_json_encode($order_ids));
        }

        // Store payment method from order
        update_post_meta($subscription_id, '_payment_method', $order->get_payment_method());
        update_post_meta($subscription_id, '_payment_method_title', $order->get_payment_method_title());

        // Store payment token if available
        $this->storePaymentToken($subscription_id, $order);

        /**
         * Fires after payment context (method, token) is stored on the subscription.
         * Pro uses this to persist gateway-specific meta (customer ID, PM ID, etc.).
         *
         * @param int       $subscription_id Subscription ID.
         * @param int       $order_id        Order ID.
         * @param array     $context         Payment context data.
         */
        do_action('arraysubs_gateway_context_captured', $subscription_id, $order_id, [
            'payment_method'       => $order->get_payment_method(),
            'payment_method_title' => $order->get_payment_method_title(),
            'order_id'             => $order_id,
        ]);

        /**
         * Fires after a payment method is attached to a subscription.
         *
         * @param int   $subscription_id Subscription ID.
         * @param array $method_data      Payment method data.
         */
        do_action('arraysubs_payment_method_attached', $subscription_id, [
            'payment_method'       => $order->get_payment_method(),
            'payment_method_title' => $order->get_payment_method_title(),
            'order_id'             => $order_id,
        ]);

        // Schedule next renewal (important!)
        $this->scheduleSubscriptionRenewal($subscription_id);

        // Fire subscription activated action
        do_action('arraysubs_data_activated', $subscription_id, $order_id);

        /**
         * Fires after a gateway payment succeeds (initial payment).
         * Pro can use this to log gateway transaction details.
         *
         * @param int    $subscription_id Subscription ID.
         * @param int    $order_id        Order ID.
         * @param string $txn_id          Transaction ID from the order.
         * @param string $gateway_slug    Payment gateway slug.
         */
        do_action(
            'arraysubs_gateway_payment_succeeded',
            $subscription_id,
            $order_id,
            $order->get_transaction_id(),
            arraysubs_get_subscription_gateway($subscription_id) ?: $order->get_payment_method()
        );
    }

    /**
     * Process renewal payment for subscription
     *
     * @param int       $subscription_id Subscription ID
     * @param int       $order_id Order ID
     * @param \WC_Order $order Order object
     */
    private function processRenewalPayment(int $subscription_id, int $order_id, $order): void
    {
        // Update payment tracking
        update_post_meta($subscription_id, '_last_payment_date', current_time('mysql', true));

        // Only count this as a completed payment if money actually changed
        // hands. Trial-end zero-value renewals and store-credit-zeroed
        // renewals must not increment the counter, otherwise thresholds like
        // "Different Renewal Price after N payments" and subscription length
        // limits trip one cycle too early.
        if ((float) $order->get_total() > 0) {
            $completed_payments = (int) get_post_meta($subscription_id, '_completed_payments', true);
            update_post_meta($subscription_id, '_completed_payments', $completed_payments + 1);
        }

        // Add order to subscription's order list (stored as JSON)
        $order_ids_json = get_post_meta($subscription_id, '_order_ids', true);
        $order_ids = !empty($order_ids_json) ? json_decode($order_ids_json, true) : [];
        if (!is_array($order_ids)) {
            $order_ids = [];
        }
        if (!in_array($order_id, $order_ids, true)) {
            $order_ids[] = $order_id;
            update_post_meta($subscription_id, '_order_ids', \wp_json_encode($order_ids));
        }

        // Clear the pending renewal order ID since it's now paid
        delete_post_meta($subscription_id, '_pending_renewal_order_id');

        // Clear on-hold date if it was set
        delete_post_meta($subscription_id, '_on_hold_date');

        // Clear retry / failure state. The admin "Retry" button gates on
        // `_last_payment_failure`; leaving it set after success keeps the
        // button visible and confuses operators. Helper deletes all four
        // related meta keys in one place.
        if (function_exists('arraysubs_reset_payment_retry_attempts')) {
            arraysubs_reset_payment_retry_attempts($subscription_id);
        }

        // Apply pending plan switch (deferred APPLY_AT_RENEWAL).
        // The renewal order was built using the target plan price (see
        // OrderCreation::createRenewalOrder). Now that payment succeeded,
        // flip the subscription product, billing period, and quantity to
        // the target plan, then clear the pending switch. The next-payment
        // calculation below will use the new billing period/interval.
        $pending_payload = $order->get_meta('_pending_switch_payload');
        if (is_array($pending_payload) && !empty($pending_payload['target_product_id'])) {
            $this->applyPendingSwitch($subscription_id, $pending_payload, $order_id);
        }

        $scheduled_base_date = $this->getRenewalScheduleBaseDate($subscription_id, $order);

        // Calculate and set next payment date from the scheduled due date, not payment time
        $this->calculateAndSetNextPaymentDate($subscription_id, $scheduled_base_date);

        // Schedule next renewal
        $this->scheduleSubscriptionRenewal($subscription_id);

        // Check if subscription should apply different renewal price
        $this->checkDifferentRenewalPrice($subscription_id);

        // Ensure subscription is active (handles both on-hold AND active cases)
        // This covers: 1) subscription was on-hold due to failed payment
        // 2) subscription was active during grace period
        // 3) subscription was on trial
        $current_status = get_post_status($subscription_id);
        if (in_array($current_status, ['arraysubs-on-hold', 'arraysubs-active', 'arraysubs-trial'], true)) {
            // If it was on trial and this is a renewal, convert to active
            $new_status = ($current_status === 'arraysubs-trial') ? 'arraysubs-active' : 'arraysubs-active';

            if ($current_status !== $new_status) {
                update_post_meta($subscription_id, '_arraysubs_renewal_activation_in_progress', $order_id);
                update_post_meta($subscription_id, '_arraysubs_status_change_context', 'renewal_payment');

                wp_update_post([
                    'ID'          => $subscription_id,
                    'post_status' => $new_status,
                ]);

                delete_post_meta($subscription_id, '_arraysubs_renewal_activation_in_progress');
                delete_post_meta($subscription_id, '_arraysubs_status_change_context');
            }
        }

        // Fire renewal payment complete action
        do_action('arraysubs_renewal_payment_complete', $subscription_id, $order_id);

        /**
         * Fires after a gateway payment succeeds (renewal payment).
         *
         * @param int    $subscription_id Subscription ID.
         * @param int    $order_id        Order ID.
         * @param string $txn_id          Transaction ID from the order.
         * @param string $gateway_slug    Payment gateway slug.
         */
        do_action(
            'arraysubs_gateway_payment_succeeded',
            $subscription_id,
            $order_id,
            $order->get_transaction_id(),
            arraysubs_get_subscription_gateway($subscription_id) ?: $order->get_payment_method()
        );
    }

    /**
     * Apply a deferred plan switch (APPLY_AT_RENEWAL) after the renewal
     * order is paid. Updates the subscription product, billing period,
     * interval, quantity, and recurring price to the target plan, then
     * clears the pending-switch meta. The next-payment recalculation in
     * processRenewalPayment() runs immediately after this and uses the
     * new billing period.
     *
     * @param int   $subscription_id Subscription ID.
     * @param array $payload         The pending-switch payload from the order.
     * @param int   $order_id        Renewal order ID (for the audit note).
     */
    private function applyPendingSwitch(int $subscription_id, array $payload, int $order_id): void
    {
        $target_product_id = (int) ($payload['target_product_id'] ?? 0);
        $target_variation_id = (int) ($payload['target_variation_id'] ?? 0);
        $target_item_id = $target_variation_id ?: $target_product_id;
        $quantity = max(1, (int) ($payload['quantity'] ?? 1));

        if ($target_item_id <= 0) {
            return;
        }

        $old_product_id = get_post_meta($subscription_id, '_variation_id', true)
            ?: get_post_meta($subscription_id, '_product_id', true);

        $proration = [
            'success' => true,
            'subscription_id' => $subscription_id,
            'current_product_id' => absint($old_product_id),
            'new_product_id' => $target_item_id,
            'switch_type' => sanitize_key($payload['switch_type'] ?? 'crossgrade'),
            'proration_type' => 'apply_at_renewal',
            'currency' => get_post_meta($subscription_id, '_currency', true) ?: get_woocommerce_currency(),
            'new_plan' => [
                'billing_period' => sanitize_key($payload['new_billing_period'] ?? get_post_meta($subscription_id, '_billing_period', true)),
                'billing_interval' => max(1, (int) ($payload['new_billing_interval'] ?? get_post_meta($subscription_id, '_billing_interval', true))),
            ],
        ];

        $result = PlanManager::updateSubscriptionProduct($subscription_id, $target_item_id, $proration, $quantity);
        if (is_wp_error($result)) {
            $order = wc_get_order($order_id);
            if ($order) {
                $order->add_order_note(sprintf(
                    /* translators: %s: error message */
                    __('Pending plan switch could not be applied after renewal payment: %s', 'arraysubs'),
                    $result->get_error_message()
                ));
            }

            return;
        }

        // Different-renewal-price configuration belongs to the OLD plan
        // and no longer applies after the switch. Clear it.
        delete_post_meta($subscription_id, '_different_renewal_price');
        delete_post_meta($subscription_id, '_different_renewal_price_after');

        if (function_exists('arraysubs_clear_pending_switch')) {
            arraysubs_clear_pending_switch($subscription_id);
        }

        if (function_exists('arraysubs_add_subscription_note')) {
            $product = wc_get_product($target_variation_id ?: $target_product_id);
            arraysubs_add_subscription_note(
                $subscription_id,
                sprintf(
                    /* translators: 1: new product name, 2: order ID */
                    __('Plan switch applied at renewal: now on %1$s. Renewal order #%2$d settled the proration and switch fee.', 'arraysubs'),
                    $product ? arraysubs_get_subscription_product_name($product) : '#' . $target_product_id,
                    $order_id
                ),
                'customer',
                0,
                [],
                'subscription'
            );
        }

        do_action('arraysubs_pending_switch_applied', $subscription_id, $payload, $order_id);
    }

    /**
     * Handle order failure
     *
     * @param int       $order_id Order ID
     * @param \WC_Order $order Order object
     */
    private function handleOrderFailure(int $order_id, $order): void
    {
        // Renewal failures stay active during the configured grace window.
        // RenewalProcessor records the failure; the overdue checker owns the
        // later active -> on-hold transition.
        if ($order instanceof \WC_Order && 'yes' === $order->get_meta('_is_renewal_order', true)) {
            if ($order->has_status('cancelled')) {
                $this->clearPendingRenewalOrderReference($order_id, $order);
            }

            return;
        }

        $subscription_ids = function_exists('arraysubs_get_subscription_ids_for_order')
            ? arraysubs_get_subscription_ids_for_order($order)
            : get_post_meta($order_id, '_subscription_ids', true);

        if (empty($subscription_ids) || !is_array($subscription_ids)) {
            return;
        }

        $subscription_ids = array_values(array_unique(array_filter(array_map('absint', $subscription_ids))));

        foreach ($subscription_ids as $subscription_id) {
            $current_status = get_post_status($subscription_id);

            // Trial subscriptions can still have an initial payment, such as a signup fee.
            if (in_array($current_status, ['arraysubs-pending', 'arraysubs-active', 'arraysubs-trial'], true)) {
                update_post_meta($subscription_id, '_arraysubs_status_change_context', 'payment_failure');

                wp_update_post([
                    'ID'          => $subscription_id,
                    'post_status' => 'arraysubs-on-hold',
                ]);

                delete_post_meta($subscription_id, '_arraysubs_status_change_context');
            }
        }
    }

    /**
     * Clear stale pending-renewal pointers when an unpaid renewal invoice is cancelled.
     *
     * Failed renewal orders stay linked for retry and recovery screens. A cancelled
     * renewal invoice is different: it is no longer payable, so the subscription
     * should not keep pointing at it as the pending invoice.
     *
     * @param int       $order_id Order ID.
     * @param \WC_Order $order    Order object.
     * @return void
     */
    private function clearPendingRenewalOrderReference(int $order_id, \WC_Order $order): void
    {
        $subscription_ids = function_exists('arraysubs_get_subscription_ids_for_order')
            ? arraysubs_get_subscription_ids_for_order($order)
            : [];

        $order_subscription_id = (int) $order->get_meta('_subscription_id', true);
        if ($order_subscription_id > 0) {
            $subscription_ids[] = $order_subscription_id;
        }

        $subscription_ids = array_values(array_unique(array_filter(array_map('absint', $subscription_ids))));

        foreach ($subscription_ids as $subscription_id) {
            $pending_order_id = (int) get_post_meta($subscription_id, '_pending_renewal_order_id', true);

            if ($pending_order_id === $order_id) {
                delete_post_meta($subscription_id, '_pending_renewal_order_id');
            }
        }
    }

    /**
     * Handle order refund
     *
     * @param int       $order_id Order ID
     * @param \WC_Order $order Order object
     */
    private function handleOrderRefund(int $order_id, $order): void
    {
        if (!$order instanceof \WC_Order) {
            $order = wc_get_order($order_id);
        }

        if (!$order instanceof \WC_Order) {
            return;
        }

        $subscription_ids = function_exists('arraysubs_get_subscription_ids_for_order')
            ? arraysubs_get_subscription_ids_for_order($order)
            : get_post_meta($order_id, '_subscription_ids', true);

        if (!is_array($subscription_ids) || empty($subscription_ids)) {
            return;
        }

        foreach (array_unique(array_filter(array_map('absint', $subscription_ids))) as $subscription_id) {
            do_action('arraysubs_subscription_fully_refunded', $subscription_id, $order_id);
        }
    }

    /**
     * Find subscriptions linked to order items
     *
     * @param \WC_Order $order Order object
     * @return array Subscription IDs
     */
    private function findSubscriptionsInOrder($order): array
    {
        $subscription_ids = [];

        foreach ($order->get_items() as $item) {
            $sub_id = $item->get_meta('_subscription_id');
            if ($sub_id) {
                $subscription_ids[] = (int) $sub_id;
            }
        }

        return $subscription_ids;
    }

    /**
     * Calculate and set next payment date
     *
    * @param int    $subscription_id Subscription ID.
    * @param string $base_date       Base UTC datetime. Defaults to last payment/current time.
     */
    private function calculateAndSetNextPaymentDate(int $subscription_id, string $base_date = ''): void
    {
        $billing_interval = (int) get_post_meta($subscription_id, '_billing_interval', true) ?: 1;
        $billing_period = get_post_meta($subscription_id, '_billing_period', true) ?: 'month';

        // Lifetime subscriptions do not have recurring payments
        if ($billing_period === 'lifetime') {
            update_post_meta($subscription_id, '_next_payment_date', '');
            return;
        }

        $base_date = !empty($base_date)
            ? $base_date
            : (get_post_meta($subscription_id, '_last_payment_date', true) ?: current_time('mysql', true));

        // Check subscription length
        $subscription_length = (int) get_post_meta($subscription_id, '_subscription_length', true);
        $completed_payments = (int) get_post_meta($subscription_id, '_completed_payments', true);

        // If subscription has a length and we've reached it, set end date instead
        if ($subscription_length > 0 && $completed_payments >= $subscription_length) {
            update_post_meta($subscription_id, '_end_date', current_time('mysql', true));
            update_post_meta($subscription_id, '_next_payment_date', '');

            // Expire the subscription
            wp_update_post([
                'ID'          => $subscription_id,
                'post_status' => 'arraysubs-expired',
            ]);

            return;
        }

        if ('yes' === get_post_meta($subscription_id, '_renewal_sync_enabled', true) && function_exists('arraysubs_calculate_renewal_sync_cycle_dates')) {
            $synced_dates = arraysubs_calculate_renewal_sync_cycle_dates($base_date, $billing_interval, $billing_period);

            if (!empty($synced_dates['next_payment_date'])) {
                update_post_meta($subscription_id, '_next_payment_date', $synced_dates['next_payment_date']);
                return;
            }
        }

        $next_payment_date = arraysubs_calculate_next_payment_date($base_date, $billing_interval, $billing_period);

        update_post_meta($subscription_id, '_next_payment_date', $next_payment_date);
    }

    /**
     * Get the schedule base date for a paid renewal.
     *
     * @param int       $subscription_id Subscription ID.
     * @param \WC_Order $order           Renewal order.
     * @return string UTC MySQL datetime.
     */
    private function getRenewalScheduleBaseDate(int $subscription_id, $order): string
    {
        $synced_base_date = $this->getSyncedFirstRenewalBaseDate($subscription_id, $order);

        if (!empty($synced_base_date)) {
            return $synced_base_date;
        }

        if ($order instanceof \WC_Order) {
            $scheduled = $order->get_meta('_renewal_scheduled_date', true);

            if (!empty($scheduled)) {
                return (string) $scheduled;
            }
        }

        $next_payment = get_post_meta($subscription_id, '_next_payment_date', true);

        if (!empty($next_payment)) {
            return (string) $next_payment;
        }

        return current_time('mysql', true);
    }

    /**
     * Get the boundary a paid synced renewal consumed, as the schedule base date.
     *
     * This keeps Renewal Sync subscriptions anchored to their billing-cycle
     * boundary even when QA or an operator time-travels _next_payment_date.
     * The first synced renewal (cycle 2) consumes the stored first full
     * renewal boundary; every later renewal consumes the boundary that many
     * billing intervals after it. Deriving the base from the stored anchor
     * instead of _renewal_scheduled_date prevents a time-traveled second
     * renewal from resetting _next_payment_date to an already-consumed
     * boundary.
     *
     * @param int $subscription_id Subscription ID.
     * @return string UTC MySQL datetime or empty string.
     */
    private function getSyncedFirstRenewalBaseDate(int $subscription_id, $order = null): string
    {
        if ('yes' !== get_post_meta($subscription_id, '_renewal_sync_enabled', true)) {
            return '';
        }

        $first_full_renewal = (string) get_post_meta($subscription_id, '_renewal_sync_first_full_renewal_date', true);

        if (empty($first_full_renewal)) {
            return '';
        }

        $renewal_cycle_number = 0;

        if ($order instanceof \WC_Order) {
            $renewal_cycle_number = (int) $order->get_meta('_renewal_cycle_number', true);
        }

        if ($renewal_cycle_number <= 0) {
            // _completed_payments has already been incremented for this paid
            // renewal, so it matches the renewal cycle number at this point.
            $renewal_cycle_number = (int) get_post_meta($subscription_id, '_completed_payments', true);
        }

        // Cycle 1 is the initial order; the first renewal is cycle 2 and
        // consumes the stored first full renewal boundary directly.
        $boundaries_to_advance = max(0, $renewal_cycle_number - 2);

        if (0 === $boundaries_to_advance) {
            return $first_full_renewal;
        }

        if (!function_exists('arraysubs_increment_renewal_sync_boundary') || !function_exists('arraysubs_renewal_sync_to_utc_mysql')) {
            return '';
        }

        $interval = max(1, (int) get_post_meta($subscription_id, '_billing_interval', true));
        $period   = (string) get_post_meta($subscription_id, '_billing_period', true) ?: 'month';

        $site_timezone = function_exists('wp_timezone') ? wp_timezone() : new \DateTimeZone('UTC');

        try {
            $boundary = (new \DateTimeImmutable($first_full_renewal, new \DateTimeZone('UTC')))
                ->setTimezone($site_timezone);
        } catch (\Exception $e) {
            return '';
        }

        for ($i = 0; $i < $boundaries_to_advance; $i++) {
            $boundary = arraysubs_increment_renewal_sync_boundary($boundary, $interval, $period);
        }

        return arraysubs_renewal_sync_to_utc_mysql($boundary);
    }

    /**
     * Schedule subscription renewal
     *
     * Uses RenewalScheduler (which uses the centralized ActionScheduler)
     * to ensure consistent scheduling across the entire codebase.
     *
     * @param int $subscription_id Subscription ID
     */
    private function scheduleSubscriptionRenewal(int $subscription_id): void
    {
        $next_payment_date = get_post_meta($subscription_id, '_next_payment_date', true);

        if (empty($next_payment_date)) {
            return;
        }

        $timestamp = strtotime($next_payment_date);

        if ($timestamp <= time()) {
            return;
        }

        // Use RenewalScheduler which uses the centralized ActionScheduler
        RenewalScheduler::schedule($subscription_id, $timestamp);
    }

    /**
     * Unschedule subscription renewal
     *
     * Uses RenewalScheduler (which uses the centralized ActionScheduler)
     * to ensure consistent unscheduling across the entire codebase.
     *
     * @param int $subscription_id Subscription ID
     */
    private function unscheduleSubscriptionRenewal(int $subscription_id): void
    {
        // Use RenewalScheduler which uses the centralized ActionScheduler
        RenewalScheduler::unschedule($subscription_id);
    }

    /**
     * Check and apply different renewal price
     *
     * @param int $subscription_id Subscription ID
     */
    private function checkDifferentRenewalPrice(int $subscription_id): void
    {
        $enable_different = get_post_meta($subscription_id, '_enable_different_renewal_price', true);

        // Check both 'yes' string and boolean true
        if ($enable_different !== 'yes' && $enable_different !== true && $enable_different !== '1') {
            return;
        }

        $different_price = (float) get_post_meta($subscription_id, '_different_renewal_price', true);
        $after_payments = (int) get_post_meta($subscription_id, '_different_renewal_price_after', true);
        $completed_payments = (int) get_post_meta($subscription_id, '_completed_payments', true);

        // Default after_payments to 1 if not set
        if ($after_payments <= 0) {
            $after_payments = 1;
        }

        if ($completed_payments >= $after_payments && $different_price > 0) {
            // Update recurring amount to different renewal price
            $current_recurring = (float) get_post_meta($subscription_id, '_recurring_amount', true);

            if ($current_recurring !== $different_price) {
                update_post_meta($subscription_id, '_recurring_amount', $different_price);
            }
        }
    }

    /**
     * Store payment token from order
     *
     * @param int       $subscription_id Subscription ID
     * @param \WC_Order $order Order object
     */
    private function storePaymentToken(int $subscription_id, $order): void
    {
        $tokens = $order->get_payment_tokens();

        if (empty($tokens)) {
            return;
        }

        $token_id = reset($tokens);
        $token = \WC_Payment_Tokens::get($token_id);

        if ($token) {
            update_post_meta($subscription_id, '_payment_token_id', $token->get_id());
            update_post_meta($subscription_id, '_payment_method_token', $token->get_token());
        }
    }
}
