<?php

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

require_once(CHURNSOLUTION_DIR . "/classes/class-churnsolution-stripe-gateway.php");
require_once(CHURNSOLUTION_DIR . "/util/class-currency-utils.php");

// Official WooCommerce Stripe Gateway (woocommerce-gateway-stripe).
const CSR_STRIPE_CUSTOMER_META = '_stripe_customer_id';
const CSR_STRIPE_SOURCE_META   = '_stripe_source_id';
const CSR_GATEWAY_ID           = 'stripe';

// Payment Plugins for Stripe (woo-stripe-payment), which registers stripe_cc,
// stripe_googlepay, stripe_applepay and friends. It reads its own meta and only falls
// back to the keys above when its own are empty, so writing the official plugin's keys
// on an order that already carries these has no effect at all.
const CSR_PP_STRIPE_TOKEN_META    = '_payment_method_token';
const CSR_PP_STRIPE_CUSTOMER_META = '_wc_stripe_customer';

class Churnsolution_Subscription_Controller
{

    // Here initialize our namespace and resource name.
    /**
     * @var string
     */
    private $namespace;

    /**
     * @var string
     */
    private $resource_name;

    public function __construct()
    {
        // No leading slash: WordPress rejects it and logs _doing_it_wrong on every request.
        $this->namespace = 'churnsolution/v1';
        $this->resource_name = 'subscription';
    }

    // Register our routes.
    public function register_routes()
    {
        register_rest_route($this->namespace, '/' . $this->resource_name, array(
            array(
                'methods' => 'GET',
                'callback' => array($this, 'get_subscription'),
                'permission_callback' => array($this, 'verify_logged_in'),
            )
        ));

        register_rest_route($this->namespace, '/' . $this->resource_name . '/apply-coupon', array(
            array(
                'methods' => 'POST',
                'callback' => array($this, 'apply_coupon'),
                'permission_callback' => array($this, 'verify_logged_in'),
            )
        ));

        register_rest_route($this->namespace, '/' . $this->resource_name . '/extend-free-trial', array(
            array(
                'methods' => 'POST',
                'callback' => array($this, 'extend_free_trial'),
                'permission_callback' => array($this, 'verify_logged_in'),
            )
        ));

        register_rest_route($this->namespace, '/' . $this->resource_name . '/pause', array(
            array(
                'methods' => 'POST',
                'callback' => array($this, 'pause_subscription'),
                'permission_callback' => array($this, 'verify_logged_in'),
            )
        ));

        register_rest_route($this->namespace, '/' . $this->resource_name . '/sync-subscription', array(
            array(
                'methods' => 'POST',
                'callback' => array($this, 'sync_subscription'),
                'permission_callback' => array($this, 'verify_logged_in'),
            )
        ));

        register_rest_route($this->namespace, '/' . $this->resource_name . '/cancel', array(
            array(
                'methods' => 'POST',
                'callback' => array($this, 'cancel'),
                'permission_callback' => array($this, 'verify_logged_in'),
            )
        ));

        // Server-to-server: called by the Churn Solution backend right after it
        // creates a new Stripe subscription while reactivating a cancelled one.
        // Authenticated via an HMAC signature (see verify_signature), NOT a WP
        // login cookie, since there is no logged-in user during this call.
        register_rest_route($this->namespace, '/' . $this->resource_name . '/reactivate', array(
            array(
                'methods' => 'POST',
                'callback' => array($this, 'reactivate'),
                'permission_callback' => array($this, 'verify_signature'),
            )
        ));

        // Server-to-server: retry charging a failed/pending WooCommerce renewal
        // order off-session. WooCommerce exposes no REST endpoint that charges a
        // saved token, so the Churn Solution backend calls this instead.
        // Authenticated via HMAC signature (see verify_signature).
        register_rest_route($this->namespace, '/' . $this->resource_name . '/retry-payment', array(
            array(
                'methods' => 'POST',
                'callback' => array($this, 'retry_payment'),
                'permission_callback' => array($this, 'verify_signature'),
            )
        ));

        // Server-to-server: WooCommerce Subscriptions' own retry state for a
        // renewal order, read from the WCS retry store — the attempts already
        // made and the one still pending, if any.
        register_rest_route($this->namespace, '/' . $this->resource_name . '/retry-context', array(
            array(
                'methods' => 'POST',
                'callback' => array($this, 'retry_context'),
                'permission_callback' => array($this, 'verify_signature'),
            )
        ));

        register_rest_route($this->namespace, '/customers/(?P<customer_id>\d+)/payment-methods', array(
            'methods' => 'GET',
            'callback' => 'csr_get_customer_payment_methods',
            'permission_callback' => 'csr_permission_check',
            'args' => array(
                'customer_id' => array('validate_callback' => 'csr_is_numeric'),
            ),
        ));

    }

    /**
     * Permission callback for the customer-facing endpoints.
     *
     * These act on the current user's own subscription, so they need a logged-in user.
     * Without a permission_callback WordPress logs _doing_it_wrong and serves the route
     * to anyone, which made every subscription on the site readable by ID.
     *
     * Per-subscription ownership is checked separately in each handler, since being
     * logged in does not entitle a customer to somebody else's subscription.
     */
    public function verify_logged_in()
    {
        return is_user_logged_in();
    }

    /**
     * Resolves a WooCommerce subscription from the request and confirms it belongs to
     * the current user. Returns a WP_REST_Response on failure so callers can bail.
     *
     * @return WC_Subscription|WP_REST_Response
     */
    private function get_own_wc_subscription(WP_REST_Request $args)
    {
        $subscription_id = (int)$args->get_param('subscription_id');

        if (empty($subscription_id)) {
            return new WP_REST_Response(array('error' => 'subscription_id is required'), 400);
        }

        $subscription = wcs_get_subscription($subscription_id);

        if (empty($subscription)) {
            return new WP_REST_Response(array('error' => 'Subscription not found'), 404);
        }

        if (!self::user_owns_wc_subscription($subscription_id)) {
            return new WP_REST_Response(array('error' => 'Unauthorized'), 403);
        }

        return $subscription;
    }

    /**
     * Whether the current user owns the subscription the given identifier names.
     *
     * The two integrations name a subscription differently - PMPro carries the gateway's own
     * reference, WooCommerce its local post id - and a store can be running either, so both are
     * tried. PMPro first, matching how get_subscription decides which integration it is serving.
     *
     * @return bool
     */
    public static function user_owns_subscription($subscription_id)
    {
        return self::user_owns_pmpro_subscription($subscription_id)
            || self::user_owns_wc_subscription($subscription_id);
    }

    private static function user_owns_pmpro_subscription($subscription_id)
    {
        if (!class_exists('PMPro_Subscription')) {
            return false;
        }

        foreach (PMPro_Subscription::get_subscriptions_for_user(get_current_user_id()) as $subscription) {
            if ($subscription->get_subscription_transaction_id() === $subscription_id) {
                return true;
            }
        }

        return false;
    }

    private static function user_owns_wc_subscription($subscription_id)
    {
        // Only a canonical integer names a WooCommerce subscription. Anything else is either a
        // PMPro reference, which this branch cannot answer for, or a padded form like "0496" that
        // would be signed verbatim and then fail to resolve at the other end.
        if (!function_exists('wcs_get_subscription')
            || !ctype_digit((string)$subscription_id)
            || (string)(int)$subscription_id !== (string)$subscription_id) {
            return false;
        }

        $subscription = wcs_get_subscription((int)$subscription_id);

        return !empty($subscription)
            && (int)$subscription->get_user_id() === (int)get_current_user_id();
    }

    /**
     * Permission callback for server-to-server endpoints.
     *
     * Verifies an HMAC-SHA256 signature computed by the Churn Solution backend
     * over "{timestamp}.{raw body}" using the shared connection api_key as the
     * secret. The api_key is never transmitted; only the signature is. A 5
     * minute timestamp window guards against replay, and hash_equals is used
     * for constant-time comparison.
     *
     * Expected request headers:
     *   X-Churnsolution-Signature: hex(hmac_sha256(timestamp . "." . body, api_key))
     *   X-Churnsolution-Timestamp: unix seconds
     */
    public function verify_signature(WP_REST_Request $request)
    {
        if (!class_exists('Churnsolution_Connection')) {
            return false;
        }

        $connection = Churnsolution_Connection::get_connection();
        if (empty($connection) || empty($connection->getApiKey())) {
            return false;
        }

        $signature = $request->get_header('x_churnsolution_signature');
        $timestamp = $request->get_header('x_churnsolution_timestamp');

        if (empty($signature) || empty($timestamp) || !ctype_digit((string)$timestamp)) {
            return false;
        }

        // Replay protection: reject requests outside a 5 minute window.
        if (abs(time() - (int)$timestamp) > 300) {
            return false;
        }

        $payload = $timestamp . '.' . $request->get_body();
        $expected = hash_hmac('sha256', $payload, $connection->getApiKey());

        return hash_equals($expected, (string)$signature);
    }

    /**
     * Reflect a Stripe-created subscription back into PMPro.
     *
     * PMPro only creates subscription records from its own in-WP checkout and
     * ignores Stripe's customer.subscription.created webhook, so a subscription
     * created directly in Stripe (during reactivation) is invisible to WP. This
     * seeds the PMPro membership + subscription record; PMPro then syncs status,
     * next payment date, billing amount, and cycle from Stripe itself.
     */
    function reactivate(WP_REST_Request $args)
    {
        if (!class_exists('PMPro_Subscription')) {
            return new WP_REST_Response(array('error' => 'PMPro subscriptions are not available'), 400);
        }

        $user_id = (int)$args->get_param('user_id');
        $email = sanitize_email((string)$args->get_param('email'));
        $customer_id = sanitize_text_field((string)$args->get_param('customer_id'));
        $subscription_id = sanitize_text_field((string)$args->get_param('subscription_id'));

        $level_id = $args->get_param('level_id');
        $level_id = $level_id === null ? null : (int)$level_id;

        // Require a subscription id and at least one way to identify the user.
        if (empty($subscription_id) || (empty($user_id) && empty($email) && empty($customer_id))) {
            return new WP_REST_Response(array('error' => 'subscription_id and one of user_id/email/customer_id are required'), 422);
        }

        // Resolve the WordPress user. Prefer the email PMPro stored on the Stripe
        // customer; fall back to the Stripe customer id (PMPro keeps it in the
        // pmpro_stripe_customerid user meta) in case the WP email was changed.
        if (empty($user_id) && !empty($email)) {
            $user = get_user_by('email', $email);
            if (!empty($user)) {
                $user_id = $user->ID;
            }
        }

        if (empty($user_id) && !empty($customer_id)) {
            $users = get_users(array(
                'meta_key' => 'pmpro_stripe_customerid',
                'meta_value' => $customer_id,
                'number' => 1,
                'fields' => 'ID',
            ));
            if (!empty($users)) {
                $user_id = (int)$users[0];
            }
        }

        // Identifier(s) were provided but did not match a WordPress user.
        if (empty($user_id) || !get_userdata($user_id)) {
            return new WP_REST_Response(array('error' => 'No WordPress user found for the provided email/customer_id'), 404);
        }

        $result = Churnsolution_Stripe_Gateway::reactivate_pmpro_subscription($user_id, $subscription_id, $level_id);

        return new WP_REST_Response($result, 200);
    }

    /**
     * Whether a pause is currently in effect.
     *
     * WooCommerce has no paused status — a pause is an active subscription whose next
     * payment has been pushed out, flagged with CHURNSOLUTION_PAUSE. Nothing clears that
     * flag when the resume date arrives, so the flag alone would keep offering "Resume"
     * on a subscription that went back to billing normally weeks ago. The backend records
     * the resume date next to it so the pause expires on its own.
     *
     * Pauses applied before that date was recorded have no resume timestamp and keep the
     * old behaviour: paused until the customer resumes explicitly.
     */
    private function is_pause_running($subscription)
    {
        if (!filter_var($subscription->get_meta('CHURNSOLUTION_PAUSE'), FILTER_VALIDATE_BOOLEAN)) {
            return false;
        }

        $resumes_at = $subscription->get_meta('cs_pause_resumes_at');

        if (empty($resumes_at) || !is_numeric($resumes_at)) {
            return true;
        }

        // Stored in milliseconds by the backend.
        return (int)$resumes_at > (time() * 1000);
    }

    /**
     * Whether the pause and skip actions should be offered at all.
     *
     * Must match what the backend decides, or the buttons appear and then fail on use.
     *
     * Pausing clears the outstanding orders and reactivates the subscription, which only
     * works when the outstanding order is a renewal we are free to cancel. If the parent
     * order is the unpaid one we are stuck: cancelling it takes the whole subscription
     * with it via cancel_subscriptions_for_order, so reactivating would leave a payable
     * order behind on a subscription that is billing again.
     *
     * Deliberately not "has this ever been paid": a parent order keeps its paid date when
     * it is later moved to failed, so a subscription can have taken money once and still
     * have an outstanding sign-up order today.
     */
    private function can_be_paused($subscription)
    {
        // Since WooCommerce Subscriptions 7.7.0 a failed sign-up stays pending rather
        // than going on hold, so this is a subscription that never started: nothing to
        // defer, and its unpaid sign-up order is the parent, which cannot be cancelled.
        // Cancel and the rest of the flow stay available to these customers.
        if ($subscription->has_status('pending')) {
            return false;
        }

        if (!$subscription->has_status('on-hold')) {
            return true;
        }

        $parent_order = $subscription->get_parent();

        if (empty($parent_order)) {
            return true;
        }

        return !$parent_order->has_status(array('pending', 'failed', 'on-hold', 'cancelled'));
    }

    /**
     * Off-session retry of a failed/pending WooCommerce renewal order.
     *
     * WooCommerce/WooCommerce Subscriptions has no REST endpoint that charges a
     * stored payment token, so this re-fires the very hook Subscriptions uses
     * for automatic renewals:
     *   woocommerce_scheduled_subscription_payment_{gateway_id}
     * The active payment gateway (WooCommerce Stripe, WooPayments, …) listens on
     * that hook, charges the saved token off-session, and moves the order to
     * processing/completed on success — or leaves it failed when the charge is
     * declined or needs SCA (which can only be completed by the customer on the
     * hosted pay page).
     *
     * WooCommerce Subscriptions runs its own retry schedule (5 rules over 7 days
     * by default), and it only fires a pending retry while the order and
     * subscription still hold the statuses its rule applied. Charging here moves
     * the order to processing/failed, which silently aborts that chain — so
     * unless the caller passes force, we refuse when Woo has a retry pending and
     * hand back its date so the caller can schedule around it.
     *
     * Body: {
     *   "order_id": <int>,
     *   "token_id": <int>   (optional — charge this saved token instead of the
     *                        one currently on the subscription)
     *   "force":    <bool>  (optional — charge even if a Woo retry is pending)
     * }
     */
    function retry_payment(WP_REST_Request $args)
    {
        if (!function_exists('wcs_get_subscriptions_for_order') || !function_exists('wc_get_order')) {
            return new WP_REST_Response(array('error' => 'WooCommerce Subscriptions is not active'), 400);
        }

        $order_id = (int)$args->get_param('order_id');
        $order = wc_get_order($order_id);

        if (empty($order)) {
            return new WP_REST_Response(array('error' => 'Order not found'), 404);
        }

        // Charge only orders WooCommerce still considers payable. needs_payment() is
        // true when the status is payable (pending/failed by default, filterable via
        // woocommerce_valid_order_statuses_for_payment) AND the total is > 0 — so it
        // blocks double-billing a paid order, skips $0 renewals (e.g. a 100%-off
        // coupon), and honours any store customization of which statuses are payable.
        if (!$order->needs_payment()) {
            return new WP_REST_Response(array(
                'error'         => 'Order is not in a chargeable state',
                'status'        => $order->get_status(),
                'needs_payment' => false,
            ), 409);
        }

        $subscriptions = wcs_get_subscriptions_for_order($order, array('order_type' => 'any'));
        if (empty($subscriptions)) {
            return new WP_REST_Response(array('error' => 'No subscription found for this order'), 404);
        }

        // The stored token lives on the subscription, keyed by its gateway.
        $subscription = reset($subscriptions);

        // Defer to WooCommerce's own retry schedule unless explicitly overridden.
        // Gated on has_active_retry, not next_retry_date: a retry that is mid-flight
        // ('processing') has no future date but must still block us, or we would
        // charge the card alongside WooCommerce's own attempt.
        $retry_info = $this->get_retry_info($order);
        if (empty($args->get_param('force')) && !empty($retry_info['has_active_retry'])) {
            return new WP_REST_Response(array_merge(array(
                'order_id' => $order_id,
                'status' => $order->get_status(),
                'paid' => false,
                'error' => 'WooCommerce has an automatic retry pending for this order',
            ), $retry_info), 409);
        }

        // The saved card lives on the subscription, but a sign-up that broke early can be
        // left without a payment method on it; the order still records what the customer
        // chose at checkout.
        $gateway_id = $subscription->get_payment_method();
        if (empty($gateway_id)) {
            $gateway_id = $order->get_payment_method();
        }

        // Optionally repoint the order + subscription at a specific saved token
        // before charging, so the caller can walk a customer's payment methods.
        $token_id = (int)$args->get_param('token_id');

        // No token named: make sure the order is not still pointing at a PaymentMethod
        // that can never be charged again. See get_reusable_token_id().
        if (empty($token_id)) {
            $token_id = $this->get_reusable_token_id($order, $gateway_id);
        }

        if (!empty($token_id)) {
            $applied = $this->apply_payment_token($order, $subscriptions, $token_id);
            if (is_wp_error($applied)) {
                $data = $applied->get_error_data();
                return new WP_REST_Response(
                    array('error' => $applied->get_error_message()),
                    isset($data['status']) ? $data['status'] : 400
                );
            }

            // Re-read: apply_payment_token may have changed the gateway.
            $gateway_id = $subscription->get_payment_method();
        }

        if (empty($gateway_id)) {
            return new WP_REST_Response(array('error' => 'No saved payment method to charge'), 422);
        }

        $amount_to_charge = $order->get_total();

        // Construct the payment gateways before firing their hook.
        //
        // Gateways are built lazily: WC()->payment_gateways() is a plain accessor for
        // WC_Payment_Gateways::instance(), whose constructor instantiates every gateway
        // class, and it is those constructors that register the renewal listener (WC
        // Stripe attaches it in maybe_init_subscriptions()). Nothing loads gateways on a
        // REST request — WooCommerce's own REST order controllers call this by hand for
        // the same reason — so without it the do_action below fires into an empty hook:
        // nothing is charged, nothing throws, the order is left untouched and the
        // response is a bare 402 indistinguishable from a decline.
        WC()->payment_gateways();

        // With the gateways loaded, a missing listener is a real answer rather than a
        // boot-order artefact: the gateway is inactive, or does not do automatic
        // renewals. WooCommerce Subscriptions gates its own retry on this same check.
        if (!has_action('woocommerce_scheduled_subscription_payment_' . $gateway_id)) {
            return new WP_REST_Response(array(
                'error' => 'The gateway on this subscription cannot be charged off-session',
                'gateway' => $gateway_id,
            ), 422);
        }

        // Capture the reason a Stripe charge failed.
        //
        // WC Stripe never lets its failures escape: process_subscription_payment() wraps
        // the whole charge in its own catch, logs the WC_Stripe_Exception, marks the
        // order failed and returns void. So the catch below cannot see a declined card,
        // an unusable customer or an SCA requirement — every one of them arrives here as
        // "the order is still unpaid" with nothing attached. It does re-broadcast the
        // exception on this action first, which is the one place the reason is offered
        // to anyone outside the gateway.
        $gateway_error = null;
        $capture_gateway_error = function ($e) use (&$gateway_error) {
            if ($e instanceof Exception) {
                $gateway_error = method_exists($e, 'getLocalizedMessage') && $e->getLocalizedMessage()
                    ? $e->getLocalizedMessage()
                    : $e->getMessage();
            }
        };
        add_action('wc_gateway_stripe_process_payment_error', $capture_gateway_error, 10, 1);

        // Kept for gateways that do let a hard decline escape, so it surfaces as a
        // structured response rather than a raw 500.
        try {
            do_action('woocommerce_scheduled_subscription_payment_' . $gateway_id, $amount_to_charge, $order);
        } catch (Exception $e) {
            remove_action('wc_gateway_stripe_process_payment_error', $capture_gateway_error, 10);
            $order = wc_get_order($order_id);
            return new WP_REST_Response(array(
                'order_id' => $order_id,
                'status' => $order ? $order->get_status() : 'failed',
                'paid' => false,
                'error' => $e->getMessage(),
                'gateway_message' => $this->get_latest_order_note($order_id),
            ), 402);
        }

        remove_action('wc_gateway_stripe_process_payment_error', $capture_gateway_error, 10);

        // Re-read the order to report the outcome the gateway produced.
        $order = wc_get_order($order_id);
        $paid = $order->is_paid();

        $response = array(
            'order_id' => $order_id,
            'status' => $order->get_status(),
            'paid' => $paid,
            'transaction_id' => $order->get_transaction_id(),
        );

        // Two independent channels, because neither covers every refusal. The captured
        // exception misses the paths that never raise one — an intent already dead for
        // want of SCA (has_authentication_already_failed) returns before charging, and a
        // charge needing authentication is recorded and returned quietly. Those all
        // write an order note, which is also the only channel a non-Stripe gateway has.
        if (!$paid) {
            $response['error'] = $this->get_latest_order_note($order_id);
        }

        return new WP_REST_Response($response, $paid ? 200 : 402);
    }

    /**
     * WooCommerce Subscriptions' own retry state for a renewal order.
     *
     * 'next_retry_date' plays the role of Stripe's next_payment_attempt, letting
     * the backend's Smart Retries planner keep its separation from the gateway's
     * own dunning. See get_retry_info() for the rest of the shape.
     *
     * Body: { "order_id": <int> }
     */
    function retry_context(WP_REST_Request $args)
    {
        if (!function_exists('wc_get_order')) {
            return new WP_REST_Response(array('error' => 'WooCommerce is not active'), 400);
        }

        $order_id = (int)$args->get_param('order_id');
        $order = wc_get_order($order_id);

        if (empty($order)) {
            return new WP_REST_Response(array('error' => 'Order not found'), 404);
        }

        return new WP_REST_Response(
            array_merge(array('order_id' => (string)$order_id), $this->get_retry_info($order)),
            200
        );
    }

    /**
     * Reads WooCommerce Subscriptions' own retry state for an order, from the
     * WCS retry store.
     *
     * The store keys retries by order id, so everything here describes the order
     * that was asked about. The subscription's 'payment_retry' date is deliberately
     * not used: it is subscription-level, so on a subscription with more than one
     * failed renewal it cannot say which order the date belongs to, and it lingers
     * after the retry that set it has already run.
     *
     * 'next_retry_date' is the Action Scheduler timestamp — what will actually fire.
     * A retry row can sit at 'pending' while its scheduled action was cancelled, so
     * the row's own date is only the fallback. Null means nothing further is queued,
     * which the backend reads as "no next_payment_attempt".
     *
     * 'has_active_retry' is the double-charge guard and is deliberately broader than
     * the date: it also covers a retry that is mid-flight, which has no future date.
     */
    private function get_retry_info($order)
    {
        $info = array(
            'retry_system_enabled' => false,
            'retry_count' => 0,
            'retries' => array(),
            'pending_retry' => null,
            'next_retry_date' => null,
            'has_active_retry' => false,
        );

        if (empty($order) || !class_exists('WCS_Retry_Manager') || !WCS_Retry_Manager::is_retry_enabled()) {
            return $info;
        }

        $info['retry_system_enabled'] = true;

        $order_id = $order->get_id();
        $store = WCS_Retry_Manager::store();

        // The attempts already made on THIS order.
        $retries = $store->get_retries_for_order($order_id);
        $info['retry_count'] = (int)$store->get_retry_count_for_order($order_id);

        // Pick the pending record out of the set rather than taking the last one and
        // reading its status — that needs no assumption about the store's ordering.
        // WCS keeps at most one pending retry per order.
        $pending = null;
        foreach ($retries as $retry) {
            $formatted = $this->format_retry($retry);
            $info['retries'][] = $formatted;

            if ('pending' === $formatted['status']) {
                $pending = $formatted;
                $info['has_active_retry'] = true;
            } elseif ('processing' === $formatted['status']) {
                $info['has_active_retry'] = true;
            }
        }

        if (empty($pending)) {
            return $info;
        }

        $info['pending_retry'] = $pending;

        $scheduled = $this->get_scheduled_retry_date($order_id);
        $info['next_retry_date'] = empty($scheduled) ? $pending['date_gmt'] : $scheduled;

        return $info;
    }

    /**
     * One WCS_Retry as JSON. 'status' is the retry's own lifecycle — pending
     * (scheduled, not yet fired), processing, failed, complete or cancelled — so
     * only 'pending' describes a future attempt.
     */
    private function format_retry($retry)
    {
        // WCS_Retry exposes get_rule(), which hands back a WCS_Retry_Rule; the raw
        // array lives behind that object's get_raw_data(). There is no get_rule_raw().
        $rule = $retry->get_rule();
        $rule_data = (is_object($rule) && method_exists($rule, 'get_raw_data'))
            ? $rule->get_raw_data()
            : null;

        return array(
            'id' => (int)$retry->get_id(),
            'status' => $retry->get_status(),
            'date_gmt' => $this->retry_date_to_iso8601($retry->get_date_gmt()),
            'rule' => empty($rule_data) ? null : wp_json_encode($rule_data),
        );
    }

    /**
     * When the retry will actually fire, per Action Scheduler, or null when nothing
     * is scheduled. No group argument: WCS has not always scheduled these into the
     * same Action Scheduler group, and omitting it matches any.
     *
     * WCS schedules this action off the subscription's last order, so the args only
     * match when the order asked about is that order. Any other order falls back to
     * the retry row's own date_gmt, which is the same instant WCS scheduled from.
     */
    private function get_scheduled_retry_date($order_id)
    {
        if (!function_exists('as_next_scheduled_action')) {
            return null;
        }

        $next = as_next_scheduled_action(
            'woocommerce_scheduled_subscription_payment_retry',
            array('order_id' => $order_id)
        );

        // int timestamp | true (in progress, no usable time) | false (nothing queued)
        return is_int($next) ? gmdate('c', $next) : null;
    }

    /**
     * WCS_Retry::get_date_gmt() returns 'Y-m-d H:i:s' in UTC with no offset marker,
     * which the backend's ISO-8601 parser rejects. Emit an explicit +00:00.
     */
    private function retry_date_to_iso8601($mysql_date)
    {
        if (empty($mysql_date) || '0000-00-00 00:00:00' === $mysql_date) {
            return null;
        }

        $timestamp = strtotime($mysql_date . ' UTC');

        return false === $timestamp ? null : gmdate('c', $timestamp);
    }

    /**
     * Points a renewal order and every subscription it belongs to at a saved
     * payment token, so the next charge uses that card.
     *
     * The token's own gateway is authoritative — we do not assume Stripe. The
     * Stripe-specific source/customer meta is only written when the token
     * actually belongs to the Stripe gateway.
     *
     * @return true|WP_Error
     */
    private function apply_payment_token($order, $subscriptions, $token_id)
    {
        if (!class_exists('WC_Payment_Tokens')) {
            return new WP_Error('tokens_unavailable', 'WooCommerce payment tokens are not available.', array('status' => 400));
        }

        $token = WC_Payment_Tokens::get($token_id);
        if (empty($token) || (int)$token->get_user_id() !== (int)$order->get_customer_id()) {
            return new WP_Error('invalid_token', "That payment token does not belong to this order's customer.", array('status' => 400));
        }

        $gateway_id = $token->get_gateway_id();
        $meta = $this->get_payment_meta_for_token($token, $order);

        $targets = array_merge(array($order), array_values($subscriptions));
        foreach ($targets as $target) {
            $target->set_payment_method($gateway_id);
            foreach ($meta as $meta_key => $meta_value) {
                $target->update_meta_data($meta_key, $meta_value);
            }
            $target->save();
        }

        return true;
    }

    /**
     * The meta an order must carry for a token to actually be charged, keyed by the
     * meta key the active Stripe plugin reads.
     *
     * Two different plugins are in play and they share neither ids nor meta keys, so
     * the token object decides rather than a gateway-id guess — their gateway ids
     * overlap ('stripe_sepa' exists in both), which makes id matching unsafe.
     *
     * Payment Plugins for Stripe (gateway ids stripe_cc, stripe_googlepay, …) reads
     * _payment_method_token and _wc_stripe_customer; its token subclasses carry the
     * Stripe customer the payment method is attached to, which has to travel with the
     * payment method — a PaymentMethod attached to one customer cannot be charged
     * against another. The official gateway ('stripe') reads _stripe_source_id and
     * _stripe_customer_id, and keeps the customer id in a user option rather than on
     * the token.
     */
    private function get_payment_meta_for_token($token, $order)
    {
        // Payment Plugins for Stripe.
        if (class_exists('WC_Payment_Token_Stripe') && $token instanceof WC_Payment_Token_Stripe) {
            $meta = array(CSR_PP_STRIPE_TOKEN_META => $token->get_token());

            $customer = $token->get_customer_id();
            if (!empty($customer)) {
                $meta[CSR_PP_STRIPE_CUSTOMER_META] = $customer;
            }

            return $meta;
        }

        if (CSR_GATEWAY_ID !== $token->get_gateway_id()) {
            return array();
        }

        // Official WooCommerce Stripe Gateway.
        $meta = array(CSR_STRIPE_SOURCE_META => $token->get_token());

        // get_user_option, not get_user_meta: WC_Stripe_Customer stores this with
        // update_user_option(..., false), which prefixes the key per site, so reading
        // '_stripe_customer_id' as plain user meta comes back empty on every install.
        $customer = get_user_option(CSR_STRIPE_CUSTOMER_META, $order->get_customer_id());
        if (!empty($customer)) {
            $meta[CSR_STRIPE_CUSTOMER_META] = $customer;
        }

        return $meta;
    }

    /**
     * A saved token to charge when the one already on the order cannot be reused, or 0
     * to leave the order as it is.
     *
     * A sign-up that failed leaves behind the PaymentMethod it created. That
     * PaymentMethod was only ever used in a PaymentIntent, so Stripe never attached it
     * to the customer and refuses it on any later charge:
     *
     *   "The provided PaymentMethod was previously used with a PaymentIntent without
     *    Customer attachment ... It may not be used again."
     *
     * The order goes on pointing at it and both plugins read that id straight off the
     * order — Payment Plugins sends _payment_method_token as the intent's payment_method,
     * the official gateway reads _stripe_source_id in prepare_order_source(). So every
     * retry hands Stripe the one id guaranteed to fail, while the customer's actual saved
     * card is never reached. Nothing self-corrects: both plugins fall back to something
     * usable only when the order carries NO payment method, and this order carries a bad
     * one.
     *
     * Saved WooCommerce tokens are the opposite and are safe to swap in — both plugins
     * build them from payment methods already attached to the Stripe customer. Tokens
     * also come back filtered to the store's current mode, since Payment Plugins hooks
     * woocommerce_get_customer_payment_tokens to drop tokens from the other environment.
     */
    private function get_reusable_token_id($order, $gateway_id)
    {
        if (!class_exists('WC_Payment_Tokens') || empty($gateway_id)) {
            return 0;
        }

        $customer_id = $order->get_customer_id();

        if (empty($customer_id)) {
            return 0;
        }

        $tokens = WC_Payment_Tokens::get_customer_tokens($customer_id, $gateway_id);

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

        // Whichever plugin is active, this is the id that would be sent to Stripe.
        $source_id = $order->get_meta(CSR_PP_STRIPE_TOKEN_META);
        if (empty($source_id)) {
            $source_id = $order->get_meta(CSR_STRIPE_SOURCE_META);
        }

        $fallback = 0;

        foreach ($tokens as $token) {
            // Already charging a saved card: attached, reusable, leave it alone. This is
            // the normal renewal case, where the meta was copied from the subscription.
            if (!empty($source_id) && $token->get_token() === $source_id) {
                return 0;
            }

            if (empty($fallback) || $token->is_default()) {
                $fallback = (int)$token->get_id();
            }
        }

        return $fallback;
    }

    /**
     * The most recent note on an order — the gateway's own account of a refusal.
     *
     * The renewal hook returns nothing and WC Stripe swallows its exceptions, so for
     * several outcomes this note is the only record of what happened: a plain decline,
     * a charge left awaiting authentication, or a retry that never charged because the
     * order already carried an intent that failed for want of SCA.
     */
    private function get_latest_order_note($order_id)
    {
        if (!function_exists('wc_get_order_notes')) {
            return null;
        }

        $notes = wc_get_order_notes(array(
            'order_id' => $order_id,
            'limit' => 1,
            'orderby' => 'date_created_gmt',
            'order' => 'DESC',
        ));

        return empty($notes) ? null : $notes[0]->content;
    }

    function get_subscription(WP_REST_Request $args)
    {

        if (class_exists('PMPro_Subscription')) {
            $current_user = wp_get_current_user();
            $level_id = $args->get_param('level_id');
            return $this->get_pmpro_subscription_details($current_user->ID, $level_id);
        }

        if (class_exists('WC_Subscription')) {
            $subscription = $this->get_own_wc_subscription($args);

            if ($subscription instanceof WP_REST_Response) {
                return $subscription;
            }

            return [
                'external_id' => (string) $subscription->get_id(),
                'status' => $subscription->get_status(),
                'start_date' => $subscription->get_date('start'),
                'end_date' => $subscription->get_date('end'),
                'next_payment_date' => $subscription->get_date('next_payment'),
                'billing_interval' => $subscription->get_billing_interval(),
                'billing_period' => $subscription->get_billing_period(),
                'customer_id' => $subscription->get_customer_id(),
                'total' => $subscription->get_total(),
                'items' => array_map(function ($item) {
                    return [
                        'name' => $item->get_name(),
                        'product_id' => $item->get_product_id(),
                        'quantity' => $item->get_quantity(),
                        'total' => $item->get_total(),
                    ];
                }, $subscription->get_items()),
                'metadata' => [
                    'churnsolution_pause' => $this->is_pause_running($subscription),
                    'scheduled_to_be_cancelled' => filter_var($subscription->get_meta('_ps_scheduled_to_be_cancelled'), FILTER_VALIDATE_BOOLEAN),
                    'can_pause' => $this->can_be_paused($subscription),
                ],
            ];
        }

        return new stdClass;
    }

    function sync_subscription(WP_REST_Request $args)
    {
        $current_user = wp_get_current_user();
        $user_id = $current_user->ID;

        Churnsolution_Stripe_Gateway::sync_pmpro_subscription($user_id);

        return new stdClass;

    }

    function cancel(WP_REST_Request $args)
    {
        if (class_exists('PMPro_Subscription')) {
            $current_user = wp_get_current_user();

            $user_id = $current_user->ID;
            $level_id = $args->get_param('level_id');

            Churnsolution_Stripe_Gateway::cancel_subscription_via_pmpro($current_user, $level_id);

        }

        if (class_exists('WC_Subscription')) {
            $subscription = $this->get_own_wc_subscription($args);

            if ($subscription instanceof WP_REST_Response) {
                return $subscription;
            }

            if ($subscription->has_status('cancelled')) {
                return new WP_REST_Response(['message' => 'Subscription already cancelled.'], 400);
            }
            if ('yes' === $subscription->get_meta('_ps_scheduled_to_be_cancelled')) {
                return new WP_REST_Response(['message' => 'Subscription already scheduled for cancellation.'], 400);
            }

            $next_payment_time = $subscription->get_time('next_payment');
            $is_prepaid = $subscription->has_status('active') && $next_payment_time > 0 && $next_payment_time > current_time('timestamp', true);

            if ($is_prepaid) {
                $subscription->update_meta_data('_ps_scheduled_to_be_cancelled', 'yes');
                $subscription->save();
                return new WP_REST_Response(['message' => 'Subscription scheduled for cancellation.']);
            }

            $subscription->update_status('cancelled');
            return new WP_REST_Response(['message' => 'Subscription cancelled.']);
        }
    }

    function pause_subscription(WP_REST_Request $args)
    {

        if (class_exists('PMPro_Subscription')) {
            $current_user = wp_get_current_user();
            $level_id = $args->get_param('level_id');

            $subscription = $this->get_pmpro_subscription_details($current_user->ID, $level_id);

            $subscription_external_id = $subscription['external_id'];

            $number_of_months = $args['number_of_months'];

            Churnsolution_Stripe_Gateway::pause($subscription_external_id, $number_of_months);

        }

        return new stdClass;
    }

    function apply_coupon(WP_REST_Request $args)
    {

        if (class_exists('PMPro_Subscription')) {
            $current_user = wp_get_current_user();
            $level_id = $args->get_param('level_id');

            $subscription = $this->get_pmpro_subscription_details($current_user->ID, $level_id);

            $subscription_external_id = $subscription['external_id'];

            $coupon_code = $args['coupon_code'];

            Churnsolution_Stripe_Gateway::apply_coupon($subscription_external_id, $coupon_code);
        }

    }

    function extend_free_trial(WP_REST_Request $args)
    {

        if (class_exists('PMPro_Subscription')) {
            $current_user = wp_get_current_user();
            $level_id = $args->get_param('level_id');

            $subscription = $this->get_pmpro_subscription_details($current_user->ID, $level_id);

            $subscription_external_id = $subscription['external_id'];

            $number_of_days = $args['number_of_days'];

            Churnsolution_Stripe_Gateway::extend_free_trial($subscription_external_id, $number_of_days);
        }

    }

    private function get_pmpro_subscription_details($user_id, $level_id)
    {

        $subscription = PMPro_Subscription::get_subscription(array("user_id" => $user_id, "level_id" => (int)$level_id));

        $subscription_data = array(
            "external_id" => null
        );

        if (!empty($subscription)) {
            $external_id = $subscription->get_subscription_transaction_id();
            $external_subscription = Churnsolution_Stripe_Gateway::get_external_subscription($external_id);

            if (!empty($external_subscription)) {

                if ($external_subscription->status != 'canceled') {
                    $upcoming_invoice = Churnsolution_Stripe_Gateway::get_external_upcoming_invoice($external_id);

                    if (!empty($upcoming_invoice)) {
                        // Resolve the invoice tax across Stripe API versions.
                        // Stripe API 2025-03-31 ("Basil") removed the scalar
                        // Invoice.tax field and replaced it with the total_taxes
                        // array, so reading ->tax alone returns null on current
                        // library versions. Prefer the legacy scalar when it is
                        // still present (older API versions), otherwise sum the
                        // amounts in total_taxes. isset() is used rather than a
                        // raw property read so a missing field does not trigger
                        // Stripe's "undefined property" notice.
                        $tax_long = null;

                        if (isset($upcoming_invoice->tax) && $upcoming_invoice->tax !== null) {
                            $tax_long = $upcoming_invoice->tax;
                        } elseif (!empty($upcoming_invoice->total_taxes)) {
                            $tax_long = 0;
                            foreach ($upcoming_invoice->total_taxes as $tax_entry) {
                                if (isset($tax_entry->amount) && csr_is_numeric($tax_entry->amount)) {
                                    $tax_long += $tax_entry->amount;
                                }
                            }
                        }

                        $mapped_upcoming_invoice = array(
                            "amount_due" => Currency_Utils::getShortestFormFromLong($upcoming_invoice->amount_due, $upcoming_invoice->currency),
                            "currency" => $upcoming_invoice->currency,
                            "currency_symbol" => Currency_Utils::getSymbol($upcoming_invoice->currency),
                            "created" => $upcoming_invoice->created,
                            "total_excluding_tax" => Currency_Utils::getShortestFormFromLong($upcoming_invoice->total_excluding_tax, $upcoming_invoice->currency),
                            "applied_balance" => Currency_Utils::getShortestFormFromLong($upcoming_invoice->applied_balance, $upcoming_invoice->currency),
                            "tax" => $tax_long === null ? null :
                                Currency_Utils::getShortestFormFromLong((int)$tax_long, $upcoming_invoice->currency),
                        );
                    }

                    if(!empty($upcoming_invoice->total_discount_amounts)){
                        $total_discounts = 0;

                        foreach ($upcoming_invoice->total_discount_amounts as $discount){
                            $total_discounts += $discount->amount;
                        }

                        $mapped_upcoming_invoice['total_discounts'] = $total_discounts;
                    }

                }


                $subscription_data['status'] = $subscription->get_status();
                $subscription_data['external_id'] = $external_id;
                $subscription_data['upcoming_invoice'] = $mapped_upcoming_invoice;
                $subscription_data['pause_collection'] = $external_subscription->pause_collection;
            }
        }

        return $subscription_data;
    }

}

/**
 * These routes are reachable two ways, so either proof of authority is accepted:
 *
 *  - Churn Solution's backend calls them server-to-server, authenticated by the
 *    same HMAC signature the /subscription/* routes use. There is no logged-in
 *    user on those requests, so a capability check alone would reject them.
 *  - A store admin may call them with a WooCommerce consumer key, in which case
 *    we require the same capability WooCommerce's own REST API requires for
 *    order writes.
 *
 * Never return true unconditionally: these routes charge saved cards and list a
 * customer's stored payment tokens.
 */
function csr_permission_check( $request ) {
    $controller = new Churnsolution_Subscription_Controller();
    if ( $request instanceof WP_REST_Request && $controller->verify_signature( $request ) ) {
        return true;
    }

    return current_user_can( 'manage_woocommerce' );
}

function csr_is_numeric( $value ) {
    return is_numeric( $value );
}

/**
 * GET /wp-json/churnsolution/v1/customers/{customer_id}/payment-methods
 * Lists a customer's saved tokens. Never exposes full card numbers.
 *
 * Deliberately not filtered to one gateway id. Which id the store's cards sit under
 * depends on the Stripe plugin it runs — 'stripe' for the official gateway,
 * 'stripe_cc' and friends for Payment Plugins — so pinning it to either returns an
 * empty list on the other, and the caller cannot pick a token_id for /retry-payment.
 * 'gateway' is reported per token so the caller can tell them apart.
 */
function csr_get_customer_payment_methods( $request ) {
    $customer_id = (int) $request['customer_id'];
    $tokens       = WC_Payment_Tokens::get_customer_tokens( $customer_id );
    $default      = WC_Payment_Tokens::get_customer_default_token( $customer_id );

    $results = array();
    foreach ( $tokens as $token ) {
        $results[] = array(
            'token_id'   => $token->get_id(),
            'gateway'    => $token->get_gateway_id(),
            'brand'      => method_exists( $token, 'get_card_type' ) ? $token->get_card_type() : null,
            'last4'      => method_exists( $token, 'get_last4' ) ? $token->get_last4() : null,
            'expiry'     => method_exists( $token, 'get_expiry_month' )
                ? $token->get_expiry_month() . '/' . $token->get_expiry_year()
                : null,
            'is_default' => (bool) ( $default && $default->get_id() === $token->get_id() ),
        );
    }

    return rest_ensure_response( $results );
}

// Function to register our new routes from the controller.
function churnsolution_register_subscription_routes()
{
    $controller = new Churnsolution_Subscription_Controller();
    $controller->register_routes();
}

add_action('rest_api_init', 'churnsolution_register_subscription_routes');
