<?php

/**
 * Auto-Downgrade Handler
 *
 * Listens to subscription status transitions and automatically
 * switches the subscription to a fallback plan when it expires
 * or is cancelled, based on product-level and global settings.
 *
 * @package ArraySubs\Features\PlanSwitching\Services
 * @since 1.7.0
 */

namespace ArraySubs\Features\PlanSwitching\Services;

use ArraySubs\Features\RecurringBilling\Services\RenewalScheduler;

defined('ABSPATH') || exit;

/**
 * Auto-downgrade handler
 */
class AutoDowngradeHandler
{
    /**
     * Non-hook instance used for static execution helpers.
     *
     * @var self|null
     */
    private static ?self $processor = null;

    /**
     * Enable auto-loading via AbstractLoader
     *
     * @var bool
     */
    public static $loadable = true;

    /**
     * Guard flag to prevent chained auto-downgrades
     *
     * @var bool
     */
    private static bool $processing = false;

    /**
     * Check whether an auto-downgrade is currently being processed.
     *
     * @return bool
     */
    public static function isProcessing(): bool
    {
        return self::$processing;
    }

    /**
     * Constructor - Register hooks.
     *
     * @param bool $register_hooks Whether to register WordPress hooks on construction.
     */
    public function __construct(bool $register_hooks = true)
    {
        if (!$register_hooks) {
            return;
        }

        add_action('arraysubs_data_status_changed', [$this, 'onStatusChanged'], 20, 3);
        add_action('arraysubs_trial_converted', [$this, 'onTrialConverted'], 20, 1);
    }

    /**
     * Get a non-hook processor instance.
     *
     * @return self
     */
    private static function getProcessor(): self
    {
        if (null === self::$processor) {
            self::$processor = new self(false);
        }

        return self::$processor;
    }

    /**
     * Handle subscription status change
     *
     * Executes synchronously on `arraysubs_data_status_changed` to avoid
     * race conditions with `ActionScheduler::unscheduleAllForSubscription()`.
     *
     * @param int    $subscription_id Subscription ID.
     * @param string $new_status      New status (e.g. 'arraysubs-expired').
     * @param string $old_status      Old status.
     */
    public function onStatusChanged(int $subscription_id, string $new_status, string $old_status): void
    {
        if (self::$processing) {
            return;
        }

        if (!self::shouldHandleLifecycleTransition($subscription_id, $new_status, $old_status)) {
            return;
        }

        $this->executeAutoDowngrade($subscription_id, $new_status, $old_status);
    }

    /**
     * Handle converted trial subscriptions.
     *
     * The current billing flow converts trials to active subscriptions, so this
     * hook provides the closest runtime equivalent to a trial-expiry downgrade.
     *
     * @param int $subscription_id Subscription ID.
     * @return void
     */
    public function onTrialConverted(int $subscription_id): void
    {
        if (self::$processing) {
            return;
        }

        if (!self::shouldHandleTrialConversion($subscription_id)) {
            return;
        }

        $this->executeAutoDowngrade($subscription_id, 'arraysubs-trial-expired', 'arraysubs-trial');
    }

    /**
     * Determine whether a lifecycle transition qualifies for auto-downgrade.
     *
     * @param int    $subscription_id Subscription ID.
     * @param string $new_status      New status.
     * @param string $old_status      Old status.
     * @return bool
     */
    public static function shouldHandleLifecycleTransition(int $subscription_id, string $new_status, string $old_status): bool
    {
        $timing = (string) arraysubs_get_setting('plan_switching.auto_downgrade_timing', 'on_expire');

        switch ($timing) {
            case 'on_expire':
                if ($new_status !== 'arraysubs-expired') {
                    return false;
                }
                break;

            case 'on_cancel':
                if (!self::isEligibleCancellationTransition($subscription_id, $new_status, $old_status)) {
                    return false;
                }
                break;

            case 'on_trial_expire':
            default:
                return false;
        }

        // Skip if retention offer was accepted during cancellation
        if ($new_status === 'arraysubs-cancelled') {
            $retention_accepted = get_post_meta($subscription_id, '_retention_offer_accepted', true);
            if ($retention_accepted) {
                return false;
            }
        }

        // Verify a valid target product is configured
        $target_product_id = self::getTargetProductId($subscription_id);
        if (!$target_product_id) {
            return false;
        }

        return true;
    }

    /**
     * Determine whether trial conversion should trigger auto-downgrade.
     *
     * @param int $subscription_id Subscription ID.
     * @return bool
     */
    public static function shouldHandleTrialConversion(int $subscription_id): bool
    {
        $timing = (string) arraysubs_get_setting('plan_switching.auto_downgrade_timing', 'on_expire');

        if ($timing !== 'on_trial_expire') {
            return false;
        }

        return self::getTargetProductId($subscription_id) > 0;
    }

    /**
     * Process a trial-expiry auto-downgrade before the subscription is converted
     * to the regular paid plan.
     *
     * @param int $subscription_id Subscription ID.
     * @return bool True when the auto-downgrade completed successfully.
     */
    public static function processTrialExpiry(int $subscription_id): bool
    {
        if (self::$processing || !self::shouldHandleTrialConversion($subscription_id)) {
            return false;
        }

        return self::getProcessor()->executeAutoDowngrade(
            $subscription_id,
            'arraysubs-trial-expired',
            'arraysubs-trial'
        );
    }

    /**
     * Determine whether lifecycle emails should be suppressed because the
     * subscription will immediately auto-downgrade.
     *
     * @param int    $subscription_id Subscription ID.
     * @param string $new_status      New status.
     * @param string $old_status      Old status.
     * @return bool
     */
    public static function shouldSuppressLifecycleEmail(int $subscription_id, string $new_status, string $old_status): bool
    {
        if (self::shouldHandleLifecycleTransition($subscription_id, $new_status, $old_status)) {
            return true;
        }

        return $new_status === 'arraysubs-active'
            && $old_status === 'arraysubs-trial'
            && self::shouldHandleTrialConversion($subscription_id);
    }

    /**
     * Determine whether a cancellation transition is eligible for auto-downgrade.
     *
     * Immediate cancellations are excluded because the feature targets the
     * grace-period and overdue-cancellation paths.
     *
     * @param int    $subscription_id Subscription ID.
     * @param string $new_status      New status.
     * @param string $old_status      Old status.
     * @return bool
     */
    private static function isEligibleCancellationTransition(int $subscription_id, string $new_status, string $old_status): bool
    {
        if ($new_status !== 'arraysubs-cancelled') {
            return false;
        }

        $cancellation_type = (string) get_post_meta($subscription_id, '_cancellation_type', true);

        if ($cancellation_type === 'immediate') {
            return false;
        }

        if ($old_status === 'arraysubs-on-hold') {
            return true;
        }

        return $cancellation_type === 'end_of_period';
    }

    /**
     * Execute the auto-downgrade
     *
     * @param int    $subscription_id Subscription ID.
     * @param string $new_status      New status that triggered the downgrade.
     * @param string $old_status      Previous status.
     * @return bool True when the downgrade completed successfully.
     */
    private function executeAutoDowngrade(int $subscription_id, string $new_status, string $old_status): bool
    {
        self::$processing = true;

        try {
            $target_product_id = self::getTargetProductId($subscription_id);

            if (!$target_product_id) {
                return false;
            }

            $target_product = wc_get_product($target_product_id);

            if (!$this->isValidTarget($target_product, $subscription_id)) {
                arraysubs_add_subscription_note(
                    $subscription_id,
                    __('Auto-downgrade skipped: target product is unavailable or invalid.', 'arraysubs'),
                    'private',
                    0,
                    [],
                    'subscription'
                );
                return false;
            }

            $old_product_id = self::getCurrentProductId($subscription_id);
            $old_product = wc_get_product($old_product_id);

            // Cancel gateway subscription if applicable
            $this->cancelGatewaySubscription($subscription_id);

            // Cancel any stale unpaid renewal invoice from the previous plan.
            $this->cancelPendingRenewalOrder($subscription_id);

            $existing_target_subscription_id = $this->findExistingTargetSubscription($subscription_id, $target_product_id);

            if ($existing_target_subscription_id > 0) {
                $this->reuseExistingTargetSubscription(
                    $subscription_id,
                    $existing_target_subscription_id,
                    $target_product,
                    $new_status
                );

                return true;
            }

            // Build minimal proration data for updateSubscriptionProduct
            $proration = $this->buildProrationData($target_product, $subscription_id);

            $result = PlanManager::updateSubscriptionProduct(
                $subscription_id,
                $target_product_id,
                $proration,
                1
            );

            if (is_wp_error($result)) {
                arraysubs_add_subscription_note(
                    $subscription_id,
                    sprintf(
                        /* translators: %s: error message */
                        __('Auto-downgrade failed: %s', 'arraysubs'),
                        $result->get_error_message()
                    ),
                    'private',
                    0,
                    [],
                    'subscription'
                );
                return false;
            }

            if ($new_status === 'arraysubs-trial-expired') {
                $this->clearTrialState($subscription_id);
            }

            // Clear stale cancellation state before reactivating the subscription.
            $this->clearEndedStateMeta($subscription_id);

            // Remote automatic billing should not continue for the downgraded plan.
            $this->detachAutomaticPaymentContext($subscription_id);

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

            // Schedule renewal for the new plan
            $this->scheduleNewRenewal($subscription_id, $target_product);

            // Add subscription notes
            $old_name = $old_product
                ? arraysubs_get_subscription_product_name($old_product)
                : '#' . $old_product_id;
            $new_name = arraysubs_get_subscription_product_name($target_product);
            $reason   = $this->getReasonLabel($new_status);

            arraysubs_add_subscription_note(
                $subscription_id,
                sprintf(
                    /* translators: %s: new plan name */
                    __('Your subscription has been automatically changed to %s.', 'arraysubs'),
                    $new_name
                ),
                'customer',
                0,
                [],
                'subscription'
            );

            arraysubs_add_subscription_note(
                $subscription_id,
                sprintf(
                    /* translators: 1: old plan name, 2: new plan name, 3: reason */
                    __('Auto-downgraded from %1$s to %2$s due to %3$s.', 'arraysubs'),
                    $old_name,
                    $new_name,
                    $reason
                ),
                'private',
                0,
                [],
                'subscription'
            );

            // Fire email action
            do_action(
                'arraysubs_auto_downgrade_completed',
                $subscription_id,
                $old_product_id,
                $target_product_id
            );

            return true;
        } finally {
            self::$processing = false;
        }
    }

    /**
     * Get the auto-downgrade target product ID for a subscription
     *
     * Looks up `_arraysubs_auto_downgrade_product` on the subscription's
     * current product (variation first, then parent).
     *
     * @param int $subscription_id Subscription ID.
     * @return int Target product ID, or 0 if not configured.
     */
    private static function getTargetProductId(int $subscription_id): int
    {
        $current_product_id = self::getCurrentProductId($subscription_id);

        if (!$current_product_id) {
            return 0;
        }

        $target = absint(get_post_meta($current_product_id, '_arraysubs_auto_downgrade_product', true));

        // If no target on variation, check the parent product
        if (!$target) {
            $parent_id = absint(get_post_meta($subscription_id, '_product_id', true));
            if ($parent_id && $parent_id !== $current_product_id) {
                $target = absint(get_post_meta($parent_id, '_arraysubs_auto_downgrade_product', true));
            }
        }

        // Prevent self-downgrade loop
        if ($target === $current_product_id) {
            return 0;
        }

        return $target;
    }

    /**
     * Get the current effective product ID for the subscription
     *
     * @param int $subscription_id Subscription ID.
     * @return int
     */
    private static function getCurrentProductId(int $subscription_id): int
    {
        $variation_id = absint(get_post_meta($subscription_id, '_variation_id', true));
        if ($variation_id) {
            return $variation_id;
        }

        return absint(get_post_meta($subscription_id, '_product_id', true));
    }

    /**
     * Validate that the target product is a usable subscription product
     *
     * @param \WC_Product|false|null $product         Product object.
     * @param int                    $subscription_id Subscription ID for context.
     * @return bool
     */
    private function isValidTarget($product, int $subscription_id): bool
    {
        if (!$product || !($product instanceof \WC_Product)) {
            return false;
        }

        // Must be published
        if ($product->get_status() !== 'publish') {
            return false;
        }

        // Must be a subscription product
        $is_subscription = get_post_meta($product->get_id(), '_is_subscription', true);
        if ($is_subscription !== 'yes') {
            return false;
        }

        // Must not be out of stock.
        if (method_exists($product, 'is_in_stock') && !$product->is_in_stock()) {
            return false;
        }

        return true;
    }

    /**
     * Cancel the stale pending renewal order left by the previous plan, if any.
     *
     * @param int $subscription_id Subscription ID.
     * @return void
     */
    private function cancelPendingRenewalOrder(int $subscription_id): void
    {
        $pending_order_id = (int) get_post_meta($subscription_id, '_pending_renewal_order_id', true);

        if ($pending_order_id > 0) {
            $order = wc_get_order($pending_order_id);

            if ($order && $order->has_status(['pending', 'on-hold', 'failed'])) {
                $order->update_status(
                    'cancelled',
                    __('Pending renewal invoice cancelled after automatic plan downgrade.', 'arraysubs')
                );
            }
        }

        delete_post_meta($subscription_id, '_pending_renewal_order_id');
    }

    /**
     * Clear stale cancellation and failure state when the downgraded subscription
     * is returned to active service.
     *
     * @param int $subscription_id Subscription ID.
     * @return void
     */
    private function clearEndedStateMeta(int $subscription_id): void
    {
        delete_post_meta($subscription_id, '_waiting_cancellation');
        delete_post_meta($subscription_id, '_cancellation_scheduled_date');
        delete_post_meta($subscription_id, '_cancelled_date');
        delete_post_meta($subscription_id, '_cancelled_by');
        delete_post_meta($subscription_id, '_cancellation_type');
        delete_post_meta($subscription_id, '_cancellation_override_scheduled');
        delete_post_meta($subscription_id, '_cancellation_source');
        delete_post_meta($subscription_id, '_last_payment_failure');
        delete_post_meta($subscription_id, '_last_payment_failure_reason');
        delete_post_meta($subscription_id, '_on_hold_date');
    }

    /**
     * Clear trial state after trial-expiry auto-downgrade.
     *
     * @param int $subscription_id Subscription ID.
     * @return void
     */
    private function clearTrialState(int $subscription_id): void
    {
        update_post_meta($subscription_id, '_trial_length', 0);
        delete_post_meta($subscription_id, '_trial_end_date');
    }

    /**
     * Cancel any gateway-managed subscription before switching
     *
     * @param int $subscription_id Subscription ID.
     */
    private function cancelGatewaySubscription(int $subscription_id): void
    {
        $gateway_slug = get_post_meta($subscription_id, '_payment_gateway', true);

        if (!empty($gateway_slug)) {
            do_action('arraysubs_gateway_subscription_cancelled', $subscription_id, $gateway_slug);
        }
    }

    /**
     * Find an existing non-terminal subscription for the same customer that is
     * already on the target fallback product/variation.
     *
     * @param int $subscription_id  Current subscription ID.
     * @param int $target_product_id Target product or variation ID.
     * @return int Existing subscription ID or 0 if none found.
     */
    private function findExistingTargetSubscription(int $subscription_id, int $target_product_id): int
    {
        $customer_id = (int) get_post_meta($subscription_id, '_customer_id', true);

        if ($customer_id <= 0 || $target_product_id <= 0) {
            return 0;
        }

        $subscriptions = arraysubs_get_customer_subscriptions(
            $customer_id,
            ['arraysubs-active', 'arraysubs-trial']
        );

        foreach ($subscriptions as $candidate_id) {
            $candidate_id = (int) $candidate_id;

            if ($candidate_id <= 0 || $candidate_id === $subscription_id) {
                continue;
            }

            if ($this->getEffectiveSubscriptionProductId($candidate_id) === $target_product_id) {
                return $candidate_id;
            }
        }

        return 0;
    }

    /**
     * Get the effective product identity for a subscription (variation first,
     * then parent/simple product).
     *
     * @param int $subscription_id Subscription ID.
     * @return int
     */
    private function getEffectiveSubscriptionProductId(int $subscription_id): int
    {
        $variation_id = (int) get_post_meta($subscription_id, '_variation_id', true);

        if ($variation_id > 0) {
            return $variation_id;
        }

        return (int) get_post_meta($subscription_id, '_product_id', true);
    }

    /**
     * Reuse an already-active fallback subscription instead of creating a second
     * active subscription on the same downgraded plan.
     *
     * @param int         $subscription_id             Current subscription ID.
     * @param int         $existing_subscription_id    Existing matching subscription ID.
     * @param \WC_Product $target_product             Target fallback product.
     * @param string      $new_status                  Triggering lifecycle status.
     * @return void
     */
    private function reuseExistingTargetSubscription(
        int $subscription_id,
        int $existing_subscription_id,
        \WC_Product $target_product,
        string $new_status
    ): void {
        if ($new_status === 'arraysubs-trial-expired') {
            $this->clearTrialState($subscription_id);

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

            update_post_meta($subscription_id, '_end_date', current_time('mysql', true));
        }

        update_post_meta($subscription_id, '_auto_downgraded_into_subscription_id', $existing_subscription_id);

        $target_name = arraysubs_get_subscription_product_name($target_product);

        arraysubs_add_subscription_note(
            $subscription_id,
            sprintf(
                /* translators: 1: plan name, 2: subscription ID */
                __('Auto-downgrade reused existing %1$s subscription #%2$d instead of creating a duplicate fallback subscription.', 'arraysubs'),
                $target_name,
                $existing_subscription_id
            ),
            'private',
            0,
            [],
            'subscription'
        );

        arraysubs_add_subscription_note(
            $existing_subscription_id,
            sprintf(
                /* translators: 1: plan name, 2: subscription ID */
                __('Existing %1$s fallback subscription reused for auto-downgrade from subscription #%2$d.', 'arraysubs'),
                $target_name,
                $subscription_id
            ),
            'private',
            0,
            [],
            'subscription'
        );
    }

    /**
     * Revert automatic gateway billing metadata so the downgraded subscription
     * continues on manual renewals until a new payment method is attached.
     *
     * @param int $subscription_id Subscription ID.
     * @return void
     */
    private function detachAutomaticPaymentContext(int $subscription_id): void
    {
        $gateway_slug = (string) get_post_meta($subscription_id, '_payment_gateway', true);

        if ($gateway_slug === '') {
            return;
        }

        update_post_meta($subscription_id, '_gateway_status', 'detached');

        delete_post_meta($subscription_id, '_payment_gateway');
        delete_post_meta($subscription_id, '_gateway_payment_method_id');
        delete_post_meta($subscription_id, '_gateway_session_id');
        delete_post_meta($subscription_id, '_last_gateway_transaction_id');
        delete_post_meta($subscription_id, '_payment_method_brand');
        delete_post_meta($subscription_id, '_payment_method_last4');
        delete_post_meta($subscription_id, '_payment_method_type');
        delete_post_meta($subscription_id, '_payment_method_expiry_month');
        delete_post_meta($subscription_id, '_payment_method_expiry_year');
    }

    /**
     * Build proration data array for PlanManager::updateSubscriptionProduct()
     *
     * Auto-downgrade is always free (no charge / no refund).
     *
     * @param \WC_Product $target_product Target product.
     * @param int         $subscription_id Subscription ID.
     * @return array
     */
    private function buildProrationData(\WC_Product $target_product, int $subscription_id): array
    {
        $billing_period   = get_post_meta($target_product->get_id(), '_subscription_period', true) ?: 'month';
        $billing_interval = absint(get_post_meta($target_product->get_id(), '_subscription_period_interval', true)) ?: 1;

        return [
            'switch_type'            => 'auto_downgrade',
            'net_amount'             => 0,
            'refund_amount'          => 0,
            'currency'               => get_woocommerce_currency(),
            'new_product_id'         => $target_product->get_id(),
            'current_product_id'     => $this->getCurrentProductId($subscription_id),
            'new_plan'               => [
                'billing_period'   => $billing_period,
                'billing_interval' => $billing_interval,
            ],
            'new_next_payment_date'  => '',
        ];
    }

    /**
     * Schedule the first renewal for the downgraded subscription
     *
     * @param int         $subscription_id Subscription ID.
     * @param \WC_Product $target_product  Target product.
     */
    private function scheduleNewRenewal(int $subscription_id, \WC_Product $target_product): void
    {
        $billing_period   = get_post_meta($target_product->get_id(), '_subscription_period', true) ?: 'month';
        $billing_interval = absint(get_post_meta($target_product->get_id(), '_subscription_period_interval', true)) ?: 1;

        if ($billing_period === 'lifetime') {
            update_post_meta($subscription_id, '_next_payment_date', '');
            RenewalScheduler::unschedule($subscription_id);
            return;
        }

        $next_payment_timestamp = strtotime("+{$billing_interval} {$billing_period}");

        if ($next_payment_timestamp) {
            $next_payment_date = gmdate('Y-m-d H:i:s', $next_payment_timestamp);
            update_post_meta($subscription_id, '_next_payment_date', $next_payment_date);

            RenewalScheduler::unschedule($subscription_id);
            RenewalScheduler::schedule($subscription_id, $next_payment_timestamp);
        }
    }

    /**
     * Get a human-readable reason label for the downgrade trigger
     *
     * @param string $status The triggering status.
     * @return string
     */
    private function getReasonLabel(string $status): string
    {
        switch ($status) {
            case 'arraysubs-expired':
                return __('subscription expiration', 'arraysubs');
            case 'arraysubs-cancelled':
                return __('subscription cancellation', 'arraysubs');
            case 'arraysubs-trial-expired':
                return __('trial expiry', 'arraysubs');
            default:
                return __('status change', 'arraysubs');
        }
    }
}
