<?php

if (!defined('ABSPATH')) {
    die('You are not allowed to call this page directly.');
}

class MeprCheckoutCtrl extends MeprBaseCtrl
{
    /**
     * Load hooks for various actions and filters related to checkout.
     *
     * @return void
     */
    public function load_hooks()
    {
        add_action('wp_enqueue_scripts', [$this,'enqueue_scripts']);
        add_action('mepr_signup', [$this, 'process_spc_payment_form'], 100); // 100 priority to give other things a chance to hook in before SPC takes over the world
        add_filter('mepr_signup_form_payment_description', [$this, 'maybe_render_payment_form'], 10, 4);
        add_filter('mepr_signup_checkout_url', [$this, 'handle_spc_checkout_url'], 10, 2);
        add_action('mepr_readylaunch_thank_you_page_after_content', [$this, 'maybe_show_order_bumps_error_message_in_readylaunch']);
        add_filter('mepr_options_helper_payment_methods', [$this, 'exclude_disconnected_gateways'], 10, 3);
        add_filter('the_content', [$this, 'maybe_show_order_bumps_error_message'], 1);
        add_action('wp_ajax_mepr_get_checkout_state', [$this, 'get_checkout_state']);
        add_action('wp_ajax_nopriv_mepr_get_checkout_state', [$this, 'get_checkout_state']);
        add_action('wp_ajax_mepr_process_signup_form', [$this, 'process_signup_form_ajax']);
        add_action('wp_ajax_nopriv_mepr_process_signup_form', [$this, 'process_signup_form_ajax']);
        add_action('wp_ajax_mepr_process_payment_form', [$this, 'process_payment_form_ajax']);
        add_action('wp_ajax_nopriv_mepr_process_payment_form', [$this, 'process_payment_form_ajax']);
        add_action('wp_ajax_mepr_debug_checkout_error', [$this, 'debug_checkout_error']);
        add_action('wp_ajax_nopriv_mepr_debug_checkout_error', [$this, 'debug_checkout_error']);

        MeprHooks::add_shortcode('mepr_ecommerce_tracking', [$this, 'replace_tracking_codes']);
    }

    /**
     * Show order bumps error message in ReadyLaunch if applicable.
     *
     * @return void
     */
    public function maybe_show_order_bumps_error_message_in_readylaunch()
    {
        if (!isset($_GET['trans_num'])) {
            return;
        }

        $trans_num    = sanitize_text_field(wp_unslash($_GET['trans_num']));
        $original_txn = MeprTransaction::get_one_by_trans_num($trans_num);

        if (isset($original_txn->id) && $original_txn->id > 0) {
            $original_txn = new MeprTransaction($original_txn->id);
        } else {
            return;
        }

        $order = $original_txn->order();

        if (!$order) {
            return;
        }

        $bumps  = MeprTransaction::get_all_by_order_id($order->id);
        $errors = [];

        foreach ($bumps as $txn) {
            $product = $txn->product();
            $meta    = $txn->get_meta('_authorizenet_txn_error_', true);

            if (empty($meta)) {
                continue;
            }

            $error         = (explode('|', $meta));
            $error_message = sprintf(
                // Translators: %1$s: product name, %2$s: opening anchor tag, %3$s: closing anchor tag.
                __('Notice: %1$s purchase failed. Click %2$shere%3$s to purchase it separately.', 'memberpress'),
                $product->post_title,
                '<a href="' . get_permalink($product->ID) . '" target="_blank">',
                '</a>'
            );

            if (! empty($error)) {
                $errors[] = $error_message;
            }
        }

        if (!empty($errors)) {
            MeprView::render('/shared/errors', get_defined_vars());
            echo '<br>';
        }
    }

    /**
     * Show order bumps error message on the thank you page if applicable.
     *
     * @param string $content The content of the page.
     *
     * @return string The modified content.
     */
    public function maybe_show_order_bumps_error_message($content)
    {
        if (is_singular() && in_the_loop() && is_main_query()) {
            $mepr_options = MeprOptions::fetch();

            if ($mepr_options->thankyou_page_id !== (int) get_the_ID()) {
                return $content;
            }

            if (!isset($_GET['trans_num'])) {
                return $content;
            }

            $trans_num    = sanitize_text_field(wp_unslash($_GET['trans_num']));
            $original_txn = MeprTransaction::get_one_by_trans_num($trans_num);

            if (isset($original_txn->id) && $original_txn->id > 0) {
                $original_txn = new MeprTransaction($original_txn->id);
            } else {
                return $content;
            }

            $order = $original_txn->order();

            if (!$order) {
                return $content;
            }

            $bumps  = MeprTransaction::get_all_by_order_id($order->id);
            $errors = [];

            foreach ($bumps as $txn) {
                $product = $txn->product();
                $meta    = $txn->get_meta('_authorizenet_txn_error_', true);

                if (empty($meta)) {
                    continue;
                }

                $error         = (explode('|', $meta));
                $error_message = sprintf(
                    // Translators: %1$s: product name, %2$s: opening anchor tag, %3$s: closing anchor tag.
                    __('Notice: %1$s purchase failed. Click %2$shere%3$s to purchase it separately.', 'memberpress'),
                    $product->post_title,
                    '<a href="' . get_permalink($product->ID) . '" target="_blank">',
                    '</a>'
                );

                if (! empty($error)) {
                    $errors[] = $error_message;
                }
            }

            if (!empty($errors)) {
                ob_start();
                MeprView::render('/shared/errors', get_defined_vars());
                $error_section = ob_get_contents();
                ob_end_clean();
                $content .= $error_section;
            }
        }

        return $content;
    }

    /**
     * Replace tracking codes in the content with actual values.
     *
     * @param array  $atts    The shortcode attributes.
     * @param string $content The content to replace codes in.
     *
     * @return string The content with replaced tracking codes.
     */
    public function replace_tracking_codes($atts, $content = '')
    {
        $atts = shortcode_atts(
            [
                'membership' => null,
            ],
            $atts,
            'mepr_ecommerce_tracking'
        );

        if (
            !($this->request_has_valid_thank_you_params($_GET) &&
            $this->request_has_valid_thank_you_membership_id($_GET) &&
            $this->request_has_valid_thank_you_trans_num($_GET) &&
            $this->request_has_valid_thank_you_membership($atts, $_GET))
        ) {
            return '';
        }

        $tracking_codes = [
            '%%subtotal%%'          => ['MeprTransaction' => 'tracking_subtotal'],
            '%%tax_amount%%'        => ['MeprTransaction' => 'tracking_tax_amount'],
            '%%tax_rate%%'          => ['MeprTransaction' => 'tracking_tax_rate'],
            '%%total%%'             => ['MeprTransaction' => 'tracking_total'],
            '%%txn_num%%'           => ['MeprTransaction' => 'trans_num'],
            '%%sub_id%%'            => ['MeprTransaction' => 'subscription_id'],
            '%%txn_id%%'            => ['MeprTransaction' => 'id'],
            '%%sub_num%%'           => ['MeprSubscription' => 'subscr_id'],
            '%%membership_amount%%' => ['MeprSubscription' => 'price'],
            '%%trial_days%%'        => ['MeprSubscription' => 'trial_days'],
            '%%trial_amount%%'      => ['MeprSubscription' => 'trial_amount'],
            '%%username%%'          => ['MeprUser' => 'user_login'],
            '%%user_email%%'        => ['MeprUser' => 'user_email'],
            '%%user_id%%'           => ['MeprUser' => 'ID'],
            '%%membership_name%%'   => ['MeprProduct' => 'post_title'],
            '%%membership_id%%'     => ['MeprProduct' => 'ID'],
        ];

        foreach ($tracking_codes as $code => $mapping) {
            // Make sure the content has a code to replace.
            if (strpos($content, $code) !== false) {
                foreach ($mapping as $model => $attr) {
                    switch ($model) {
                        case 'MeprTransaction':
                              // Only fetch the object once!
                            if (!isset($txn)) {
                                if (isset($_GET['trans_num']) && !empty($_GET['trans_num'])) {
                                          $rec = $model::get_one_by_trans_num(sanitize_text_field(wp_unslash($_GET['trans_num'])));
                                          $txn = $obj = new MeprTransaction($rec->id);
                                } elseif (isset($_GET['transaction_id']) && !empty($_GET['transaction_id'])) {
                                    $txn = $obj = new MeprTransaction((int) $_GET['transaction_id']);
                                }
                            }
                            break;
                        case 'MeprSubscription':
                            if (!isset($sub)) {
                                if (isset($_GET['subscr_id']) && !empty($_GET['subscr_id'])) {
                                    $sub = $obj = $model::get_one_by_subscr_id(sanitize_text_field(wp_unslash($_GET['subscr_id'])));
                                } elseif (isset($_GET['subscription_id']) && !empty($_GET['subscription_id'])) {
                                    $sub = $obj = $model::get_one((int) $_GET['subscription_id']);
                                }
                            }
                            break;
                        case 'MeprUser':
                            if (!isset($user)) {
                                $user = $obj = MeprUtils::get_currentuserinfo();
                            }
                            break;
                        case 'MeprProduct':
                            if (!isset($prod) && isset($_GET['membership_id']) && !empty($_GET['membership_id'])) {
                                $prod = $obj = new $model(intval(wp_unslash($_GET['membership_id'])));
                            }
                            break;
                        default:
                              unset($obj);
                    }
                    if (isset($obj) && (isset($obj->id) && (int) $obj->id > 0) || (isset($obj->ID) && (int) $obj->ID > 0)) {
                        $content = str_replace($code, $obj->$attr, $content);
                        break; // Once we've replaced the code, it's time to move on.
                    }
                }
                // Blank out the code if it isn't found.
                $content = str_replace($code, '', $content);
            }
        }
        return $content;
    }

    /**
     * Enqueue gateway specific js/css if required.
     *
     * @return void
     */
    public function enqueue_scripts()
    {
        global $post;
        $mepr_options = MeprOptions::fetch();

        if (MeprProduct::is_product_page($post)) {
            $has_phone = false;

            if (! empty($mepr_options->custom_fields)) {
                foreach ($mepr_options->custom_fields as $field) {
                    // Detect any phone field regardless of the global "Show at Signup" flag: a membership
                    // with customized profile fields can display it on signup even when that flag is off.
                    if ('tel' === $field->field_type) {
                        $has_phone = true;
                        break;
                    }
                }
            }

            // Check if there's a phone field.
            if ($has_phone) {
                wp_enqueue_style('mepr-phone-css', MEPR_CSS_URL . '/vendor/intlTelInput.min.css', '', '16.0.0');
                wp_enqueue_style('mepr-tel-config-css', MEPR_CSS_URL . '/tel_input.css', '', MEPR_VERSION);
                wp_enqueue_script('mepr-phone-js', MEPR_JS_URL . '/vendor/intlTelInput.js', '', '16.0.0', true);
                wp_enqueue_script('mepr-tel-config-js', MEPR_JS_URL . '/tel_input.js', ['mepr-phone-js', 'mp-signup'], MEPR_VERSION, true);
                wp_localize_script('mepr-tel-config-js', 'meprTel', MeprHooks::apply_filters('mepr_phone_input_config', [
                    'defaultCountry' => strtolower(get_option('mepr_biz_country')),
                    'utilsUrl'       => MEPR_JS_URL . '/vendor/intlTelInputUtils.js',
                    'onlyCountries'  => '',
                    'i18n'           => [
                        'selectCountryCode'  => esc_html__('Select country code', 'memberpress'),
                        'countryCodeOptions' => esc_html__('Country code options', 'memberpress'),
                        'countryChangedTo'   => esc_html__('Country changed to', 'memberpress'),
                        'countryCode'        => esc_html__('country code', 'memberpress'),
                        'phoneNumberInput'   => esc_html__('Phone number input', 'memberpress'),
                    ],
                ]));
            }

            $txn = null;
            $pm  = null;

            if (isset($_REQUEST['action']) && $_REQUEST['action'] === 'checkout') {
                if (isset($_REQUEST['mepr_transaction_id'])) {
                    $txn = new MeprTransaction(intval(wp_unslash($_REQUEST['mepr_transaction_id'])));
                } elseif (isset($_REQUEST['txn'])) {
                    $txn = new MeprTransaction(intval(wp_unslash($_REQUEST['txn'])));
                }

                if ($txn && $txn->id > 0) {
                    $pm = $txn->payment_method();
                }
            } elseif (
                MeprUtils::is_user_logged_in() &&
                isset($_REQUEST['action']) && $_REQUEST['action'] === 'update' &&
                isset($_REQUEST['sub'])
            ) {
                $sub = new MeprSubscription(intval(wp_unslash($_REQUEST['sub'])));
                if ($sub->id > 0) {
                    $pm = $sub->payment_method();
                }
            }

            if ($pm instanceof MeprBaseRealGateway) {
                wp_register_script('mepr-checkout-js', MEPR_JS_URL . '/checkout.js', ['jquery', 'jquery.payment'], MEPR_VERSION);
                $pm->enqueue_payment_form_scripts();
            }
        }
    }

    /**
     * Renders the payment form if SPC is enabled and supported by the payment method.
     * Called from: mepr_signup_form_payment_description filter
     * Returns: description includding form for SPC if enabled
     *
     * @param string      $description    The description of the payment form.
     * @param object      $payment_method The payment method object.
     * @param boolean     $first          Whether this is the first payment method.
     * @param object|null $product        The product object.
     *
     * @return string The modified description.
     */
    public function maybe_render_payment_form($description, $payment_method, $first, $product = null)
    {
        $mepr_options = MeprOptions::fetch();
        if (
            (
                $mepr_options->enable_spc ||
                $mepr_options->design_enable_checkout_template ||
                MeprFseHelper::is_fse_mode_active()
            ) &&
            $payment_method->has_spc_form
        ) {
            // TODO: Maybe we queue these up from wp_enqueue_scripts?
            wp_register_script('mepr-checkout-js', MEPR_JS_URL . '/checkout.js', ['jquery', 'jquery.payment'], MEPR_VERSION);
            wp_enqueue_script('mepr-checkout-js');
            $payment_method->enqueue_payment_form_scripts();
            $description = $payment_method->spc_payment_fields($product);
        }
        return $description;
    }

    /**
     * Display the signup form for a product.
     *
     * @param MeprProduct $product The product object.
     *
     * @return void
     */
    public function display_signup_form(MeprProduct $product)
    {
        $mepr_options = MeprOptions::fetch();
        $mepr_blogurl = home_url();

        extract($_REQUEST, EXTR_SKIP);
        if (isset($_REQUEST['errors'])) {
            if (is_array($_REQUEST['errors'])) {
                $errors = array_map('wp_kses_post', wp_unslash($_REQUEST['errors'])); // Use kses here so our error HTML isn't stripped.
            } else {
                $errors = [wp_kses_post(wp_unslash($_REQUEST['errors']))];
            }
        }

        $mepr_coupon_code = $product->get_applicable_coupon_code();

        if (MeprUtils::is_user_logged_in()) {
            $mepr_current_user = MeprUtils::get_currentuserinfo();
        }

        $first_name_value = '';
        if (isset($user_first_name)) {
            $first_name_value = esc_attr(stripslashes($user_first_name));
        } elseif (MeprUtils::is_user_logged_in()) {
            $first_name_value = (string)$mepr_current_user->first_name;
        }

        $last_name_value = '';
        if (isset($user_last_name)) {
            $last_name_value = esc_attr(stripslashes($user_last_name));
        } elseif (MeprUtils::is_user_logged_in()) {
            $last_name_value = (string)$mepr_current_user->last_name;
        }

        if (isset($errors) and !empty($errors)) {
            MeprView::render('/shared/errors', get_defined_vars());
        }

        // Gather payment methods for checkout.
        $payment_methods = $product->payment_methods();
        if (empty($payment_methods)) {
            $payment_methods = array_keys($mepr_options->integrations);
        }
        $payment_methods = MeprHooks::apply_filters('mepr_options_helper_payment_methods', $payment_methods, 'mepr_payment_method', $product);
        $payment_methods = array_map(function ($pm_id) use ($mepr_options) {
            return $mepr_options->payment_method($pm_id);
        }, $payment_methods);

        static $unique_suffix = 0;
        ++$unique_suffix;

        $payment_required = $product->is_payment_required($mepr_coupon_code);

        // Required order bumps render pre-checked and cannot be removed, so a paid required bump
        // makes payment due even when a coupon zeroes the base price. This mirrors the AJAX
        // recompute in get_checkout_state(), which derives the same from the posted bumps. A free
        // required bump on a zeroed base keeps the total at zero, so the checkout stays on the
        // free path and finalize_free_order() grants the bump there.
        foreach (self::get_required_order_bump_products($product, $mepr_coupon_code) as $order_bump_product) {
            if ($order_bump_product->is_payment_required()) {
                $payment_required = true;
                break;
            }
        }

        $payment_required = MeprHooks::apply_filters('mepr_signup_payment_required', $payment_required, $product);

        if ($mepr_options->enable_spc) {
            if (
                MeprReadyLaunchCtrl::template_enabled('checkout')
                || MeprAppHelper::has_block('memberpress/checkout')
                || MeprHooks::apply_filters('mepr_pro_templates_has_checkout_block', false)
            ) {
                $is_rl_widget = ( is_active_sidebar('mepr_rl_registration_footer') || is_active_sidebar('mepr_rl_global_footer') );
                MeprView::render('/readylaunch/checkout/form', get_defined_vars());
            } else {
                MeprView::render('/checkout/spc_form', get_defined_vars());
            }
        } else {
            if (
                MeprReadyLaunchCtrl::template_enabled('checkout')
                || MeprAppHelper::has_block('memberpress/checkout')
                || MeprHooks::apply_filters('mepr_pro_templates_has_checkout_block', false)
            ) {
                $is_rl_widget = ( is_active_sidebar('mepr_rl_registration_footer') || is_active_sidebar('mepr_rl_global_footer') );
                MeprView::render('/readylaunch/checkout/form', get_defined_vars());
            } else {
                MeprView::render('/checkout/form', get_defined_vars());
            }
        }
    }

    /**
     * Gets called on the 'init' hook ... used for processing aspects of the signup
     * form before the logic progresses on to 'the_content' ...
     *
     * @return void
     */
    public function process_signup_form()
    {
        $mepr_options = MeprOptions::fetch();

        // Validate the form post.
        $errors = MeprHooks::apply_filters('mepr_validate_signup', MeprUser::validate_signup($_POST, []));
        if (!empty($errors)) {
            $_POST['errors']    = $errors; // Deprecated?
            $_REQUEST['errors'] = $errors;

            return;
        }

        // Check if the user is logged in already.
        $is_existing_user = MeprUtils::is_user_logged_in();

        if ($is_existing_user) {
            $usr = MeprUtils::get_currentuserinfo();
        } else { // If new user we've got to create them and sign them in.
            $usr             = new MeprUser();
            $usr->user_login = ($mepr_options->username_is_email) ? sanitize_email(wp_unslash($_POST['user_email'] ?? '')) : sanitize_user(wp_unslash($_POST['user_login'] ?? ''));
            $usr->user_email = sanitize_email(wp_unslash($_POST['user_email'] ?? ''));
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $usr->first_name = MeprUtils::sanitize_name_field(wp_unslash($_POST['user_first_name'] ?? ''));
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $usr->last_name = MeprUtils::sanitize_name_field(wp_unslash($_POST['user_last_name'] ?? ''));

            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            $password = ($mepr_options->disable_checkout_password_fields === true) ? wp_generate_password() : ($_POST['mepr_user_password'] ?? '');
            // Have to use rec here because we unset user_pass on __construct.
            $usr->set_password($password);

            try {
                $usr->store();

                // We need to refresh the user object. In the case where emails are used as
                // usernames, the email & username could differ after the user is saved.
                $usr = new MeprUser($usr->ID);

                // Log the new user in.
                if (MeprHooks::apply_filters('mepr_auto_login', true, intval(wp_unslash($_POST['mepr_product_id'] ?? 0)), $usr)) {
                    wp_signon(
                        [
                            'user_login'    => $usr->user_login,
                            'user_password' => $password,
                        ],
                        MeprUtils::is_ssl() // May help with the users getting logged out when going between http and https.
                    );
                }

                MeprEvent::record('login', $usr); // Record the first login here.
            } catch (MeprCreateException $e) {
                $_POST['errors']    = [__('The user was unable to be saved.', 'memberpress')];  // Deprecated?
                $_REQUEST['errors'] = [__('The user was unable to be saved.', 'memberpress')];
                return;
            }
        }

        // Create a new transaction and set our new membership details.
        $txn          = new MeprTransaction();
        $txn->user_id = $usr->ID;

        // Get the membership in place.
        $txn->product_id = intval(wp_unslash($_POST['mepr_product_id'] ?? 0));
        $product         = $txn->product();

        if (empty($product->ID)) {
            $_POST['errors']    = [__('Sorry, we were unable to find the membership.', 'memberpress')];
            $_REQUEST['errors'] = [__('Sorry, we were unable to find the membership.', 'memberpress')];
            return;
        }

        // If we're showing the fields on logged in purchases, let's save them here.
        if (!$is_existing_user || ($is_existing_user && $mepr_options->show_fields_logged_in_purchases)) {
            MeprUsersCtrl::save_extra_profile_fields($usr->ID, true, $product, true);
            $usr = new MeprUser($usr->ID); // Re-load the user object with the metadata now (helps with first name last name missing from hooks below).
        }

        // Needed for autoresponders (SPC + Stripe + Free Trial issue).
        MeprHooks::do_action('mepr_signup_user_loaded', $usr);

        // Set default price, adjust it later if coupon applies.
        $price = $product->adjusted_price();

        // Default coupon object.
        $cpn = (object)[
            'ID'         => 0,
            'post_title' => null,
        ];

        // Adjust membership price from the coupon code.
        if (isset($_POST['mepr_coupon_code']) && !empty($_POST['mepr_coupon_code'])) {
            // Coupon object has to be loaded here or else txn create will record a 0 for coupon_id.
            $mepr_coupon_code = htmlentities(sanitize_text_field(wp_unslash($_POST['mepr_coupon_code'])));
            $found_cpn        = MeprHooks::apply_filters('mepr_coupon_from_code', false, sanitize_text_field(wp_unslash($_POST['mepr_coupon_code'])));

            if ($found_cpn !== false) {
                $cpn   = $found_cpn;
                $price = $product->adjusted_price($cpn->post_title);
            }
        }

        $txn->set_subtotal(MeprUtils::maybe_round_to_minimum_amount($price));

        // Set the coupon id of the transaction.
        $txn->coupon_id = $cpn->ID;

        // Figure out the Payment Method.
        if (isset($_POST['mepr_payment_method']) && !empty($_POST['mepr_payment_method'])) {
            $txn->gateway = sanitize_text_field(wp_unslash($_POST['mepr_payment_method']));
        } else {
            $txn->gateway = MeprTransaction::$free_gateway_str;
        }

        // Let's checkout now.
        if ($txn->gateway === MeprTransaction::$free_gateway_str) {
            $signup_type = 'free';
        } else {
            $pm = $txn->payment_method();
            if ($pm instanceof MeprBaseExclusiveRecurringGateway) {
                $sub_attrs = $pm->subscription_attributes($product->plan_code);
                if ($pm->is_one_time_payment($product->plan_code)) {
                    $signup_type = 'non-recurring';
                    $price       = $sub_attrs['one_time_amount'];
                } else {
                    $signup_type = 'recurring';

                    // Create the subscription from the gateway plan.
                    $sub             = new MeprSubscription($sub_attrs);
                    $sub->user_id    = $usr->ID;
                    $sub->gateway    = $pm->id;
                    $sub->product_id = $product->ID;
                    $sub->maybe_prorate(); // Sub to sub.
                    $sub->store();

                    // Update the transaction with subscription id.
                    $txn->subscription_id = $sub->id;
                    $price                = $sub->price;
                }
                // Update subtotal.
                $txn->amount = $price;
            } elseif ($pm instanceof MeprBaseRealGateway) {
                // Set default price, adjust it later if coupon applies.
                $price = $product->adjusted_price();
                // Default coupon object.
                $cpn = (object)[
                    'ID'         => 0,
                    'post_title' => null,
                ];
                // Adjust membership price from the coupon code.
                if (isset($_POST['mepr_coupon_code']) && !empty($_POST['mepr_coupon_code'])) {
                    // Coupon object has to be loaded here or else txn create will record a 0 for coupon_id.
                    $found_cpn = MeprHooks::apply_filters('mepr_coupon_from_code', false, sanitize_text_field(wp_unslash($_POST['mepr_coupon_code'])));
                    if ($found_cpn !== false) {
                        $cpn   = $found_cpn;
                        $price = $product->adjusted_price($cpn->post_title);
                    }
                }
                $txn->set_subtotal(MeprUtils::maybe_round_to_minimum_amount($price));

                // Set the coupon id of the transaction.
                $txn->coupon_id = $cpn->ID;
                // Create a new subscription.
                if ($product->is_one_time_payment() || !$product->is_payment_required($cpn->post_title)) {
                    $signup_type = 'non-recurring';
                } else {
                    $signup_type = 'recurring';

                    $sub          = new MeprSubscription();
                    $sub->user_id = $usr->ID;
                    $sub->gateway = $pm->id;
                    $sub->load_product_vars($product, $cpn->post_title, true);
                    $sub->maybe_prorate(); // Sub to sub.
                    $sub->store();

                    $txn->subscription_id = $sub->id;
                }
            } else {
                $_POST['errors'] = [__('Invalid payment method', 'memberpress')];
                return;
            }
        }

        $txn->store();

        if (empty($txn->id)) {
            // Don't want any loose ends here if the $txn didn't save for some reason.
            if ($signup_type === 'recurring' && ($sub instanceof MeprSubscription)) {
                $sub->destroy();
            }
            $_POST['errors'] = [__('Sorry, we were unable to create a transaction.', 'memberpress')];
            return;
        }

        try {
            if (! $is_existing_user) {
                if ($mepr_options->disable_checkout_password_fields === true) {
                    $usr->send_password_notification('new');
                }
            }

            // DEPRECATED: These 2 actions here for backwards compatibility ... use mepr-signup instead.
            MeprHooks::do_action('mepr_track_signup', $txn->amount, $usr, $product->ID, $txn->id);
            MeprHooks::do_action('mepr_process_signup', $txn->amount, $usr, $product->ID, $txn->id);

            if (('free' !== $signup_type) && isset($pm) && ($pm instanceof MeprBaseRealGateway)) {
                $pm->process_signup_form($txn);
            }

            // Signup type can be 'free', 'non-recurring' or 'recurring'.
            MeprHooks::do_action("mepr_{$signup_type}_signup", $txn);
            MeprHooks::do_action('mepr_signup', $txn);

            // Pass order bump product IDs to the checkout second page.
            $obs  = isset($_POST['mepr_order_bumps']) && is_array($_POST['mepr_order_bumps']) ? array_filter(array_map('intval', $_POST['mepr_order_bumps'])) : [];
            $args = count($obs) ? ['obs' => $obs] : [];

            MeprUtils::wp_redirect(MeprHooks::apply_filters('mepr_signup_checkout_url', $txn->checkout_url($args), $txn));
        } catch (Exception $e) {
            $_POST['errors'] = $_REQUEST['errors'] = [$e->getMessage()];
        }
    }

    /**
     * Called from filter mepr-signup-checkout-url
     * Used to handle redirection when there are errors on SPC
     * Returns: redirection URL
     *
     * @param string $checkout_url The checkout URL.
     * @param object $txn          The transaction object.
     *
     * @return string The modified checkout URL.
     */
    public function handle_spc_checkout_url($checkout_url, $txn)
    {
        $mepr_options = MeprOptions::fetch();
        if (isset($_POST['mepr_payment_method'])) {
            $payment_method = $mepr_options->payment_method(sanitize_text_field(wp_unslash($_POST['mepr_payment_method'])));
            if ($mepr_options->enable_spc && $payment_method->has_spc_form && !empty($_POST['errors'])) {
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
                $errors       = wp_unslash($_POST['errors']);
                $errors       = array_map('urlencode', $errors);
                $query_params = [
                    'errors'                    => $errors,
                    'mepr_transaction_id'       => $txn->id,
                    'mepr_checkout_token'       => $txn->checkout_token(),
                    'mepr_process_signup_form'  => 0,
                    'mepr_process_payment_form' => 1,
                    'mepr_payment_method'       => sanitize_text_field(wp_unslash($_POST['mepr_payment_method'])),
                ];
                if (!empty($_POST['mepr_coupon_code'])) {
                    $query_params = array_merge(['mepr_coupon_code' => htmlentities(sanitize_text_field(wp_unslash($_POST['mepr_coupon_code'])))], $query_params);
                }
                $product      = $txn->product();
                $checkout_url = add_query_arg($query_params, $product->url());
            }
        }
        return $checkout_url;
    }

    /**
     * Called from mepr-signup action
     * Processes the payment for SPC
     *
     * @param object $txn The transaction object.
     *
     * @return void
     */
    public function process_spc_payment_form($txn)
    {
        if (wp_doing_ajax()) {
            return;
        }

        if (isset($_POST['smart-payment-button']) && sanitize_text_field(wp_unslash($_POST['smart-payment-button']))) {
            return;
        }

        $mepr_options = MeprOptions::fetch();
        if (isset($_POST['mepr_payment_method'])) {
            $payment_method = $mepr_options->payment_method(sanitize_text_field(wp_unslash($_POST['mepr_payment_method'])));
            if (
                ($mepr_options->enable_spc && $payment_method->has_spc_form) ||
                $mepr_options->design_enable_checkout_template ||
                MeprFseHelper::is_fse_mode_active()
            ) {
                $_POST = array_merge(
                    $_POST,
                    [
                        'mepr_process_payment_form' => 1,
                        'mepr_transaction_id'       => $txn->id,
                        // The transaction was created by this very request, so it's
                        // safe to vouch for it with its own checkout token here.
                        'mepr_checkout_token'       => $txn->checkout_token(),
                    ]
                );
                $this->process_payment_form();
            }
        }
    }

    /**
     * Display the payment page for a transaction.
     *
     * @return void
     */
    public function display_payment_page()
    {
        $mepr_options = MeprOptions::fetch();

        $txn_id = intval(wp_unslash($_REQUEST['txn'] ?? 0));
        $txn    = new MeprTransaction($txn_id);

        if (!isset($txn->id) || $txn->id <= 0) {
            wp_die(esc_html__('ERROR: Invalid Transaction ID. Use your browser back button and try registering again.', 'memberpress'));
        }

        // Resolve the order bumps carried to this page so a coupon-zeroed checkout completes them
        // instead of dropping them. Prefer the posted bumps (obs), validated against the product;
        // on the pure free path fall back to the offered required bumps.
        $order_bump_product_ids = isset($_GET['obs']) && is_array($_GET['obs']) ? array_map('intval', $_GET['obs']) : []; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

        try {
            $order_bump_products = self::get_order_bump_products($txn->product_id, $order_bump_product_ids);
        } catch (Exception $e) {
            $order_bump_products = [];
        }

        if ($txn->gateway === MeprTransaction::$free_gateway_str && empty($order_bump_products)) {
            $coupon              = $txn->coupon();
            $order_bump_products = self::get_required_order_bump_products($txn->product(), $coupon instanceof MeprCoupon ? $coupon->post_title : '');
        }

        $bump_payment_required = false;
        foreach ($order_bump_products as $order_bump_product) {
            if ($order_bump_product->is_payment_required()) {
                $bump_payment_required = true;
                break;
            }
        }

        // A paid order bump must never complete on the free gateway. validate_signup() requires a
        // payment method whenever one is due, so this is an inconsistent state rather than a
        // reachable flow, guard against silently granting the base without charging the bump.
        if ($txn->gateway === MeprTransaction::$free_gateway_str && $bump_payment_required) {
            wp_die(esc_html__('ERROR: A payment method is required to complete this order.', 'memberpress'));
        }

        if ($txn->gateway === MeprTransaction::$free_gateway_str || ($txn->amount <= 0.00 && !$bump_payment_required)) {
            try {
                self::finalize_free_order($txn, $order_bump_products, $txn->gateway);
            } catch (Exception $e) {
                wp_die(esc_html($e->getMessage()));
            }
        } else {
            $pm = $mepr_options->payment_method($txn->gateway);
            if ($pm instanceof MeprBaseRealGateway) {
                $pm->display_payment_page($txn);
            }
        }

        // Artificially set the payment method params so we can use them downstream
        // when display_payment_form is called in the 'the_content' action.
        $_REQUEST['payment_method_params'] = [
            'method'         => $txn->gateway,
            'amount'         => $txn->amount,
            'user'           => $txn->user(),
            'product_id'     => $txn->product_id,
            'transaction_id' => $txn->id,
        ];
    }

    /**
     * Display the payment form.
     * Called in the 'the_content' hook ... used to display a signup form
     *
     * @return void
     */
    public function display_payment_form()
    {
        $mepr_options = MeprOptions::fetch();

        if (isset($_REQUEST['payment_method_params'])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
            extract(wp_unslash($_REQUEST['payment_method_params']), EXTR_SKIP);

            if (isset($_REQUEST['errors']) && !empty($_REQUEST['errors'])) {
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
                $errors = wp_unslash($_REQUEST['errors']);
                MeprView::render('/shared/errors', get_defined_vars());
            }

            $pm = $mepr_options->payment_method($method);
            if ($pm && $pm instanceof MeprBaseRealGateway) {
                $pm->display_payment_form($amount, $user, $product_id, $transaction_id);
            }
        }
    }

    /**
     * Whether the current request is allowed to act on the given checkout transaction.
     *
     * Unauthenticated requests must supply the checkout token that was generated
     * when the transaction was created — it rides along on the checkout URL and
     * as a hidden payment form field. The transaction's owner is always allowed.
     *
     * @param MeprTransaction $txn   The transaction being acted on.
     * @param string          $token The checkout token supplied with the request.
     *
     * @return boolean True if the request may act on the transaction.
     */
    public static function verify_checkout_access($txn, $token)
    {
        if (!($txn instanceof MeprTransaction) || (int) $txn->id <= 0) {
            return false;
        }

        if (
            MeprUtils::is_user_logged_in() &&
            (int) $txn->user_id > 0 &&
            (int) $txn->user_id === (int) MeprUtils::get_current_user_id()
        ) {
            return true;
        }

        return $txn->verify_checkout_token($token);
    }

    /**
     * Process the payment form.
     *
     * @return void
     */
    public function process_payment_form()
    {
        if (isset($_POST['mepr_process_payment_form']) && isset($_POST['mepr_transaction_id']) && is_numeric($_POST['mepr_transaction_id'])) {
            $txn = new MeprTransaction(intval(wp_unslash($_POST['mepr_transaction_id'])));

            $token = isset($_POST['mepr_checkout_token']) ? sanitize_text_field(wp_unslash($_POST['mepr_checkout_token'])) : '';

            if (!self::verify_checkout_access($txn, $token)) {
                $prd = new MeprProduct($txn->product_id);
                MeprUtils::wp_redirect($prd->ID > 0 ? $prd->url() : home_url());
            }

            if ($txn->rec !== false) {
                $mepr_options = MeprOptions::fetch();
                $pm           = $mepr_options->payment_method($txn->gateway);
                if ($pm instanceof MeprBaseRealGateway) {
                    $errors = MeprHooks::apply_filters('mepr_validate_payment_form', $pm->validate_payment_form([]), $txn, $pm);

                    if (empty($errors)) {
                        // The process_payment_form either returns true
                        // for success or an array of $errors on failure.
                        try {
                            $pm->process_payment_form($txn);
                        } catch (Exception $e) {
                            MeprHooks::do_action('mepr_payment_failure', $txn);
                            $errors = [$e->getMessage()];
                        }
                    }

                    if (empty($errors)) {
                        // Reload the txn now that it should have a proper trans_num set.
                        $txn             = new MeprTransaction($txn->id);
                        $product         = new MeprProduct($txn->product_id);
                        $sanitized_title = sanitize_title($product->post_title);
                        $query_params    = [
                            'membership'    => $sanitized_title,
                            'trans_num'     => $txn->trans_num,
                            'membership_id' => $product->ID,
                        ];
                        if ($txn->subscription_id > 0) {
                              $sub          = $txn->subscription();
                              $query_params = array_merge($query_params, ['subscr_id' => $sub->subscr_id]);
                        }
                        MeprUtils::wp_redirect($mepr_options->thankyou_page_url(build_query($query_params)));
                    } else {
                        // Artificially set the payment method params so we can use them downstream
                        // when display_payment_form is called in the 'the_content' action.
                        $_REQUEST['payment_method_params'] = [
                            'method'         => $pm->id,
                            'amount'         => $txn->amount,
                            'user'           => new MeprUser($txn->user_id),
                            'product_id'     => $txn->product_id,
                            'transaction_id' => $txn->id,
                        ];
                        $_REQUEST['mepr_payment_method']   = $pm->id;
                        $_POST['errors']                   = $errors;
                        return;
                    }
                }
            }
        }

        $_POST['errors'] = [__('Sorry, an unknown error occurred.', 'memberpress')];
    }

    /**
     * Exclude disconnected gateways from the list of payment methods.
     *
     * @param array            $pm_ids     The payment method IDs.
     * @param string           $field_name The field name.
     * @param MeprProduct|null $product    The product being purchased.
     *
     * @return array The filtered payment method IDs.
     */
    public function exclude_disconnected_gateways($pm_ids, $field_name, $product = null)
    {
        $mepr_options     = MeprOptions::fetch();
        $connected_pm_ids = [];
        $product          = $product instanceof MeprProduct ? $product : null;

        foreach ($pm_ids as $pm_id) {
            $obj = $mepr_options->payment_method($pm_id);

            if (MeprUtils::is_gateway_connected($obj, $product)) {
                $connected_pm_ids[] = $pm_id;
            }
        }

        return $connected_pm_ids;
    }

    /**
     * Check if the request has valid thank you parameters.
     *
     * @param array $req The request parameters.
     *
     * @return boolean True if valid, false otherwise.
     */
    private function request_has_valid_thank_you_params($req)
    {
        // If these aren't set as parameters then this isn't actually a real checkout.
        if (
            !isset($req['membership']) ||
            !isset($req['membership_id']) ||
            (!isset($req['trans_num']) && !isset($req['transaction_id']))
        ) {
            return false;
        }

        return true;
    }

    /**
     * Check if the request has a valid membership ID.
     *
     * @param array $req The request parameters.
     *
     * @return boolean True if valid, false otherwise.
     */
    private function request_has_valid_thank_you_membership_id($req)
    {
        // If this is an invalid membership then let's bail, yo.
        $membership = new MeprProduct($req['membership_id']);
        if (!$membership || empty($membership->ID)) {
            return false;
        }

        return true;
    }

    /**
     * Check if the request has a valid transaction number.
     *
     * @param array $req The request parameters.
     *
     * @return boolean True if valid, false otherwise.
     */
    private function request_has_valid_thank_you_trans_num($req)
    {
        // If this is an invalid transaction then let's bail, yo.
        $transaction = $this->get_transaction_from_request($req);

        return !empty($transaction);
    }

    /**
     * Check if the request has a valid membership.
     *
     * @param array $atts The shortcode attributes.
     * @param array $req  The request parameters.
     *
     * @return boolean True if valid, false otherwise.
     */
    private function request_has_valid_thank_you_membership($atts, $req)
    {
        $membership  = new MeprProduct($req['membership_id']);
        $transaction = $this->get_transaction_from_request($req);

        // If this transaction doesn't match the membership then something fishy is going on here bro.
        if (empty($transaction) || (int) $transaction->product_id !== (int) $membership->ID) {
            return false;
        }

        // If the shortcode is tied to a specific membership then only show
        // it when this is the thank you page for the specified membership.
        if (
            !is_null($atts['membership']) && isset($req['membership_id']) &&
            (int) $req['membership_id'] !== (int) $atts['membership']
        ) {
            return false;
        }

        return true;
    }

    /**
     * Get the transaction from the request.
     *
     * @param array $req The request parameters.
     *
     * @return object|false The transaction object or false if not found.
     */
    private function get_transaction_from_request($req)
    {
        if (isset($req['trans_num'])) {
            return MeprTransaction::get_one_by_trans_num($req['trans_num']);
        } elseif (isset($req['transaction_id'])) {
            return MeprTransaction::get_one((int) $req['transaction_id']);
        }

        return false;
    }

    /**
     * Prepare transaction variables
     *
     * Instantiates and returns a new transaction and subscription (if recurring), based on the given parameters.
     *
     * @param  MeprProduct      $product    The product being purchased.
     * @param  integer          $order_id   The order ID.
     * @param  integer          $user_id    The user ID.
     * @param  string           $gateway_id The payment gateway ID.
     * @param  MeprCoupon|false $cpn        Optional coupon code.
     * @param  boolean          $store      Whether to store the transaction & subscription, default true.
     * @return array{0: MeprTransaction, 1: MeprSubscription|null}
     * @throws Exception If unable to store a transaction or subscription.
     */
    public static function prepare_transaction(MeprProduct $product, $order_id, $user_id, $gateway_id, $cpn = false, $store = true)
    {
        $txn             = new MeprTransaction();
        $txn->order_id   = $order_id;
        $txn->user_id    = $user_id;
        $txn->gateway    = $gateway_id;
        $txn->product_id = $product->ID;
        $txn->coupon_id  = 0;

        // Set default price, adjust it later if coupon applies.
        $price = $product->adjusted_price();

        /*
         * Adjust membership price from the coupon code. A failed instanceof
         * never autoloads, and && short-circuits before the static call, so
         * the call is only reached when MeprCoupon is present. Safe in
         * stripped builds. mepr:coupon-surface
         */
        if ($cpn instanceof MeprCoupon && MeprCoupon::is_valid_coupon_code($cpn->post_title, $product->ID)) {
            $price          = $product->adjusted_price($cpn->post_title);
            $txn->coupon_id = $cpn->ID;
        }

        $txn->set_subtotal(MeprUtils::maybe_round_to_minimum_amount($price));

        if ($product->is_one_time_payment()) {
            $sub = null;
        } else {
            $sub           = new MeprSubscription();
            $sub->order_id = $order_id;
            $sub->user_id  = $user_id;
            $sub->gateway  = $gateway_id;
            $sub->load_product_vars($product, $cpn instanceof MeprCoupon ? $cpn->post_title : null, true);
            $sub->maybe_prorate(); // Sub to sub.
        }

        if ($store) {
            if ($sub instanceof MeprSubscription) {
                $sub->store();

                if (empty($sub->id)) {
                    throw new Exception(esc_html__('Sorry, we were unable to create a subscription.', 'memberpress'));
                }

                $txn->subscription_id = $sub->id;
            }

            $txn->store();

            if (empty($txn->id)) {
                // Don't want any loose ends here if the transaction didn't save for some reason.
                if ($sub instanceof MeprSubscription) {
                    $sub->destroy();
                }

                throw new Exception(esc_html__('Sorry, we were unable to create a transaction.', 'memberpress'));
            }
        }

        return [$txn, $sub];
    }

    /**
     * Complete a free order, including any free order bumps, and grant the memberships.
     *
     * Creates an order and free child transactions for the given order bump products, marks
     * the base transaction, the bump transactions, and the order complete, then redirects to
     * the thank-you page. This mirrors the free branch of {@see MeprBaseRealGateway::process_order()}
     * so the pure free signup path grants required order bumps that the single-product
     * {@see MeprTransaction::create_free_transaction()} would otherwise drop. All given order bump
     * products are expected to be free; the caller is responsible for that guarantee.
     *
     * The order and its transactions are recorded against $gateway_id (the gateway that processed
     * the checkout) so an order's free items stay grouped under it; the order row itself is marked
     * with the free gateway once complete. The base transaction is completed after its order_id is
     * set so it stays grouped with the order too.
     *
     * @param  MeprTransaction $txn                 The base product transaction to complete.
     * @param  MeprProduct[]   $order_bump_products The order bump products to grant for free.
     * @param  string          $gateway_id          The gateway that processed the checkout.
     * @param  boolean         $redirect            Whether to redirect to the thank-you page when done.
     * @return void
     * @throws Exception If a bump transaction or subscription cannot be stored.
     */
    public static function finalize_free_order(MeprTransaction $txn, array $order_bump_products, string $gateway_id, bool $redirect = true): void
    {
        if (count($order_bump_products)) {
            $order                         = new MeprOrder();
            $order->user_id                = $txn->user_id;
            $order->primary_transaction_id = $txn->id;
            $order->gateway                = $gateway_id;
            $order->store();

            $txn->order_id = $order->id;
            $txn->store();

            $sub = $txn->subscription();

            if ($sub instanceof MeprSubscription) {
                $sub->order_id = $order->id;
                $sub->store();
            }

            // Complete the base after its order_id is set so it stays grouped under the order's gateway.
            MeprTransaction::create_free_transaction($txn, false);

            foreach ($order_bump_products as $product) {
                list($transaction) = self::prepare_transaction($product, $order->id, $txn->user_id, $gateway_id);
                MeprTransaction::create_free_transaction($transaction, false);
            }

            $order->status  = 'complete';
            $order->gateway = MeprTransaction::$free_gateway_str;
            $order->store();
        } else {
            MeprTransaction::create_free_transaction($txn, false);
        }

        if (!$redirect) {
            return;
        }

        $mepr_options    = MeprOptions::fetch();
        $product         = $txn->product();
        $sanitized_title = sanitize_title($product->post_title);
        $query_params    = [
            'membership'    => $sanitized_title,
            'trans_num'     => $txn->trans_num,
            'membership_id' => $product->ID,
        ];

        MeprUtils::wp_redirect($mepr_options->thankyou_page_url(build_query($query_params)));
    }

    /**
     * Get order bump products
     *
     * Gets an array that is filled with a MeprProduct for each order bump in the $_POST data.
     *
     * @param  integer $product_id             The main product for the purchase, order bumps with this ID will be ignored.
     * @param  array   $order_bump_product_ids The order bump product IDs.
     * @return MeprProduct[]
     * @throws Exception If a product was not found.
     */
    public static function get_order_bump_products($product_id, array $order_bump_product_ids)
    {
        $order_bump_products  = [];
        $base_product         = new MeprProduct($product_id);
        $required_order_bumps = $base_product->get_required_order_bumps();

        // Track if all required order bumps are found.
        if (!empty($required_order_bumps) && !empty($order_bump_product_ids)) {
            $missing_required_order_bumps = array_diff($required_order_bumps, $order_bump_product_ids);
            if (!empty($missing_required_order_bumps)) {
                throw new Exception(esc_html__('One of the required products is missing.', 'memberpress'));
            }
        }

        $allowed_order_bump_ids = array_map('intval', (array) get_post_meta($base_product->ID, '_mepr_order_bumps', true));

        foreach ($order_bump_product_ids as $order_bump_product_id) {
            $product = new MeprProduct($order_bump_product_id);

            if (empty($product->ID)) {
                throw new Exception(esc_html__('Product not found', 'memberpress'));
            }

            if ((int) $product_id === $product->ID) {
                continue;
            }

            if (!in_array($product->ID, $allowed_order_bump_ids, true)) {
                throw new Exception(esc_html__('Invalid order bump product.', 'memberpress'));
            }

            if (!$product->can_you_buy_me()) {
                throw new Exception(sprintf(
                    // Translators: %s: product name.
                    esc_html__("You don't have access to purchase %s.", 'memberpress'),
                    esc_html($product->post_title)
                ));
            }

            $group = $product->group();

            if ($group instanceof MeprGroup && $group->is_upgrade_path) {
                throw new Exception(sprintf(
                    // Translators: %s: product name.
                    esc_html__('The product %s cannot be purchased at this time.', 'memberpress'),
                    esc_html($product->post_title)
                ));
            }

            $order_bump_products[] = $product;
        }

        return $order_bump_products;
    }

    /**
     * Get the required order bump products offered for a product's checkout.
     *
     * Required order bumps render pre-checked and cannot be deselected, so they must be
     * included when calculating the initial payment state. The gating mirrors the order
     * bump renderer: nothing is offered when the base product is genuinely free, is a
     * corporate product, or is a subaccount signup. A bump the visitor cannot purchase
     * is skipped rather than rejected. A coupon that zeroes the base price does not
     * suppress required bumps: they must still render so the pre-checked bump is posted
     * with the signup, matching the renderer.
     *
     * @param  MeprProduct $prd         The base product.
     * @param  string      $coupon_code The applicable coupon code.
     * @return MeprProduct[]
     */
    public static function get_required_order_bump_products(MeprProduct $prd, string $coupon_code = ''): array
    {
        $order_bump_products = [];
        $offers_order_bumps  = (float) $prd->price > 0.00
            && !get_post_meta($prd->ID, 'mpca_is_corporate_product', true)
            && !isset($_GET['ca']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
            && (
                empty($coupon_code)
                || (float) $prd->adjusted_price($coupon_code) > 0.00
                || !empty($prd->get_required_order_bumps())
            );

        if ($offers_order_bumps) {
            $allowed_order_bump_ids = array_map('intval', (array) get_post_meta($prd->ID, '_mepr_order_bumps', true));

            foreach ($prd->get_required_order_bumps() as $order_bump_product_id) {
                $product = new MeprProduct($order_bump_product_id);

                if (
                    empty($product->ID)
                    || $product->ID === $prd->ID
                    || !in_array($product->ID, $allowed_order_bump_ids, true)
                    || !$product->can_you_buy_me()
                    || get_post_meta($product->ID, 'mpca_is_corporate_product', true)
                ) {
                    continue;
                }

                $group = $product->group();

                if ($group instanceof MeprGroup && $group->is_upgrade_path) {
                    continue;
                }

                $order_bump_products[] = $product;
            }
        }

        return $order_bump_products;
    }

    /**
     * Get dynamic updates when the checkout state changes
     *
     * @return void
     */
    public function get_checkout_state()
    {
        $mepr_options = MeprOptions::fetch();
        $product_id   = intval(wp_unslash($_POST['mepr_product_id'] ?? 0));
        $coupon_code  = sanitize_text_field(wp_unslash($_POST['mepr_coupon_code'] ?? ''));

        if (empty($product_id)) {
            wp_send_json_error();
        }

        $prd = new MeprProduct($product_id);

        if (empty($prd->ID)) {
            wp_send_json_error();
        }

        if (!empty($coupon_code) && !check_ajax_referer('mepr_coupons', 'mepr_coupon_nonce', false)) {
            wp_send_json_error();
        }

        $is_gift             = isset($_POST['mpgft_gift_checkbox']) ? sanitize_text_field(wp_unslash($_POST['mpgft_gift_checkbox'])) : 'false';
        $payment_required    = $prd->is_payment_required($coupon_code);
        $order_bump_products = [];

        if ($is_gift === 'true') {
            $prd->allow_renewal = false;
        }

        try {
            $order_bump_product_ids = isset($_POST['mepr_order_bumps']) && is_array($_POST['mepr_order_bumps']) ? array_map('intval', $_POST['mepr_order_bumps']) : [];
            $order_bump_products    = self::get_order_bump_products($prd->ID, $order_bump_product_ids);

            foreach ($order_bump_products as $product) {
                if ($product->is_payment_required()) {
                    $payment_required = true;
                }
            }
        } catch (Exception $e) {
            // Ignore exception.
        }

        $payment_required = MeprHooks::apply_filters('mepr_signup_payment_required', $payment_required, $prd);

        ob_start();
        MeprProductsHelper::display_invoice($prd, $coupon_code);
        $price_string = ob_get_clean();

        // By default hide required order bumps pricing terms on SPC and ReadyLaunch™ Templates.
        $disable_ob_required_terms = $mepr_options->enable_spc
            || $mepr_options->design_enable_checkout_template
            || MeprFseHelper::is_fse_mode_active();
        if (! $mepr_options->enable_spc_invoice) {
            $disable_ob_required_terms = false;
        }

        if (! MeprHooks::apply_filters('mepr_signup_disable_order_bumps_required_terms', $disable_ob_required_terms, $prd)) {
            $required_order_bumps = $prd->get_required_order_bumps();
            if (! empty($required_order_bumps)) {
                ob_start();
                foreach ($required_order_bumps as $required_order_bump_id) {
                    if (! MeprHooks::apply_filters('mepr_signup_skip_order_bump_required_terms', false, $required_order_bump_id, $prd)) {
                        echo'<br />';
                        MeprProductsHelper::display_invoice(new MeprProduct($required_order_bump_id), false, true);
                    }
                }

                $required_order_bumps_terms = ob_get_clean();
                if (! empty($required_order_bumps_terms)) {
                    $price_string .= wp_kses_post($required_order_bumps_terms);
                }
            }
        }

        ob_start();
        MeprProductsHelper::display_spc_invoice($prd, $coupon_code, $order_bump_products);
        $invoice_html = ob_get_clean();

        $data = [
            'payment_required' => $payment_required,
            'price_string'     => $price_string,
            'invoice_html'     => $invoice_html,
            'is_gift'          => MeprHooks::apply_filters('mepr_signup_product_is_gift', false, $prd),
        ];

        if ($payment_required) {
            $payment_method_ids = isset($_POST['mepr_payment_methods']) && is_array($_POST['mepr_payment_methods']) ? array_map('sanitize_text_field', wp_unslash($_POST['mepr_payment_methods'])) : [];

            if (count($payment_method_ids)) {
                $payment_methods     = $mepr_options->payment_methods(false);
                $coupon              = $coupon_code !== ''
                    ? MeprHooks::apply_filters('mepr_coupon_from_code', false, $coupon_code)
                    : false;
                $coupon_code         = $coupon !== false ? $coupon->post_title : '';
                $elements_options    = [];
                $square_verification = [];
                $paypal_sdk_options  = [];

                foreach ($payment_methods as $pm) {
                    if (!in_array($pm->id, $payment_method_ids, true)) {
                        continue;
                    }

                    // Skip payment methods that don't need transaction preparation.
                    if (
                        !($pm instanceof MeprStripeGateway && $pm->settings->stripe_checkout_enabled !== 'on') &&
                        !($pm instanceof MeprSquarePaymentsGateway) &&
                        !($pm instanceof MeprPayPalVaultingGateway)
                    ) {
                        continue;
                    }

                    try {
                        list($txn, $sub) = MeprCheckoutCtrl::prepare_transaction(
                            $prd,
                            0,
                            get_current_user_id(),
                            $pm->id,
                            $coupon,
                            false
                        );

                        if ($pm instanceof MeprStripeGateway && $pm->settings->stripe_checkout_enabled !== 'on') {
                            $elements_options[$pm->id] = $pm->get_elements_options(
                                $prd,
                                $txn,
                                $sub,
                                $coupon_code,
                                $order_bump_products
                            );
                        } elseif ($pm instanceof MeprSquarePaymentsGateway) {
                            $square_verification[$pm->id] = $pm->get_verification_details(
                                $prd,
                                $txn,
                                $sub,
                                $coupon_code,
                                $order_bump_products
                            );
                        } elseif ($pm instanceof MeprPayPalVaultingGateway) {
                            $paypal_sdk_options[$pm->id] = $pm->get_sdk_options(
                                $prd,
                                $txn,
                                $sub,
                                $coupon_code,
                                $order_bump_products
                            );
                        }
                    } catch (Exception $e) {
                        // Ignore exception.
                    }
                }

                if (count($elements_options)) {
                    $data['elements_options'] = $elements_options;
                }

                if (count($square_verification)) {
                    $data['square_verification'] = $square_verification;
                }

                if (count($paypal_sdk_options)) {
                    $data['paypal_sdk_options'] = $paypal_sdk_options;
                }
            }
        }

        wp_send_json_success($data);
    }

    /**
     * Signup form processing for asynchronous gateways (Single Page Checkout).
     */
    public function process_signup_form_ajax()
    {
        MeprHooks::do_action('mepr_process_signup_form_ajax');

        try {
            $mepr_options      = MeprOptions::fetch();
            $payment_method_id = isset($_POST['mepr_payment_method']) ? sanitize_text_field(wp_unslash($_POST['mepr_payment_method'])) : '';
            $pm                = $mepr_options->payment_method($payment_method_id);

            if (!$pm instanceof MeprBaseRealAjaxGateway || !$pm->validate_ajax_gateway()) {
                wp_send_json_error(__('Invalid payment gateway', 'memberpress'));
            }

            // Validate the form post.
            $mepr_current_url = isset($_POST['mepr_current_url']) && is_string($_POST['mepr_current_url']) ? sanitize_text_field(wp_unslash($_POST['mepr_current_url'])) : '';
            $errors           = MeprHooks::apply_filters('mepr_validate_signup', MeprUser::validate_signup($_POST, [], $mepr_current_url));

            if (!empty($errors)) {
                wp_send_json_error(['errors' => $errors]);
            }

            $product_id = intval(wp_unslash($_POST['mepr_product_id'] ?? 0));
            $prd        = new MeprProduct($product_id);

            if (empty($prd->ID)) {
                wp_send_json_error(__('Sorry, we were unable to find the product.', 'memberpress'));
            }

            // Check if the user is logged in already.
            $is_existing_user = MeprUtils::is_user_logged_in();

            if ($is_existing_user) {
                $usr = MeprUtils::get_currentuserinfo();
            } else {
                // If new user we've got to create them and sign them in.
                $usr             = new MeprUser();
                $usr->user_login = ($mepr_options->username_is_email) ? sanitize_email(wp_unslash($_POST['user_email'] ?? '')) : sanitize_user(wp_unslash($_POST['user_login'] ?? ''));
                $usr->user_email = sanitize_email(wp_unslash($_POST['user_email']));
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
                $usr->first_name = MeprUtils::sanitize_name_field(wp_unslash($_POST['user_first_name'] ?? ''));
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
                $usr->last_name = MeprUtils::sanitize_name_field(wp_unslash($_POST['user_last_name'] ?? ''));

                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
                $password = ($mepr_options->disable_checkout_password_fields === true) ? wp_generate_password() : ($_POST['mepr_user_password'] ?? '');
                // Have to use rec here because we unset user_pass on __construct.
                $usr->set_password($password);
                try {
                    $usr->store();

                    // We need to refresh the user object. In the case where emails are used as
                    // usernames, the email & username could differ after the user is saved.
                    $usr = new MeprUser($usr->ID);

                    // Log the new user in.
                    if (MeprHooks::apply_filters('mepr_auto_login', true, intval(wp_unslash($_POST['mepr_product_id'] ?? 0)), $usr)) {
                        wp_signon(
                            [
                                'user_login'    => $usr->user_login,
                                'user_password' => $password,
                            ],
                            MeprUtils::is_ssl() // May help with the users getting logged out when going between http and https.
                        );
                    }

                    MeprEvent::record('login', $usr); // Record the first login here.

                    if ($mepr_options->disable_checkout_password_fields === true) {
                        $usr->send_password_notification('new');
                    }
                } catch (MeprCreateException $e) {
                    wp_send_json_error(__('The user was unable to be saved.', 'memberpress'));
                }
            }

            // If we're showing the fields on logged in purchases, let's save them here.
            if (!$is_existing_user || ($is_existing_user && $mepr_options->show_fields_logged_in_purchases)) {
                MeprUsersCtrl::save_extra_profile_fields($usr->ID, true, $prd, true);
                $usr = new MeprUser($usr->ID); // Re-load the user object with the metadata now (helps with first name last name missing from hooks below).
            }

            // Needed for autoresponders (SPC + Stripe + Free Trial issue).
            MeprHooks::do_action('mepr_signup_user_loaded', $usr);

            $coupon_code = isset($_POST['mepr_coupon_code']) ? sanitize_text_field(wp_unslash($_POST['mepr_coupon_code'])) : '';
            $cpn         = $coupon_code !== ''
                ? MeprHooks::apply_filters('mepr_coupon_from_code', false, $coupon_code)
                : false;

            try {
                list($txn, $sub) = MeprCheckoutCtrl::prepare_transaction(
                    $prd,
                    0,
                    $usr->ID,
                    $pm->id,
                    $cpn
                );

                MeprHooks::do_action('mepr_process_signup', $txn->amount, $usr, $prd->ID, $txn->id);
                MeprHooks::do_action('mepr_signup', $txn);

                $pm->process_payment_ajax(
                    $prd,
                    $usr,
                    $txn,
                    $sub instanceof MeprSubscription ? $sub : null,
                    $cpn instanceof MeprCoupon ? $cpn : null
                );
            } catch (Exception $e) {
                wp_send_json_error($e->getMessage());
            }
        } catch (Throwable $t) {
            $this->handle_critical_error($t);
        }
    }

    /**
     * Payment form processing for asynchronous gateways (Two-Page Checkout).
     */
    public function process_payment_form_ajax()
    {
        MeprHooks::do_action('mepr_process_payment_form_ajax');

        try {
            $txn = new MeprTransaction((int) $_POST['mepr_transaction_id'] ?? 0);

            if (!$txn->id) {
                wp_send_json_error(__('Transaction not found', 'memberpress'));
            }

            $token = isset($_POST['mepr_checkout_token']) ? sanitize_text_field(wp_unslash($_POST['mepr_checkout_token'])) : '';

            if (!self::verify_checkout_access($txn, $token)) {
                wp_send_json_error(__('Invalid checkout session', 'memberpress'));
            }

            $pm = $txn->payment_method();

            if (!$pm instanceof MeprBaseRealAjaxGateway || !$pm->validate_ajax_gateway()) {
                wp_send_json_error(__('Invalid payment gateway', 'memberpress'));
            }

            $prd = $txn->product();

            if (!$prd->ID) {
                wp_send_json_error(__('Product not found', 'memberpress'));
            }

            $usr = $txn->user();

            if (!$usr->ID) {
                wp_send_json_error(__('User not found', 'memberpress'));
            }

            $sub = !$prd->is_one_time_payment() ? $txn->subscription() : null;
            $cpn = $txn->coupon();

            $pm->process_payment_ajax(
                $prd,
                $usr,
                $txn,
                $sub instanceof MeprSubscription ? $sub : null,
                $cpn instanceof MeprCoupon ? $cpn : null
            );
        } catch (Throwable $t) {
            $this->handle_critical_error($t);
        }
    }

    /**
     * Handle a critical error during checkout processing.
     *
     * @param Throwable $t The critical error that was triggered.
     */
    protected function handle_critical_error(Throwable $t)
    {
        $this->send_checkout_error_debug_email(
            $t->__toString(),
            isset($_POST['mepr_transaction_id']) && is_numeric($_POST['mepr_transaction_id']) ? (int) $_POST['mepr_transaction_id'] : null,
            isset($_POST['user_email']) && is_string($_POST['user_email']) ? sanitize_text_field(wp_unslash($_POST['user_email'])) : null
        );

        MeprUtils::debug_log(sprintf('PHP Fatal error: Uncaught %s', $t->__toString()));

        wp_send_json_error(__('An error occurred, please DO NOT submit the form again as you may be double charged. Please contact us for further assistance instead.', 'memberpress'));
    }

    /**
     * Handle the Ajax request to debug a checkout error
     */
    public function debug_checkout_error()
    {
        if (!MeprUtils::is_post_request() || !isset($_POST['data']) || !is_string($_POST['data'])) {
            wp_send_json_error();
        }

        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        $data = json_decode(wp_unslash($_POST['data']), true);

        if (!is_array($data)) {
            wp_send_json_error();
        }

        $allowed_keys = [
            'text_status'   => 'textStatus',
            'error_thrown'  => 'errorThrown',
            'status'        => 'jqXHR.status (200 expected)',
            'status_text'   => 'jqXHR.statusText (OK expected)',
            'response_text' => 'jqXHR.responseText (JSON object expected)',
        ];

        $content = 'INVALID SERVER RESPONSE' . "\n\n";

        foreach ($allowed_keys as $key => $label) {
            if (!array_key_exists($key, $data)) {
                continue;
            }

            ob_start();
            var_dump($data[$key]); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_dump
            $value = ob_get_clean();

            $content .= sprintf(
                "%s:\n%s\n",
                $label,
                $value
            );
        }

        $transaction_id = isset($data['transaction_id']) && is_numeric($data['transaction_id']) ? (int) $data['transaction_id'] : null;
        $customer_email = isset($data['customer_email']) && is_string($data['customer_email']) ? sanitize_text_field($data['customer_email']) : null;
        $checkout_token = isset($data['checkout_token']) && is_string($data['checkout_token']) ? sanitize_text_field($data['checkout_token']) : '';

        // This endpoint is unauthenticated. The customer email is the caller's
        // own form input, but the transaction ID must not be trusted blindly:
        // it drives a server-side lookup that would otherwise pull a real
        // customer's email into the admin notification. Only keep the
        // transaction ID when the request proves it owns the checkout, so an
        // attacker cannot probe sequential IDs. A verified transaction still
        // fills in the email server-side when the caller didn't supply one.
        if ($transaction_id !== null && !self::verify_checkout_access(new MeprTransaction($transaction_id), $checkout_token)) {
            $transaction_id = null;
        }

        $this->send_checkout_error_debug_email($content, $transaction_id, $customer_email);

        wp_send_json_success();
    }

    /**
     * Sends an email to the admin email addresses alerting them of the given checkout error.
     *
     * @param string       $content        The email content.
     * @param integer|null $transaction_id The transaction ID.
     * @param string|null  $customer_email The customer's email address.
     */
    protected function send_checkout_error_debug_email($content, $transaction_id = null, $customer_email = null)
    {
        if (MeprHooks::apply_filters('mepr_disable_checkout_error_debug_email', false)) {
            return;
        }

        $message  = 'An error occurred during the MemberPress checkout which resulted in an error message being displayed to the customer. The transaction may not have fully completed.' . "\n\n";
        $message .= 'The error may have happened due to a plugin conflict or custom code, please carefully check the details below to identify the cause, or you can send this email to support@memberpress.com for help.' . "\n\n";

        if ($transaction_id) {
            $message .= sprintf("MemberPress transaction ID: %s\n", $transaction_id);

            if (!$customer_email) {
                $transaction = new MeprTransaction($transaction_id);
                $user        = $transaction->user();

                if ($user->ID > 0 && $user->user_email) {
                    $customer_email = $user->user_email;
                }
            }
        }

        if ($customer_email) {
            $message .= sprintf("Customer email: %s\n", $customer_email);
        }

        $message .= sprintf("Customer IP: %s\n", sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')));
        $message .= sprintf("Customer User Agent: %s\n", !empty($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '(empty)');
        $message .= sprintf("Date (UTC): %s\n\n", gmdate('Y-m-d H:i:s'));

        MeprUtils::wp_mail_to_admin('[MemberPress] IMPORTANT: Checkout error', $message . $content);
    }
}
