<?php
/**
 * ConvesioPay accelerated checkout route handler.
 *
 * @package PeachPay
 */

if ( ! defined( 'PEACHPAY_ABSPATH' ) ) {
	exit;
}

/**
 * Handles accelerated checkout order creation and payment processing.
 */
class PeachPay_ConvesioPay_Accelerated_Checkout {
	/**
	 * Route namespace.
	 *
	 * @var string
	 */
	const ROUTE_NAMESPACE = 'peachpay/v1';

	/**
	 * Create-order route.
	 *
	 * @var string
	 */
	const CREATE_ORDER_ROUTE = '/convesiopay/accelerated-checkout/create-order';

	/**
	 * Recalculate route.
	 *
	 * @var string
	 */
	const RECALCULATE_ROUTE = '/convesiopay/accelerated-checkout/recalculate';

	/**
	 * Lock transient prefix.
	 *
	 * @var string
	 */
	const LOCK_PREFIX = 'pp_cp_accel_lock_';

	/**
	 * Result transient prefix.
	 *
	 * @var string
	 */
	const RESULT_PREFIX = 'pp_cp_accel_result_';

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	/**
	 * Register REST routes.
	 */
	public function register_routes() {
		register_rest_route(
			self::ROUTE_NAMESPACE,
			self::CREATE_ORDER_ROUTE,
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'create_order' ),
				'permission_callback' => '__return_true',
			)
		);

		register_rest_route(
			self::ROUTE_NAMESPACE,
			self::RECALCULATE_ROUTE,
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'recalculate_totals' ),
				'permission_callback' => '__return_true',
			)
		);
	}

	/**
	 * Recalculate cart totals for accelerated Apple Pay updates.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response
	 */
	public function recalculate_totals( $request ) {
		$payload = $request->get_json_params();
		if ( ! is_array( $payload ) ) {
			$payload = array();
		}

		$nonce = $request->get_header( 'X-WP-Nonce' );
		if ( empty( $nonce ) && isset( $payload['nonce'] ) ) {
			$nonce = sanitize_text_field( (string) $payload['nonce'] );
		}
		$nonce_valid = false;
		if ( ! empty( $nonce ) ) {
			$nonce_valid = wp_verify_nonce( $nonce, 'wp_rest' ) || wp_verify_nonce( $nonce, 'peachpay_convesiopay_nonce' );
		}
		if ( ! $nonce_valid ) {
			return $this->error_response( 'invalid_nonce', __( 'Security validation failed.', 'peachpay-for-woocommerce' ), 403 );
		}

		// REST requests may not have cart/customer/session bootstrapped yet.
		if ( function_exists( 'wc_load_cart' ) ) {
			wc_load_cart();
		}

		if ( ! function_exists( 'WC' ) || ! WC()->cart || ! WC()->customer || ! WC()->session ) {
			return $this->error_response( 'wc_unavailable', __( 'WooCommerce cart is unavailable.', 'peachpay-for-woocommerce' ), 503 );
		}

		// Parse page context and product fields early so we can inject the product
		// before the empty-cart guard (product pages don't require an existing cart).
		$recalc_page_context = isset( $payload['page_context'] ) ? sanitize_key( (string) $payload['page_context'] ) : '';
		$recalc_product_id   = isset( $payload['product_id'] ) ? (int) $payload['product_id'] : 0;
		$recalc_quantity     = isset( $payload['quantity'] ) ? max( 1, (int) $payload['quantity'] ) : 1;
		$recalc_variation_id = isset( $payload['variation_id'] ) ? (int) $payload['variation_id'] : 0;
		$recalc_variation    = isset( $payload['variation'] ) && is_array( $payload['variation'] )
			? array_map( 'sanitize_text_field', $payload['variation'] )
			: array();

		// Parse all request fields that affect totals BEFORE touching the cart.
		// This allows the cache check to happen before any cart injection, so a
		// cache hit can return immediately — no inject, no restore, no side effects.
		$shipping_address = array();
		if ( isset( $payload['shipping_address'] ) && is_array( $payload['shipping_address'] ) ) {
			$shipping_address = $payload['shipping_address'];
		}
		$shipping_country  = isset( $shipping_address['country'] ) ? $this->normalize_country_code( sanitize_text_field( (string) $shipping_address['country'] ) ) : '';
		$shipping_state    = isset( $shipping_address['state'] ) ? $this->normalize_state_code( $shipping_country, sanitize_text_field( (string) $shipping_address['state'] ) ) : '';
		$shipping_postcode = '';
		if ( isset( $shipping_address['postcode'] ) && '' !== (string) $shipping_address['postcode'] ) {
			$shipping_postcode = sanitize_text_field( (string) $shipping_address['postcode'] );
		} elseif ( isset( $shipping_address['postal_code'] ) && '' !== (string) $shipping_address['postal_code'] ) {
			$shipping_postcode = sanitize_text_field( (string) $shipping_address['postal_code'] );
		}
		$shipping_city    = isset( $shipping_address['city'] ) ? sanitize_text_field( (string) $shipping_address['city'] ) : '';
		$shipping_line1   = isset( $shipping_address['line1'] ) ? sanitize_text_field( (string) $shipping_address['line1'] ) : '';
		$shipping_line2   = isset( $shipping_address['line2'] ) ? sanitize_text_field( (string) $shipping_address['line2'] ) : '';

		$shipping_method = null;
		if ( array_key_exists( 'shipping_method', $payload ) && null !== $payload['shipping_method'] ) {
			$shipping_method = sanitize_text_field( (string) $payload['shipping_method'] );
		}
		$shipping_amount = null;
		if ( array_key_exists( 'shipping_amount', $payload ) && null !== $payload['shipping_amount'] && '' !== (string) $payload['shipping_amount'] ) {
			$shipping_amount = (float) $payload['shipping_amount'];
		}
		$wallet_type = isset( $payload['wallet_type'] ) ? sanitize_key( (string) $payload['wallet_type'] ) : '';
		if ( function_exists( 'peachpay_log' ) ) {
			peachpay_log(
				'debug',
				'ConvesioPay accelerated checkout debug.',
				array(
					'event'     => 'accelerated_checkout.recalculate.start',
					'component' => 'payments',
					'feature'   => 'convesiopay_accelerated_checkout',
					'operation' => 'recalculate',
					'outcome'   => 'debug',
					'method'    => __METHOD__,
					'line'      => __LINE__,
					'data'      => array(
						'details' => 'Recalculate request received.',
						'context' => array(
							'shipping_method'  => $shipping_method,
							'shipping_amount'  => $shipping_amount,
							'shipping_country' => $shipping_country,
							'shipping_state'   => $shipping_state,
							'wallet_type'      => $wallet_type,
						),
					),
				)
			);
		}

		// Build a deterministic cache key from the request inputs BEFORE any cart
		// injection.  A cache hit here means we can return immediately without
		// injecting the product at all — the user's real cart is never touched and
		// no restore step is needed.
		//
		// The cart hash captures the real (pre-injection) cart contents so that
		// cart-page and checkout-page contexts (where the actual cart drives totals)
		// produce correct cache buckets.  For product-page context the explicit
		// product_id / variation_id / quantity fields make the key unique regardless
		// of what is in the real cart.
		$recalc_signature = md5( wp_json_encode( array(
			'country'      => $shipping_country,
			'state'        => $shipping_state,
			'postcode'     => $shipping_postcode,
			'city'         => $shipping_city,
			'line1'        => $shipping_line1,
			'line2'        => $shipping_line2,
			'method'       => $shipping_method,
			'wallet'       => $wallet_type,
			'product_id'   => $recalc_product_id,
			'variation_id' => $recalc_variation_id,
			'quantity'     => 'product' === $recalc_page_context ? $recalc_quantity : 0,
			'cart'         => md5( wp_json_encode( WC()->cart->get_cart() ) ),
		) ) );
		$recalc_cache_key = 'pp_cp_recalc_' . $recalc_signature;
		$cached_response  = get_transient( $recalc_cache_key );
		if ( is_array( $cached_response ) && isset( $cached_response['success'] ) ) {
			// No injection has happened yet so the user's cart is untouched.
			return new WP_REST_Response( $cached_response, 200 );
		}

		// Product page: validate and inject the product into the cart — matching
		// Stripe's express-checkout behaviour exactly.  empty_cart() + add_to_cart()
		// replaces whatever was in the cart; no snapshot/restore is performed.
		if ( 'product' === $recalc_page_context ) {
			$this->ensure_wc_product_registry();

			$product_validation = $this->validate_product_for_accelerated_checkout(
				$recalc_product_id,
				$recalc_variation_id,
				$recalc_variation,
				$recalc_page_context
			);
			if ( is_wp_error( $product_validation ) ) {
				return $this->error_response(
					$product_validation->get_error_code(),
					$product_validation->get_error_message(),
					400
				);
			}

			try {
				$this->inject_product_into_cart(
					$recalc_product_id,
					$recalc_quantity,
					$recalc_variation_id,
					$recalc_variation
				);
			} catch ( Throwable $inject_e ) {
				return $this->error_response(
					'product_inject_failed',
					$inject_e->getMessage(),
					400
				);
			}
		} else {
			// Non-product-page: cart must already have items.
			if ( WC()->cart->is_empty() ) {
				return $this->error_response( 'empty_cart', __( 'Cart is empty.', 'peachpay-for-woocommerce' ), 400 );
			}
		}

		$customer_snapshot = $this->snapshot_customer_state();
		$session_snapshot  = $this->snapshot_shipping_session_state();

		try {
			// Force the payment method to ConvesioPay before recalculating so that other
			// gateways (e.g. Stripe) do not add card-specific fees to the cart total.
			// Accelerated checkout is always Apple Pay or Google Pay — card fees must not
			// appear in the totals shown to the customer in the wallet sheet.
			WC()->session->set( 'chosen_payment_method', 'peachpay_convesiopay_unified' );

			// Also set the ConvesioPay sub-method so the correct wallet fee (Apple Pay /
			// Google Pay) is used — not the card fee which is the default fallback.
			if ( in_array( $wallet_type, array( 'applepay', 'googlepay' ), true ) ) {
				WC()->session->set( 'convesiopay_selected_method', $wallet_type );
			}

			if ( '' !== $shipping_country ) {
				WC()->customer->set_shipping_country( $shipping_country );
				WC()->customer->set_billing_country( $shipping_country );
			}
			if ( '' !== $shipping_state ) {
				WC()->customer->set_shipping_state( $shipping_state );
				WC()->customer->set_billing_state( $shipping_state );
			}
			if ( '' !== $shipping_postcode ) {
				WC()->customer->set_shipping_postcode( $shipping_postcode );
				WC()->customer->set_billing_postcode( $shipping_postcode );
			}
			if ( '' !== $shipping_city ) {
				WC()->customer->set_shipping_city( $shipping_city );
				WC()->customer->set_billing_city( $shipping_city );
			}
			if ( '' !== $shipping_line1 ) {
				WC()->customer->set_shipping_address_1( $shipping_line1 );
				WC()->customer->set_billing_address_1( $shipping_line1 );
			}
			if ( '' !== $shipping_line2 ) {
				WC()->customer->set_shipping_address_2( $shipping_line2 );
				WC()->customer->set_billing_address_2( $shipping_line2 );
			}
			WC()->customer->set_calculated_shipping( false );
			WC()->customer->save();

			// Clear WooCommerce's internal shipping cache so that a new address gets
			// fresh rates instead of the stale cached packages from the previous address.
			// Without this, calculate_shipping() below returns the old rates even after
			// the customer address has been updated on the WC_Customer object.
			if ( WC()->shipping() && method_exists( WC()->shipping(), 'reset_shipping' ) ) {
				WC()->shipping()->reset_shipping();
			}

			WC()->cart->calculate_shipping();
			$available_rates = $this->extract_shipping_rates_for_recalculate();
			if ( WC()->cart->needs_shipping() && empty( $available_rates ) ) {
				if ( function_exists( 'peachpay_log' ) ) {
					peachpay_log(
						'debug',
						'ConvesioPay accelerated checkout debug.',
						array(
							'event'     => 'accelerated_checkout.recalculate.no_rates',
							'component' => 'payments',
							'feature'   => 'convesiopay_accelerated_checkout',
							'operation' => 'recalculate',
							'outcome'   => 'debug',
							'method'    => __METHOD__,
							'line'      => __LINE__,
							'data'      => array(
								'details' => 'No shipping methods available for selected address.',
								'context' => array(
									'shipping_country' => $shipping_country,
									'shipping_state'   => $shipping_state,
								),
							),
						)
					);
				}
				return $this->error_response(
					'unsupported_shipping_address',
					__( 'No shipping methods are available for the selected address.', 'peachpay-for-woocommerce' ),
					400
				);
			}

			$available_rate_ids = array();
			foreach ( $available_rates as $rate ) {
				$available_rate_ids[] = isset( $rate['identifier'] ) ? sanitize_text_field( (string) $rate['identifier'] ) : '';
			}
			$available_rate_ids = array_filter( $available_rate_ids );

			$existing_chosen = '';
			$current_chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() );
			if ( is_array( $current_chosen_methods ) && ! empty( $current_chosen_methods[0] ) ) {
				$existing_chosen = sanitize_text_field( (string) $current_chosen_methods[0] );
			}

			$resolved_shipping_method = '';
			$preferred_method = '';
			if ( null !== $shipping_method && '' !== $shipping_method ) {
				$preferred_method = $shipping_method;
			} elseif ( '' !== $existing_chosen ) {
				$preferred_method = $existing_chosen;
			}

			// 1) Keep same identifier when possible.
			if ( '' !== $preferred_method && in_array( $preferred_method, $available_rate_ids, true ) ) {
				$resolved_shipping_method = $preferred_method;
			}

			// 2) If previous identifier is gone, match by shipping amount when provided.
			if ( '' === $resolved_shipping_method && null !== $shipping_amount ) {
				foreach ( $available_rates as $rate ) {
					$rate_amount = isset( $rate['amount'] ) ? (float) $rate['amount'] : 0.0;
					if ( abs( $rate_amount - $shipping_amount ) < 0.00001 ) {
						$resolved_shipping_method = isset( $rate['identifier'] ) ? sanitize_text_field( (string) $rate['identifier'] ) : '';
						break;
					}
				}
			}

			// 3) Final fallback: choose first available method.
			if ( '' === $resolved_shipping_method && ! empty( $available_rate_ids ) ) {
				$resolved_shipping_method = (string) $available_rate_ids[0];
			}

			if ( '' !== $resolved_shipping_method ) {
				WC()->session->set( 'chosen_shipping_methods', array( $resolved_shipping_method ) );
			}

			WC()->cart->calculate_shipping();
			WC()->cart->calculate_totals();

			$totals = $this->build_recalculate_totals_payload();
			$totals['shipping_methods'] = $available_rates;
			$totals['selected_shipping_method'] = '';
			$chosen = WC()->session->get( 'chosen_shipping_methods', array() );
			if ( is_array( $chosen ) && ! empty( $chosen[0] ) ) {
				$totals['selected_shipping_method'] = sanitize_text_field( (string) $chosen[0] );
			}
			if ( function_exists( 'peachpay_log' ) ) {
				peachpay_log(
					'debug',
					'ConvesioPay accelerated checkout debug.',
					array(
						'event'     => 'accelerated_checkout.recalculate.success',
						'component' => 'payments',
						'feature'   => 'convesiopay_accelerated_checkout',
						'operation' => 'recalculate',
						'outcome'   => 'debug',
						'method'    => __METHOD__,
						'line'      => __LINE__,
						'data'      => array(
							'details' => 'Recalculate request completed.',
							'context' => array(
								'subtotal'                 => $totals['subtotal'] ?? '',
								'shipping'                 => $totals['shipping'] ?? '',
								'applepay_fee'             => $totals['applepay_fee'] ?? '',
								'tax'                      => $totals['tax'] ?? '',
								'total'                    => $totals['total'] ?? '',
								'selected_shipping_method' => $totals['selected_shipping_method'] ?? '',
							),
						),
					)
				);
			}

			// Cache for 30 seconds so rapid identical calls (e.g. address change +
			// immediate re-render) get a free hit instead of a full recalculation.
			set_transient( $recalc_cache_key, $totals, 30 );

			return new WP_REST_Response( $totals, 200 );
		} catch ( Throwable $e ) {
			if ( function_exists( 'peachpay_log' ) ) {
				peachpay_log(
					'error',
					'ConvesioPay accelerated checkout error.',
					array(
						'event'     => 'accelerated_checkout.recalculate.failed',
						'component' => 'payments',
						'feature'   => 'convesiopay_accelerated_checkout',
						'operation' => 'recalculate',
						'outcome'   => 'failed',
						'method'    => __METHOD__,
						'line'      => __LINE__,
						'data'      => array(
							'details' => $e->getMessage(),
							'context' => array(
								'shipping_method' => $shipping_method,
							),
						),
					)
				);
			}

			return $this->error_response(
				'recalculate_failed',
				__( 'Unable to recalculate accelerated totals.', 'peachpay-for-woocommerce' ),
				500
			);
		} finally {
			// Always restore the customer address and shipping session state so that
			// the temporary values set during recalculation do not persist to the
			// user's next page load.  Cart contents are intentionally not restored —
			// the product-page injection is kept (Stripe parity).
			$this->restore_customer_state( $customer_snapshot );
			$this->restore_shipping_session_state( $session_snapshot );
		}
	}

	/**
	 * Create an accelerated checkout order and process payment.
	 *
	 * @param WP_REST_Request $request Incoming request.
	 * @return WP_REST_Response
	 */
	public function create_order( $request ) {
		$payload = $request->get_json_params();
		if ( ! is_array( $payload ) ) {
			$payload = array();
		}

		$nonce = $request->get_header( 'X-WP-Nonce' );
		if ( empty( $nonce ) && isset( $payload['nonce'] ) ) {
			$nonce = sanitize_text_field( (string) $payload['nonce'] );
		}

		$nonce_valid = false;
		if ( ! empty( $nonce ) ) {
			$nonce_valid = wp_verify_nonce( $nonce, 'wp_rest' ) || wp_verify_nonce( $nonce, 'peachpay_convesiopay_nonce' );
		}

		if ( ! $nonce_valid ) {
			return $this->error_response( 'invalid_nonce', __( 'Security validation failed.', 'peachpay-for-woocommerce' ), 403 );
		}

		// Apply the shipping method the customer selected in the Apple Pay/Google Pay sheet.
		// The recalculate endpoint restores the session after each preview call, so the session
		// still holds the pre-wallet default. We must apply the chosen method here so that:
		//   (a) the amount mismatch check compares against the correct cart total, and
		//   (b) the order is created with the shipping method the customer actually chose.
		if ( function_exists( 'wc_load_cart' ) ) {
			wc_load_cart();
		}
		$selected_shipping_method = isset( $payload['selected_shipping_method'] )
			? sanitize_text_field( (string) $payload['selected_shipping_method'] )
			: '';
		$order_wallet_type = isset( $payload['wallet_type'] ) ? sanitize_key( (string) $payload['wallet_type'] ) : '';

		// Snapshot the current session state so we can restore it if the order fails.
		// Without this, a failed order creation would leave chosen_payment_method and
		// convesiopay_selected_method stuck pointing at the wallet, which could then
		// bleed the wallet fee into a subsequent normal-checkout session for the same user.
		$order_session_snapshot = function_exists( 'WC' ) && WC()->session
			? $this->snapshot_shipping_session_state()
			: null;

		if ( function_exists( 'WC' ) && WC()->session && WC()->cart ) {
			// Accelerated checkout is its own checkout context. Ignore normal
			// checkout-form selections and build the cart state only from the
			// wallet payload for this request.
			WC()->session->set( 'chosen_payment_method', 'peachpay_convesiopay_unified' );

			if ( in_array( $order_wallet_type, array( 'applepay', 'googlepay' ), true ) ) {
				WC()->session->set( 'convesiopay_selected_method', $order_wallet_type );
			}

			$payload_shipping_addr = isset( $payload['shipping_address'] ) && is_array( $payload['shipping_address'] )
				? $payload['shipping_address']
				: array();
			$payload_billing_addr  = isset( $payload['billing_address'] ) && is_array( $payload['billing_address'] )
				? $payload['billing_address']
				: $payload_shipping_addr;

			$order_shipping_country  = isset( $payload_shipping_addr['country'] )
				? $this->normalize_country_code( sanitize_text_field( (string) $payload_shipping_addr['country'] ) )
				: '';
			$order_shipping_state    = isset( $payload_shipping_addr['state'] )
				? $this->normalize_state_code( $order_shipping_country, sanitize_text_field( (string) $payload_shipping_addr['state'] ) )
				: '';
			$order_shipping_postcode = isset( $payload_shipping_addr['postal_code'] )
				? sanitize_text_field( (string) $payload_shipping_addr['postal_code'] )
				: '';
			if ( '' === $order_shipping_postcode && isset( $payload_shipping_addr['postcode'] ) ) {
				$order_shipping_postcode = sanitize_text_field( (string) $payload_shipping_addr['postcode'] );
			}
			$order_shipping_city  = isset( $payload_shipping_addr['city'] ) ? sanitize_text_field( (string) $payload_shipping_addr['city'] ) : '';
			$order_shipping_line1 = isset( $payload_shipping_addr['line1'] ) ? sanitize_text_field( (string) $payload_shipping_addr['line1'] ) : '';
			$order_shipping_line2 = isset( $payload_shipping_addr['line2'] ) ? sanitize_text_field( (string) $payload_shipping_addr['line2'] ) : '';

			$order_billing_country  = isset( $payload_billing_addr['country'] )
				? $this->normalize_country_code( sanitize_text_field( (string) $payload_billing_addr['country'] ) )
				: '';
			$order_billing_state    = isset( $payload_billing_addr['state'] )
				? $this->normalize_state_code( $order_billing_country, sanitize_text_field( (string) $payload_billing_addr['state'] ) )
				: '';
			$order_billing_postcode = isset( $payload_billing_addr['postal_code'] )
				? sanitize_text_field( (string) $payload_billing_addr['postal_code'] )
				: '';
			if ( '' === $order_billing_postcode && isset( $payload_billing_addr['postcode'] ) ) {
				$order_billing_postcode = sanitize_text_field( (string) $payload_billing_addr['postcode'] );
			}
			$order_billing_city  = isset( $payload_billing_addr['city'] ) ? sanitize_text_field( (string) $payload_billing_addr['city'] ) : '';
			$order_billing_line1 = isset( $payload_billing_addr['line1'] ) ? sanitize_text_field( (string) $payload_billing_addr['line1'] ) : '';
			$order_billing_line2 = isset( $payload_billing_addr['line2'] ) ? sanitize_text_field( (string) $payload_billing_addr['line2'] ) : '';

			if ( WC()->customer ) {
				if ( '' !== $order_shipping_country ) {
					WC()->customer->set_shipping_country( $order_shipping_country );
				}
				if ( '' !== $order_shipping_state ) {
					WC()->customer->set_shipping_state( $order_shipping_state );
				}
				if ( '' !== $order_shipping_postcode ) {
					WC()->customer->set_shipping_postcode( $order_shipping_postcode );
				}
				if ( '' !== $order_shipping_city ) {
					WC()->customer->set_shipping_city( $order_shipping_city );
				}
				if ( '' !== $order_shipping_line1 ) {
					WC()->customer->set_shipping_address_1( $order_shipping_line1 );
				}
				if ( '' !== $order_shipping_line2 ) {
					WC()->customer->set_shipping_address_2( $order_shipping_line2 );
				}

				if ( '' !== $order_billing_country ) {
					WC()->customer->set_billing_country( $order_billing_country );
				}
				if ( '' !== $order_billing_state ) {
					WC()->customer->set_billing_state( $order_billing_state );
				}
				if ( '' !== $order_billing_postcode ) {
					WC()->customer->set_billing_postcode( $order_billing_postcode );
				}
				if ( '' !== $order_billing_city ) {
					WC()->customer->set_billing_city( $order_billing_city );
				}
				if ( '' !== $order_billing_line1 ) {
					WC()->customer->set_billing_address_1( $order_billing_line1 );
				}
				if ( '' !== $order_billing_line2 ) {
					WC()->customer->set_billing_address_2( $order_billing_line2 );
				}
				WC()->customer->save();
			}

			// Product page: inject the product BEFORE shipping validation so that
			// WC()->cart->needs_shipping() and the available package rates reflect
			// the item the customer is actually purchasing.  If we validated first,
			// the old cart (often empty or all-virtual) would make needs_shipping()
			// return false, silently skipping the missing/invalid-method checks and
			// allowing an order to be created with no shipping rate or with a rate
			// that does not exist in the computed packages for the injected product.
			$order_page_context = isset( $payload['page_context'] ) ? sanitize_key( (string) $payload['page_context'] ) : '';
			if ( 'product' === $order_page_context ) {
				$this->ensure_wc_product_registry();
				$order_product_id   = isset( $payload['product_id'] ) ? (int) $payload['product_id'] : 0;
				$order_quantity     = isset( $payload['quantity'] ) ? max( 1, (int) $payload['quantity'] ) : 1;
				$order_variation_id = isset( $payload['variation_id'] ) ? (int) $payload['variation_id'] : 0;
				$order_variation    = isset( $payload['variation'] ) && is_array( $payload['variation'] )
					? array_map( 'sanitize_text_field', $payload['variation'] )
					: array();

				$product_validation = $this->validate_product_for_accelerated_checkout(
					$order_product_id,
					$order_variation_id,
					$order_variation,
					$order_page_context
				);
				if ( is_wp_error( $product_validation ) ) {
					if ( null !== $order_session_snapshot ) {
						$this->restore_shipping_session_state( $order_session_snapshot );
					}
					return $this->error_response(
						$product_validation->get_error_code(),
						$product_validation->get_error_message(),
						400
					);
				}

				try {
					$this->inject_product_into_cart(
						$order_product_id,
						$order_quantity,
						$order_variation_id,
						$order_variation
					);
				} catch ( Throwable $inject_order_e ) {
					if ( null !== $order_session_snapshot ) {
						$this->restore_shipping_session_state( $order_session_snapshot );
					}
					return $this->error_response(
						'product_inject_failed',
						$inject_order_e->getMessage(),
						400
					);
				}
			}

			// Reset shipping after any cart mutation (product injection above for
			// product page, or existing cart for cart/checkout) so package rates
			// are computed fresh for the correct customer address set above.
			if ( WC()->shipping() && method_exists( WC()->shipping(), 'reset_shipping' ) ) {
				WC()->shipping()->reset_shipping();
			}

			$available_rate_ids = array();

			if ( WC()->cart->needs_shipping() ) {
				if ( '' === $selected_shipping_method ) {
					if ( null !== $order_session_snapshot ) {
						$this->restore_shipping_session_state( $order_session_snapshot );
					}
					return $this->error_response(
						'missing_shipping_method',
						__( 'A shipping method is required for this order.', 'peachpay-for-woocommerce' ),
						400
					);
				}

				// Mirror the recalculate flow so the cart's shipping totals are
				// computed by WooCommerce itself, not by a side-path helper call.
				WC()->cart->calculate_shipping();
				$available_rates = $this->extract_shipping_rates_for_recalculate();
				foreach ( $available_rates as $rate ) {
					$available_rate_ids[] = isset( $rate['identifier'] ) ? sanitize_text_field( (string) $rate['identifier'] ) : '';
				}
				$available_rate_ids = array_filter( $available_rate_ids );

				if ( ! in_array( $selected_shipping_method, $available_rate_ids, true ) ) {
					if ( null !== $order_session_snapshot ) {
						$this->restore_shipping_session_state( $order_session_snapshot );
					}
					if ( function_exists( 'peachpay_log' ) ) {
						peachpay_log(
							'warning',
							'ConvesioPay accelerated checkout: wallet-selected shipping method not available for address.',
							array(
								'event'     => 'accelerated_checkout.create_order.shipping_method_unavailable',
								'component' => 'payments',
								'feature'   => 'convesiopay_accelerated_checkout',
								'operation' => 'create_order',
								'outcome'   => 'error',
								'method'    => __METHOD__,
								'line'      => __LINE__,
								'data'      => array(
									'details' => 'Wallet-selected shipping method not found in computed package rates.',
									'context' => array(
										'requested' => $selected_shipping_method,
										'available' => $available_rate_ids,
										'country'   => $order_shipping_country,
										'state'     => $order_shipping_state,
										'city'      => $order_shipping_city,
									),
								),
							)
						);
					}
					return $this->error_response(
						'invalid_shipping_method',
						__( 'The selected shipping method is not available for this address. Please try again.', 'peachpay-for-woocommerce' ),
						400
					);
				}

				WC()->session->set( 'chosen_shipping_methods', array( $selected_shipping_method ) );
				WC()->cart->calculate_shipping();
			}

			WC()->cart->calculate_totals();

			if ( '' !== $selected_shipping_method && WC()->cart->needs_shipping() ) {
				WC()->session->set( 'chosen_shipping_methods', array( $selected_shipping_method ) );
			}
		}

		$validation = $this->validate_payload( $payload );
		if ( is_wp_error( $validation ) ) {
			if ( null !== $order_session_snapshot ) {
				$this->restore_shipping_session_state( $order_session_snapshot );
			}
			return $this->error_response(
				$validation->get_error_code(),
				$validation->get_error_message(),
				400
			);
		}

		$idempotency_key  = sanitize_text_field( (string) $payload['idempotency_key'] );
		$idempotency_hash = md5( $idempotency_key );
		$result_key       = self::RESULT_PREFIX . $idempotency_hash;
		$lock_key         = self::LOCK_PREFIX . $idempotency_hash;

		$cached = get_transient( $result_key );
		if ( is_array( $cached ) && isset( $cached['success'] ) ) {
			if ( null !== $order_session_snapshot ) {
				$this->restore_shipping_session_state( $order_session_snapshot );
			}
			return new WP_REST_Response( $cached, 200 );
		}

		if ( get_transient( $lock_key ) ) {
			if ( null !== $order_session_snapshot ) {
				$this->restore_shipping_session_state( $order_session_snapshot );
			}
			return $this->error_response(
				'request_in_progress',
				__( 'A matching payment request is already being processed.', 'peachpay-for-woocommerce' ),
				409
			);
		}

		set_transient( $lock_key, 1, 120 );

		try {
			$existing_order = $this->find_existing_order_by_idempotency_key( $idempotency_key );
			if ( $existing_order instanceof WC_Order ) {
				$response = $this->success_payload_from_order( $existing_order );
				set_transient( $result_key, $response, 15 * MINUTE_IN_SECONDS );
				delete_transient( $lock_key );
				return new WP_REST_Response( $response, 200 );
			}

			$order = $this->create_order_from_checkout_context( $payload );
			if ( ! $order instanceof WC_Order ) {
				throw new Exception( __( 'Failed to create order.', 'peachpay-for-woocommerce' ) );
			}

			$order->update_meta_data( '_peachpay_accelerated_idempotency_key', $idempotency_key );
			$order->update_meta_data( '_peachpay_accelerated_wallet_type', sanitize_text_field( (string) $payload['wallet_type'] ) );
			$order->update_meta_data( '_peachpay_accelerated_page_context', sanitize_text_field( (string) $payload['page_context'] ) );
			$order->save();

			$order_id = $order->get_id();


			// ── Route ALL wallet payments through the unified gateway ─────────────────
			//
			// The order was created with payment_method = 'peachpay_convesiopay_unified'
			// (set in create_order_from_checkout_context).  We leave it there and call
			// the unified gateway's process_payment() directly.  Inside, the unified
			// gateway:
			//   1. Reads payment_method_type from $_POST to detect the wallet
			//   2. Routes to redirect_to_applepay_gateway() / redirect_to_googlepay_gateway()
			//      (or any future wallet) which changes the ORDER's payment method and
			//      calls the appropriate individual gateway
			//
			// Why this matters for subscriptions:
			//   WC Subscriptions creates the subscription during create_order() and
			//   records its payment_method from $checkout_data['payment_method'], which
			//   is 'peachpay_convesiopay_unified'.  The unified gateway declares full
			//   WC Subscriptions support and has a renewal handler — so renewals fire
			//   correctly even though the parent ORDER ends up on the individual gateway
			//   after process_payment() finishes.
			//
			// Why future wallets get for free:
			//   Adding Google Pay (or any other wallet) only requires implementing a new
			//   individual gateway + redirect_to_googlepay_gateway() inside the unified
			//   gateway.  This route handler never needs to change.

			$wallet_type = sanitize_key( (string) $payload['wallet_type'] );

			// Validate the payment token is present and well-formed.
			if ( ! isset( $payload['convesiopay_payment_token'] ) || empty( $payload['convesiopay_payment_token'] ) ) {
				throw new Exception( __( 'Payment token is missing. Please try again.', 'peachpay-for-woocommerce' ) );
			}
			$token_to_pass = (string) $payload['convesiopay_payment_token'];
			if ( strpos( $token_to_pass, ' ' ) !== false || strlen( trim( $token_to_pass ) ) < 20 ) {
				throw new Exception( __( 'Invalid payment token format. Please try again.', 'peachpay-for-woocommerce' ) );
			}

			// Get the unified gateway — it owns all wallet routing logic.
			$all_gateways     = WC()->payment_gateways->payment_gateways();
			$unified_gateway  = $all_gateways['peachpay_convesiopay_unified'] ?? null;
			if ( ! $unified_gateway ) {
				throw new Exception( 'ConvesioPay unified gateway not available' );
			}

			// Build $_POST so unified gateway's extract_unified_payment_data() and
			// detect_payment_method() can read everything they need.
			$_POST['payment_method']               = 'peachpay_convesiopay_unified';
			$_POST['payment_method_type']          = $wallet_type; // 'applepay', 'googlepay', …
			$_POST['convesiopay_checkout_context'] = 'accelerated';
			$_POST['peachpay_transaction_id'] = $_POST['peachpay_transaction_id'] ?? $this->get_transaction_id_from_request();
			$_POST['convesiopay_payment_token'] = $token_to_pass;

			// Legacy / optional fields — pass through if present.
			if ( isset( $payload['convesiopay_payment_id'] ) ) {
				$_POST['convesiopay_payment_id'] = sanitize_text_field( (string) $payload['convesiopay_payment_id'] );
			} elseif ( isset( $payload['paymentId'] ) ) {
				$_POST['convesiopay_payment_id'] = sanitize_text_field( (string) $payload['paymentId'] );
			}
			if ( isset( $payload['convesiopay_status'] ) ) {
				$_POST['convesiopay_status'] = sanitize_text_field( (string) $payload['convesiopay_status'] );
			} elseif ( isset( $payload['status'] ) ) {
				$_POST['convesiopay_status'] = sanitize_text_field( (string) $payload['status'] );
			}

			// Merge into payment_method_data for block-checkout compatibility.
			$existing_pmd = array();
			if ( isset( $_POST['payment_method_data'] ) ) {
				$raw = $_POST['payment_method_data'];
				$existing_pmd = is_string( $raw ) ? (array) json_decode( stripslashes( $raw ), true ) : (array) $raw;
			}
			// Wallet-specific amount fields.  The individual gateways read these from
			// $_POST for logging and frontend-vs-backend amount validation.
			// extract_unified_payment_data() tries to populate $payment_data['amount']
			// from $_POST['googlepay_amount'] / $_POST['applepay_amount'] BEFORE
			// redirect_to_*_gateway() is called, so they must be present here.
			// Amount is in minor units (cents) as a string, matching what the JS
			// sends for normal (non-accelerated) Google Pay / Apple Pay flows.
			$order_amount_minor = (string) round( $order->get_total() * 100 );
			$order_currency     = get_woocommerce_currency();
			if ( 'googlepay' === $wallet_type ) {
				$_POST['googlepay_amount']   = $order_amount_minor;
				$_POST['googlepay_currency'] = $order_currency;
			} elseif ( 'applepay' === $wallet_type ) {
				$_POST['applepay_amount']   = $order_amount_minor;
				$_POST['applepay_currency'] = $order_currency;
			}

			$_POST['payment_method_data'] = wp_json_encode( array_merge(
				$existing_pmd,
				array(
					'payment_method_type'        => $wallet_type,
					'convesiopay_payment_token'  => $token_to_pass,
					'convesiopay_payment_id'     => $_POST['convesiopay_payment_id'] ?? '',
					'convesiopay_status'         => $_POST['convesiopay_status'] ?? '',
					'googlepay_amount'           => 'googlepay' === $wallet_type ? $order_amount_minor : '',
					'googlepay_currency'         => 'googlepay' === $wallet_type ? $order_currency : '',
					'applepay_amount'            => 'applepay' === $wallet_type ? $order_amount_minor : '',
					'applepay_currency'          => 'applepay' === $wallet_type ? $order_currency : '',
				)
			) );

			// Hand off to the unified gateway — it detects the wallet type and routes
			// to the appropriate individual gateway internally.
			$result = $unified_gateway->process_payment( $order_id );

			// Normalise: some gateway paths return a WC_Order object on success.
			if ( is_a( $result, 'WC_Order' ) ) {
				$result = array(
					'result'   => 'success',
					'redirect' => $result->get_checkout_order_received_url(),
				);
			}

			// Surface any failure back to the client.
			if ( is_wp_error( $result ) ) {
				throw new Exception( $result->get_error_message() );
			}
			if ( ! is_array( $result ) || ! isset( $result['result'] ) || 'success' !== $result['result'] ) {
				$failure_message = is_array( $result ) && ! empty( $result['messages'] )
					? sanitize_text_field( (string) $result['messages'] )
					: __( 'Payment failed. Please try again.', 'peachpay-for-woocommerce' );
				throw new Exception( $failure_message );
			}

			// On success: clear cart + session so this order cannot be replayed.
			WC()->cart->empty_cart();
			if ( WC()->session ) {
				WC()->session->destroy_session();
				WC()->session->init();
				WC()->session->set( 'order_awaiting_payment', null );
				WC()->session->set( 'reload_checkout', true );
			}

			$response = $this->success_payload_from_order( $order_id );
			set_transient( $result_key, $response, 15 * MINUTE_IN_SECONDS );
			delete_transient( $lock_key );

			return new WP_REST_Response( $response, 200 );
		} catch ( Throwable $e ) {
			delete_transient( $lock_key );

			if ( function_exists( 'peachpay_log' ) ) {
				peachpay_log(
					'error',
					'ConvesioPay accelerated checkout error.',
					array(
						'event'     => 'accelerated_checkout.create_order.failed',
						'component' => 'payments',
						'feature'   => 'convesiopay_accelerated_checkout',
						'operation' => 'create_order',
						'outcome'   => 'failed',
						'method'    => __METHOD__,
						'line'      => __LINE__,
						'data'      => array(
							'details' => $e->getMessage(),
							'context' => array(
								'idempotency_hash' => $idempotency_hash,
							),
						),
					)
				);
			}

			$raw_error_message = trim( (string) $e->getMessage() );
			$error_code = 'internal_error';
			$error_message = __( 'Unable to process accelerated checkout request.', 'peachpay-for-woocommerce' );
			$error_status = 500;

			// These strings must match the exceptions thrown in the unified gateway
			// dispatch block above.  Update here whenever those messages change.
			$known_errors = array(
				'Invalid payment token format. Please try again.'                    => array(
					'code'   => 'invalid_payment_token',
					'status' => 400,
				),
				'Payment token is missing. Please try again.'                        => array(
					'code'   => 'missing_payment_token',
					'status' => 400,
				),
				'ConvesioPay unified gateway not available'                          => array(
					'code'   => 'gateway_unavailable',
					'status' => 503,
				),
				'Google Pay payment processing is not yet available.'                => array(
					'code'   => 'wallet_not_supported',
					'status' => 400,
				),
			);

			if ( '' !== $raw_error_message && isset( $known_errors[ $raw_error_message ] ) ) {
				$error_code = $known_errors[ $raw_error_message ]['code'];
				$error_status = $known_errors[ $raw_error_message ]['status'];
				$error_message = sanitize_text_field( $raw_error_message );
			} elseif ( '' !== $raw_error_message && false !== stripos( $raw_error_message, 'token' ) ) {
				$error_code = 'invalid_payment_token';
				$error_status = 400;
				$error_message = sanitize_text_field( $raw_error_message );
			} elseif ( '' !== $raw_error_message && false !== stripos( $raw_error_message, 'payment' ) ) {
				$error_code = 'payment_failed';
				$error_status = 402;
				$error_message = sanitize_text_field( $raw_error_message );
			}

			return $this->error_response( $error_code, $error_message, $error_status );
		} finally {
			// Always restore the session payment method / wallet sub-method so that
			// a failed order creation does not leave wallet-specific session values
			// set for the user's next page load or normal-checkout attempt.
			// Cart contents are intentionally not restored — Stripe parity.
			if ( null !== $order_session_snapshot ) {
				$this->restore_shipping_session_state( $order_session_snapshot );
			}
		}
	}

	/**
	 * Validate payload.
	 *
	 * @param array $payload Request payload.
	 * @return true|WP_Error
	 */
	private function validate_payload( $payload ) {
		$wallet_type = isset( $payload['wallet_type'] ) ? sanitize_key( (string) $payload['wallet_type'] ) : '';
		$page_context = isset( $payload['page_context'] ) ? sanitize_key( (string) $payload['page_context'] ) : '';
		$idempotency_key = isset( $payload['idempotency_key'] ) ? sanitize_text_field( (string) $payload['idempotency_key'] ) : '';
		$convesiopay_payment_token = isset( $payload['convesiopay_payment_token'] ) ? sanitize_text_field( (string) $payload['convesiopay_payment_token'] ) : '';

		if ( ! in_array( $wallet_type, array( 'applepay', 'googlepay' ), true ) ) {
			return new WP_Error( 'invalid_wallet_type', __( 'Unsupported wallet type.', 'peachpay-for-woocommerce' ) );
		}

		if ( ! in_array( $page_context, array( 'checkout', 'cart', 'product' ), true ) ) {
			return new WP_Error( 'invalid_page_context', __( 'Unsupported page context.', 'peachpay-for-woocommerce' ) );
		}

		if ( '' === $idempotency_key || strlen( $idempotency_key ) < 8 ) {
			return new WP_Error( 'invalid_idempotency_key', __( 'A valid idempotency key is required.', 'peachpay-for-woocommerce' ) );
		}

		if ( '' === $convesiopay_payment_token ) {
			return new WP_Error( 'missing_convesiopay_payment_token', __( 'Payment token is required.', 'peachpay-for-woocommerce' ) );
		}

		$settings = $this->get_accelerated_settings();
		$wallet_enabled_key = $wallet_type . '_enabled';
		if ( ! isset( $settings[ $wallet_enabled_key ] ) || 'yes' !== $settings[ $wallet_enabled_key ] ) {
			return new WP_Error( 'wallet_disabled', __( 'Selected wallet is currently disabled.', 'peachpay-for-woocommerce' ) );
		}

		$page_enabled_key = $page_context . '_enabled';
		if ( ! isset( $settings[ $page_enabled_key ] ) || 'yes' !== $settings[ $page_enabled_key ] ) {
			return new WP_Error(
				$page_context . '_disabled',
				// translators: %s: page context (checkout or cart)
				sprintf( __( 'Accelerated checkout is disabled for the %s page.', 'peachpay-for-woocommerce' ), $page_context )
			);
		}

		if ( ! function_exists( 'WC' ) || ! WC()->cart || WC()->cart->is_empty() ) {
			return new WP_Error( 'empty_cart', __( 'Cart is empty.', 'peachpay-for-woocommerce' ) );
		}

		// Accelerated checkout does not re-validate the cart total against the
		// amount_snapshot. This differs intentionally from normal checkout.
		//
		// In normal checkout the cart lives in the browser and the user could change
		// it between page load and form submit, so a server-side amount guard makes
		// sense. In accelerated checkout the flow is:
		//   1. We fetch and present totals to the wallet sheet (recalculate endpoint).
		//   2. The customer reviews and authorises exactly those amounts in the sheet.
		//   3. We receive a one-time payment token that is already bound to that amount
		//      by the payment processor.
		//
		// Re-deriving the total here by calling WC()->cart->get_total() after apply-
		// ing the wallet's shipping address and method can produce a value that
		// differs from the authorised amount due to:
		//   • Country normalisation lag (full name vs. ISO code).
		//   • Shipping zone cache returning stale rates.
		//   • Tax rounding differences between the recalculate call and this request.
		//
		// The authoritative amount check is performed by the ConvesioPay payment
		// processor when it validates the token. If the token was issued for $1.07,
		// the processor will reject any attempt to charge a different amount.
		//
		// We still validate the currency so a store currency change is caught early.
		$amount_snapshot = isset( $payload['amount_snapshot'] ) && is_array( $payload['amount_snapshot'] ) ? $payload['amount_snapshot'] : array();
		if ( ! empty( $amount_snapshot ) ) {
			$snapshot_currency = isset( $amount_snapshot['currency'] ) ? strtoupper( sanitize_text_field( (string) $amount_snapshot['currency'] ) ) : '';
			$cart_currency     = strtoupper( get_woocommerce_currency() );
			if ( ! empty( $snapshot_currency ) && $snapshot_currency !== $cart_currency ) {
				return new WP_Error( 'amount_currency_mismatch', __( 'Cart currency has changed. Please try again.', 'peachpay-for-woocommerce' ) );
			}
		}

		$customer = isset( $payload['customer'] ) && is_array( $payload['customer'] ) ? $payload['customer'] : array();
		$email = isset( $customer['email'] ) ? sanitize_email( (string) $customer['email'] ) : '';
		if ( empty( $email ) ) {
			return new WP_Error( 'missing_email', __( 'Customer email is required.', 'peachpay-for-woocommerce' ) );
		}

		if ( WC()->cart && WC()->cart->needs_shipping() ) {
			$shipping = isset( $payload['shipping_address'] ) && is_array( $payload['shipping_address'] ) ? $payload['shipping_address'] : array();
			$line1 = isset( $shipping['line1'] ) ? trim( (string) $shipping['line1'] ) : '';
			$city = isset( $shipping['city'] ) ? trim( (string) $shipping['city'] ) : '';
			$country = isset( $shipping['country'] ) ? trim( (string) $shipping['country'] ) : '';
			$postal = isset( $shipping['postal_code'] ) ? trim( (string) $shipping['postal_code'] ) : '';

			if ( '' === $line1 || '' === $city || '' === $country || '' === $postal ) {
				return new WP_Error( 'missing_shipping_address', __( 'Shipping address is incomplete.', 'peachpay-for-woocommerce' ) );
			}
		}

		return true;
	}

	/**
	 * Create order from checkout/cart context.
	 *
	 * @param array $payload Request payload.
	 * @return WC_Order|WP_Error
	 */
	private function create_order_from_checkout_context( $payload ) {
		if ( ! function_exists( 'WC' ) || ! WC()->checkout() ) {
			return new WP_Error( 'woocommerce_unavailable', __( 'WooCommerce checkout is unavailable.', 'peachpay-for-woocommerce' ) );
		}

		$customer = isset( $payload['customer'] ) && is_array( $payload['customer'] ) ? $payload['customer'] : array();
		$billing  = isset( $payload['billing_address'] ) && is_array( $payload['billing_address'] ) ? $payload['billing_address'] : array();
		$shipping = isset( $payload['shipping_address'] ) && is_array( $payload['shipping_address'] ) ? $payload['shipping_address'] : $billing;

		$name_parts = $this->split_name(
			isset( $customer['name'] ) ? sanitize_text_field( (string) $customer['name'] ) : ''
		);
		$first_name = isset( $customer['first_name'] ) ? sanitize_text_field( (string) $customer['first_name'] ) : $name_parts['first_name'];
		$last_name  = isset( $customer['last_name'] ) ? sanitize_text_field( (string) $customer['last_name'] ) : $name_parts['last_name'];

		$billing_country  = $this->normalize_country_code( sanitize_text_field( (string) ( $billing['country'] ?? '' ) ) );
		$billing_state    = $this->normalize_state_code( $billing_country, sanitize_text_field( (string) ( $billing['state'] ?? '' ) ) );
		$shipping_country = $this->normalize_country_code( sanitize_text_field( (string) ( $shipping['country'] ?? '' ) ) );
		$shipping_state   = $this->normalize_state_code( $shipping_country, sanitize_text_field( (string) ( $shipping['state'] ?? '' ) ) );

		$checkout_data = array(
			'payment_method'            => 'peachpay_convesiopay_unified',
			'billing_first_name'        => $first_name,
			'billing_last_name'         => $last_name,
			'billing_email'             => sanitize_email( (string) ( $customer['email'] ?? '' ) ),
			'billing_phone'             => sanitize_text_field( (string) ( $customer['phone'] ?? '' ) ),
			'billing_country'           => $billing_country,
			'billing_state'             => $billing_state,
			'billing_postcode'          => sanitize_text_field( (string) ( $billing['postal_code'] ?? '' ) ),
			'billing_city'              => sanitize_text_field( (string) ( $billing['city'] ?? '' ) ),
			'billing_address_1'         => sanitize_text_field( (string) ( $billing['line1'] ?? '' ) ),
			'billing_address_2'         => sanitize_text_field( (string) ( $billing['line2'] ?? '' ) ),
			'shipping_first_name'       => $first_name,
			'shipping_last_name'        => $last_name,
			'shipping_country'          => $shipping_country,
			'shipping_state'            => $shipping_state,
			'shipping_postcode'         => sanitize_text_field( (string) ( $shipping['postal_code'] ?? '' ) ),
			'shipping_city'             => sanitize_text_field( (string) ( $shipping['city'] ?? '' ) ),
			'shipping_address_1'        => sanitize_text_field( (string) ( $shipping['line1'] ?? '' ) ),
			'shipping_address_2'        => sanitize_text_field( (string) ( $shipping['line2'] ?? '' ) ),
			'ship_to_different_address' => true,
			'terms'                     => 1,
		);

		$order_id = WC()->checkout()->create_order( $checkout_data );
		if ( is_wp_error( $order_id ) ) {
			return $order_id;
		}

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return new WP_Error( 'order_creation_failed', __( 'Order creation failed.', 'peachpay-for-woocommerce' ) );
		}

		if ( is_user_logged_in() ) {
			$order->set_customer_id( get_current_user_id() );
		}

		// ── Subscription creation ────────────────────────────────────────────────
		// WC_Checkout::create_order() fires 'woocommerce_checkout_order_created'.
		// WC Subscriptions hooks into 'woocommerce_checkout_order_processed' instead
		// (the action fired by WC_Checkout::process_checkout() during a normal form
		// submission).  Because we call create_order() directly and bypass
		// process_checkout(), the subscription object is never created.
		//
		// Calling WC_Subscriptions_Checkout::process_checkout() explicitly after
		// order creation replicates exactly what WC Subscriptions does during a
		// normal checkout — it reads WC()->cart->recurring_carts (already populated
		// by the calculate_totals() calls in our recalculate/create-order flow) and
		// creates the subscription linked to this order with the correct payment
		// method, billing period, and shipping address.
		//
		// $checkout_data is passed as $posted_data so that WCS can populate the
		// subscription's billing/shipping address and fire the
		// woocommerce_checkout_create_subscription action with the full data.
		if ( class_exists( 'WC_Subscriptions_Checkout' )
			&& class_exists( 'WC_Subscriptions_Cart' )
			&& WC_Subscriptions_Cart::cart_contains_subscription() ) {
			WC_Subscriptions_Checkout::process_checkout( $order->get_id(), $checkout_data );
		}

		// WC_Checkout::create_order() can create the correct shipping line item while
		// leaving the order-level shipping total / grand total copied from the cart's
		// stale aggregate values. Recalculate the order totals from its actual items
		// so accelerated-checkout orders reflect the wallet-selected shipping cost.
		$order->calculate_totals( false );
		$order->save();
		return $order;
	}

	/**
	 * Get transaction ID from request (following card gateway pattern)
	 */
	private function get_transaction_id_from_request() {
		
		// Method 1: Classic checkout - check POST data
		if ( isset( $_POST['peachpay_transaction_id'] ) && !empty( $_POST['peachpay_transaction_id'] ) ) {
			$transaction_id = sanitize_text_field( $_POST['peachpay_transaction_id'] );
			return $transaction_id;
		}
		
		// Method 2: WooCommerce Blocks - check for blocks payment method data
		$payment_method = $_POST['payment_method'] ?? '';
		if ( $payment_method === 'peachpay_convesiopay_unified' ) {
			
			// Check all possible locations where blocks might send the transaction ID
			$possible_locations = [
				'payment_method_data',
				'wc-peachpay_convesiopay_unified-transaction-id',
				'peachpay_convesiopay_unified_transaction_id',
				'meta',
				'paymentMethodData'
			];
			
			foreach ( $possible_locations as $location ) {
				if ( isset( $_POST[$location] ) ) {
					$data = $_POST[$location];
					
					if ( is_string( $data ) ) {
						// Try to decode if it's JSON
						$decoded = json_decode( stripslashes( $data ), true );
						if ( $decoded && isset( $decoded['peachpay_transaction_id'] ) ) {
							$transaction_id = sanitize_text_field( $decoded['peachpay_transaction_id'] );
							return $transaction_id;
						}
					}
					
					if ( is_array( $data ) && isset( $data['peachpay_transaction_id'] ) ) {
						$transaction_id = sanitize_text_field( $data['peachpay_transaction_id'] );
						return $transaction_id;
					}
				}
			}
		}

		// Method 3: Fallback to PHP/WooCommerce session.
		if ( session_status() === PHP_SESSION_ACTIVE ) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			$session_transaction_id = $_SESSION['peachpay_transaction_id'] ?? '';
			if ( ! empty( $session_transaction_id ) ) {
				return sanitize_text_field( $session_transaction_id );
			}
		}

		if ( function_exists( 'WC' ) && WC()->session ) {
			$wc_transaction_id = WC()->session->get( 'peachpay_transaction_id' );
			if ( ! empty( $wc_transaction_id ) ) {
				return sanitize_text_field( $wc_transaction_id );
			}
		}
		
		return '';
	}

	/**
	 * Find existing order by idempotency key.
	 *
	 * @param string $idempotency_key Idempotency key.
	 * @return WC_Order|false
	 */
	private function find_existing_order_by_idempotency_key( $idempotency_key ) {
		// Only match orders in a paid / authorised state.  Idempotency is meant
		// to deduplicate *successful* operations — a failed or still-pending order
		// should be retriable with the same key.  Including 'pending' or 'failed'
		// here would cause success_payload_from_order() to redirect the shopper to
		// an unpaid or failed order instead of allowing a retry.
		// 'on-hold' is included because wallet payments should never legitimately
		// land on-hold, but if a gateway does set it the order has been authorised.
		$orders = wc_get_orders(
			array(
				'limit'      => 1,
				'orderby'    => 'date',
				'order'      => 'DESC',
				'meta_key'   => '_peachpay_accelerated_idempotency_key',
				'meta_value' => $idempotency_key,
				'status'     => array( 'on-hold', 'processing', 'completed' ),
			)
		);

		if ( empty( $orders ) || ! isset( $orders[0] ) ) {
			return false;
		}

		return $orders[0] instanceof WC_Order ? $orders[0] : false;
	}

	/**
	 * Build success payload from order.
	 *
	 * @param WC_Order $order Order object.
	 * @return array
	 */
	private function success_payload_from_order( $order_id ) {
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return array(
				'success'      => false,
				'order_id'     => $order_id,
				'order_number' => '',
				'redirect_url' => '',
				'error_message' => __( 'Order not found.', 'peachpay-for-woocommerce' ),
			);
		}

		return array(
			'success'      => true,
			'order_id'     => $order->get_id(),
			'order_number' => $order->get_order_number(),
			'redirect_url' => $order->get_checkout_order_received_url(),
		);
	}

	/**
	 * Build error response.
	 *
	 * @param string $code Error code.
	 * @param string $message Error message.
	 * @param int    $status HTTP status.
	 * @return WP_REST_Response
	 */
	private function error_response( $code, $message, $status ) {
		return new WP_REST_Response(
			array(
				'success'    => false,
				'error_code' => $code,
				'message'    => $message,
			),
			$status
		);
	}

	/**
	 * Snapshot mutable customer state before recalculation mutation.
	 *
	 * @return array
	 */
	private function snapshot_customer_state() {
		return array(
			'shipping_country'  => WC()->customer->get_shipping_country(),
			'shipping_state'    => WC()->customer->get_shipping_state(),
			'shipping_postcode' => WC()->customer->get_shipping_postcode(),
			'billing_country'   => WC()->customer->get_billing_country(),
			'billing_state'     => WC()->customer->get_billing_state(),
			'billing_postcode'  => WC()->customer->get_billing_postcode(),
			'calculated'        => WC()->customer->has_calculated_shipping(),
		);
	}

	/**
	 * Restore customer state after recalculation mutation.
	 *
	 * @param array $snapshot Previous state.
	 */
	private function restore_customer_state( $snapshot ) {
		if ( ! is_array( $snapshot ) || ! function_exists( 'WC' ) || ! WC()->customer ) {
			return;
		}

		WC()->customer->set_shipping_country( sanitize_text_field( (string) ( $snapshot['shipping_country'] ?? '' ) ) );
		WC()->customer->set_shipping_state( sanitize_text_field( (string) ( $snapshot['shipping_state'] ?? '' ) ) );
		WC()->customer->set_shipping_postcode( sanitize_text_field( (string) ( $snapshot['shipping_postcode'] ?? '' ) ) );
		WC()->customer->set_billing_country( sanitize_text_field( (string) ( $snapshot['billing_country'] ?? '' ) ) );
		WC()->customer->set_billing_state( sanitize_text_field( (string) ( $snapshot['billing_state'] ?? '' ) ) );
		WC()->customer->set_billing_postcode( sanitize_text_field( (string) ( $snapshot['billing_postcode'] ?? '' ) ) );
		WC()->customer->set_calculated_shipping( ! empty( $snapshot['calculated'] ) );
		WC()->customer->save();
	}

	/**
	 * Snapshot mutable shipping-related session values.
	 *
	 * @return array
	 */
	private function snapshot_shipping_session_state() {
		return array(
			'chosen_shipping_methods'      => WC()->session->get( 'chosen_shipping_methods', array() ),
			'chosen_payment_method'        => WC()->session->get( 'chosen_payment_method', '' ),
			'convesiopay_selected_method'  => WC()->session->get( 'convesiopay_selected_method', '' ),
		);
	}

	/**
	 * Restore mutable shipping-related session values.
	 *
	 * @param array $snapshot Previous state.
	 */
	private function restore_shipping_session_state( $snapshot ) {
		if ( ! is_array( $snapshot ) || ! function_exists( 'WC' ) || ! WC()->session ) {
			return;
		}

		$chosen = isset( $snapshot['chosen_shipping_methods'] ) && is_array( $snapshot['chosen_shipping_methods'] )
			? $snapshot['chosen_shipping_methods']
			: array();
		$sanitized = array();
		foreach ( $chosen as $value ) {
			$sanitized[] = sanitize_text_field( (string) $value );
		}
		WC()->session->set( 'chosen_shipping_methods', $sanitized );

		// Restore the payment method so other gateways' fee hooks return to normal.
		$payment_method = isset( $snapshot['chosen_payment_method'] )
			? sanitize_text_field( (string) $snapshot['chosen_payment_method'] )
			: '';
		WC()->session->set( 'chosen_payment_method', $payment_method );

		// Restore the ConvesioPay sub-method so its fee hook returns to the original state.
		$convesiopay_method = isset( $snapshot['convesiopay_selected_method'] )
			? sanitize_text_field( (string) $snapshot['convesiopay_selected_method'] )
			: '';
		WC()->session->set( 'convesiopay_selected_method', $convesiopay_method );
	}

	/**
	 * Validate a product is purchasable as an accelerated checkout item.
	 *
	 * IMPORTANT — wc_get_product() has an internal guard that returns false when
	 * called before woocommerce_init / woocommerce_after_register_post_type /
	 * woocommerce_after_register_taxonomy have all fired.  In the REST API context
	 * those hooks may not have completed when our route callback runs, so calling
	 * wc_get_product() here would silently return false and report "product not
	 * found" even for valid products.
	 *
	 * Strategy:
	 *  1. Use get_post() for a fast, always-available existence and post-type check.
	 *  2. Only call wc_get_product() when WooCommerce has confirmed it is safe to
	 *     do so (i.e. all three actions have fired).
	 *  3. If wc_get_product() still returns null / false after the guard (edge-case
	 *     parity issue with the WC version in use), skip the deeper checks —
	 *     WC()->cart->add_to_cart() will reject an invalid product on its own.
	 *
	 * @param int    $product_id      Product ID.
	 * @param int    $variation_id    Variation ID (0 for simple products).
	 * @param array  $variation       Variation attribute map.
	 * @param string $page_context    'product' | 'cart' | 'checkout'.
	 * @return true|WP_Error
	 */
	private function validate_product_for_accelerated_checkout( $product_id, $variation_id, $variation, $page_context = 'product' ) {
		$product_id = (int) $product_id;
		if ( $product_id <= 0 ) {
			return new WP_Error( 'invalid_product_id', __( 'Invalid product ID.', 'peachpay-for-woocommerce' ) );
		}

		// Use the WP post layer for a lightweight existence + post-type check.
		// This always works regardless of WooCommerce's initialisation state.
		$post = get_post( $product_id );
		if ( ! $post ) {
			return new WP_Error( 'product_not_found', __( 'Product not found.', 'peachpay-for-woocommerce' ) );
		}
		// Accept both top-level products and variations (post_type = product_variation).
		if ( ! in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) {
			return new WP_Error( 'product_not_found', __( 'Product not found.', 'peachpay-for-woocommerce' ) );
		}

		// Deeper WooCommerce-level validation — only run when the product registry
		// is guaranteed to be ready, otherwise skip and let add_to_cart() catch it.
		$wc_ready = did_action( 'woocommerce_init' )
			&& did_action( 'woocommerce_after_register_taxonomy' )
			&& did_action( 'woocommerce_after_register_post_type' );

		if ( $wc_ready ) {
			$product = wc_get_product( $product_id );
			if ( $product instanceof WC_Abstract_Product ) {

				// ── Product-type allow-list (mirrors Stripe's supported_product_types) ─────
				// grouped and external products are never purchasable via express checkout.
				// composite products are not supported on the product page because component
				// selection data is not captured in the accelerated checkout flow.
				$unsupported_types = array( 'grouped', 'external' );
				if ( 'product' === $page_context ) {
					$unsupported_types[] = 'composite';
				}
				if ( in_array( $product->get_type(), $unsupported_types, true ) ) {
					return new WP_Error(
						'product_type_unsupported',
						/* translators: %s: WooCommerce product type slug */
						sprintf( __( 'Product type "%s" is not supported for accelerated checkout.', 'peachpay-for-woocommerce' ), $product->get_type() )
					);
				}

				if ( ! $product->is_purchasable() ) {
					return new WP_Error( 'product_not_purchasable', __( 'Product is not available for purchase.', 'peachpay-for-woocommerce' ) );
				}
				if ( ! $product->is_in_stock() ) {
					return new WP_Error( 'product_out_of_stock', __( 'Product is out of stock.', 'peachpay-for-woocommerce' ) );
				}

				// ── Variable / variable-subscription ────────────────────────────────────
				// Require a resolved variation_id before proceeding. Applies to both
				// 'variable' and 'variable-subscription' product types.
				if ( in_array( $product->get_type(), array( 'variable', 'variable-subscription' ), true ) ) {
					if ( empty( $variation_id ) ) {
						return new WP_Error( 'variation_required', __( 'Please select a product variation before using accelerated checkout.', 'peachpay-for-woocommerce' ) );
					}
					$variation_product = wc_get_product( $variation_id );
					if ( $variation_product instanceof WC_Abstract_Product ) {
						if ( ! $variation_product->is_purchasable() || ! $variation_product->is_in_stock() ) {
							return new WP_Error( 'variation_unavailable', __( 'The selected variation is unavailable.', 'peachpay-for-woocommerce' ) );
						}
					}
				}

				// ── Subscription-specific validation ────────────────────────────────────
				// Subscriptions with a free trial that requires shipping are not
				// supported — the express checkout sheet cannot present a $0 initial
				// payment followed by a deferred first shipping charge.
				// Mirrors Stripe's is_invalid_subscription_product() logic.
				if ( $this->is_invalid_subscription_product( $product, 'product' === $page_context ) ) {
					return new WP_Error(
						'subscription_not_supported',
						__( 'This subscription product cannot be purchased with accelerated checkout (free trial with shipping, or synced virtual subscription with no upfront cost).', 'peachpay-for-woocommerce' )
					);
				}

				// ── File-upload product add-ons ──────────────────────────────────────────
				// File upload add-ons cannot be handled inside the express payment sheet.
				if ( class_exists( 'WC_Product_Addons_Helper' ) ) {
					$addons = WC_Product_Addons_Helper::get_product_addons( $product->get_id() );
					foreach ( $addons as $addon ) {
						if ( isset( $addon['type'] ) && 'file_upload' === $addon['type'] ) {
							return new WP_Error(
								'addon_file_upload_unsupported',
								__( 'Products with file-upload add-ons cannot be purchased with accelerated checkout.', 'peachpay-for-woocommerce' )
							);
						}
					}
				}
			}
			// If wc_get_product() returned false despite the guard (rare parity issue),
			// fall through and let add_to_cart() surface the error naturally.
		}

		return true;
	}

	/**
	 * Returns the WooCommerce product types supported for accelerated checkout.
	 *
	 * Mirrors Stripe's WC_Stripe_Express_Checkout_Helper::supported_product_types().
	 * Grouped and external products are intentionally excluded.  Composite products
	 * are in the list here but are blocked on the product page by
	 * validate_product_for_accelerated_checkout() because component-selection data
	 * is not captured in the express checkout flow.
	 *
	 * @return string[]
	 */
	public function get_supported_product_types() {
		return apply_filters(
			'peachpay_convesiopay_accelerated_checkout_supported_product_types',
			array(
				'simple',
				'variable',
				'variation',
				'subscription',
				'variable-subscription',
				'subscription_variation',
				'bundle',
				'composite',
			)
		);
	}

	/**
	 * Returns true if the given subscription product is incompatible with
	 * accelerated checkout and should be blocked.
	 *
	 * Invalid cases (mirrors Stripe's is_invalid_subscription_product()):
	 *  - Free trial that requires shipping: the express sheet cannot show a $0
	 *    initial total and then charge shipping at a later date.
	 *  - Synced subscription with no upfront payment that is virtual (product
	 *    page only): get_price() does not account for prorated / trial amounts
	 *    until the product is in the cart, so the initial totals passed to the
	 *    payment sheet are incorrect.
	 *
	 * If WooCommerce Subscriptions is not active this always returns false.
	 *
	 * @param WC_Product|null $product                 Product to check.
	 * @param bool            $is_product_page_request Whether this is a product-page context.
	 * @return bool True if the product should be blocked.
	 */
	private function is_invalid_subscription_product( $product, $is_product_page_request = false ) {
		if (
			! class_exists( 'WC_Subscriptions_Product' ) ||
			! class_exists( 'WC_Subscriptions_Synchroniser' ) ||
			! WC_Subscriptions_Product::is_subscription( $product )
		) {
			return false;
		}

		// For variable-subscriptions check all available variations; the product is
		// considered invalid only if *every* variation is invalid.
		if ( $product->get_type() === 'variable-subscription' ) {
			$products = $product->get_available_variations( 'object' );
		} else {
			$products = array( $product );
		}

		$is_invalid = true;

		foreach ( $products as $variation ) {
			$needs_shipping     = $variation->needs_shipping();
			$is_synced          = WC_Subscriptions_Synchroniser::is_product_synced( $variation );
			$is_payment_upfront = WC_Subscriptions_Synchroniser::is_payment_upfront( $variation );
			$has_trial          = WC_Subscriptions_Product::get_trial_length( $variation ) > 0;

			// Virtual synced subscriptions with no upfront cost cannot have their
			// totals calculated correctly from the product page.
			if ( $is_product_page_request && $is_synced && ! $is_payment_upfront && ! $needs_shipping ) {
				continue;
			}

			// Synced subscription with no upfront payment that requires shipping.
			if ( $is_synced && ! $is_payment_upfront && $needs_shipping ) {
				continue;
			}

			// Free trial that requires a shipping address.
			if ( $has_trial && $needs_shipping ) {
				continue;
			}

			// This variation is valid — the product as a whole is not invalid.
			$is_invalid = false;
			break;
		}

		return $is_invalid;
	}

	/**
	 * Ensure WooCommerce post types and taxonomies are registered before any
	 * call that relies on wc_get_product() (including WC_Cart::add_to_cart).
	 *
	 * wc_get_product() internally checks did_action('woocommerce_init'),
	 * did_action('woocommerce_after_register_taxonomy'), and
	 * did_action('woocommerce_after_register_post_type') and returns false if any
	 * is missing.  In the REST API path these hooks may not have completed yet.
	 * Calling WC()->register_post_types() / register_taxonomies() directly is
	 * idempotent — WooCommerce guards against double-registration internally.
	 */
	private function ensure_wc_product_registry() {
		if ( ! function_exists( 'WC' ) || ! WC() ) {
			return;
		}
		// Trigger post type registration if the action hasn't fired yet.
		if ( ! did_action( 'woocommerce_after_register_post_type' ) ) {
			if ( method_exists( WC(), 'register_post_types' ) ) {
				WC()->register_post_types();
			} elseif ( function_exists( 'wc_register_post_types' ) ) {
				wc_register_post_types();
			}
		}
		// Trigger taxonomy registration if the action hasn't fired yet.
		if ( ! did_action( 'woocommerce_after_register_taxonomy' ) ) {
			if ( method_exists( WC(), 'register_taxonomies' ) ) {
				WC()->register_taxonomies();
			} elseif ( function_exists( 'wc_register_taxonomies' ) ) {
				wc_register_taxonomies();
			}
		}
		// Ensure the product factory is available — it is normally set inside WC()->init()
		// but may be missing if WC initialisation was incomplete when the REST request arrived.
		if ( class_exists( 'WC_Product_Factory' ) && empty( WC()->product_factory ) ) {
			WC()->product_factory = new WC_Product_Factory();
		}
	}

	/**
	 * Replace the cart with a single product for accelerated checkout.
	 *
	 * Calls empty_cart() then add_to_cart() — identical to Stripe's express-checkout
	 * implementation.  Any existing cart items are discarded; the caller is
	 * responsible for deciding whether that is acceptable for the context.
	 *
	 * @param int   $product_id   Product ID.
	 * @param int   $quantity     Quantity to purchase.
	 * @param int   $variation_id Variation ID (0 for simple products).
	 * @param array $variation    Variation attribute map (may be empty).
	 * @throws Exception On add-to-cart failure.
	 */
	private function inject_product_into_cart( $product_id, $quantity, $variation_id, $variation ) {
		// Ensure WC product types are registered before add_to_cart() calls
		// wc_get_product() internally — without this, add_to_cart() silently
		// returns false in REST API contexts where WC hasn't fully initialised.
		$this->ensure_wc_product_registry();

		$product_id   = (int) $product_id;
		$variation_id = (int) $variation_id;
		$variation    = is_array( $variation ) ? $variation : array();

		// Server-side variation resolution: if the client sent attribute values but
		// no variation_id (can happen when WC JS hasn't resolved the variation yet,
		// e.g. on first page load), find the matching variation server-side.
		// Mirrors Stripe's WC_Data_Store::find_matching_product_variation() approach.
		if ( 0 === $variation_id && ! empty( $variation ) ) {
			$parent_product = wc_get_product( $product_id );
			if ( $parent_product instanceof WC_Abstract_Product && $parent_product->is_type( 'variable' ) ) {
				$data_store  = WC_Data_Store::load( 'product' );
				$resolved_id = (int) $data_store->find_matching_product_variation( $parent_product, $variation );
				if ( $resolved_id > 0 ) {
					$variation_id = $resolved_id;
				}
			}
		}

		// Replace the cart contents with only this product — identical to how
		// Stripe's WooCommerce express-checkout element works: empty_cart() then
		// add_to_cart().  Any existing cart items are discarded; the user's session
		// will contain just the purchased product after this call.
		WC()->cart->empty_cart();

		$cart_item_key = WC()->cart->add_to_cart(
			$product_id,
			max( 1, (int) $quantity ),
			$variation_id,
			$variation
		);

		if ( false === $cart_item_key ) {
			// Capture WC notices which may contain a more descriptive error.
			$notices = function_exists( 'wc_get_notices' ) ? wc_get_notices( 'error' ) : array();
			$notice_message = '';
			if ( ! empty( $notices ) ) {
				$first = reset( $notices );
				// WC 3.x: array of strings; WC 4+: array of arrays with 'notice' key.
				$notice_message = is_array( $first ) && isset( $first['notice'] )
					? wp_strip_all_tags( (string) $first['notice'] )
					: wp_strip_all_tags( (string) $first );
				// Clear the error notice so it doesn't bleed into the response.
				if ( function_exists( 'wc_clear_notices' ) ) {
					wc_clear_notices();
				}
			}
			$message = $notice_message
				?: __( 'Unable to add product to cart for accelerated checkout.', 'peachpay-for-woocommerce' );
			throw new Exception( $message );
		}
	}

	/**
	 * Build totals response where values are major-unit strings.
	 *
	 * @return array
	 */
	private function build_recalculate_totals_payload() {
		$product_subtotal = (float) WC()->cart->get_cart_contents_total();
		if ( $product_subtotal < 0 ) {
			$product_subtotal = 0.0;
		}

		$cart_items = array();
		foreach ( WC()->cart->get_cart() as $cart_item ) {
			if ( ! is_array( $cart_item ) ) {
				continue;
			}
			$product = isset( $cart_item['data'] ) ? $cart_item['data'] : null;
			if ( ! $product || ! is_object( $product ) || ! method_exists( $product, 'get_name' ) ) {
				continue;
			}

			$quantity         = isset( $cart_item['quantity'] ) ? max( 1, (int) $cart_item['quantity'] ) : 1;
			$item_label       = sanitize_text_field( (string) $product->get_name() ) . ' x ' . $quantity;
			$item_line_amount = isset( $cart_item['line_subtotal'] ) ? (float) $cart_item['line_subtotal'] : 0.0;
			if ( $item_line_amount < 0 ) {
				$item_line_amount = 0.0;
			}

			$cart_items[] = array(
				'label'    => $item_label,
				'quantity' => $quantity,
				'amount'   => wc_format_decimal( $item_line_amount, 2 ),
			);
		}

		$discount = (float) WC()->cart->get_discount_total();
		if ( $discount < 0 ) {
			$discount = 0.0;
		}

		$shipping = (float) WC()->cart->get_shipping_total();
		if ( $shipping < 0 ) {
			$shipping = 0.0;
		}

		// Sum all cart fees already computed by WooCommerce (e.g. the Apple Pay or
		// Google Pay surcharge added via the fee hook). Since convesiopay_selected_method
		// was set before calculate_totals() was called, the correct wallet fee is active.
		$fees_total = 0.0;
		foreach ( WC()->cart->get_fees() as $fee ) {
			$fees_total += (float) $fee->amount;
		}
		if ( $fees_total < 0 ) {
			$fees_total = 0.0;
		}

		$subtotal = $product_subtotal + $fees_total;
		$tax      = max( 0.0, (float) WC()->cart->get_total_tax() );
		$total    = max( 0.0, (float) WC()->cart->get_total( 'edit' ) );

		return array(
			'success'      => true,
			'subtotal'     => wc_format_decimal( $subtotal, 2 ),
			'discount'     => wc_format_decimal( $discount, 2 ),
			'shipping'     => wc_format_decimal( $shipping, 2 ),
			'tax'          => wc_format_decimal( $tax, 2 ),
			'total'        => wc_format_decimal( $total, 2 ),
			'applepay_fee' => wc_format_decimal( $fees_total, 2 ),
			'cart_items'   => $cart_items,
		);
	}


	/**
	 * Extract current shipping rates from package 0.
	 *
	 * @return array
	 */
	private function extract_shipping_rates_for_recalculate() {
		$rates = array();
		if ( ! function_exists( 'WC' ) || ! WC()->shipping ) {
			return $rates;
		}

		$packages = WC()->shipping()->get_packages();
		if ( ! is_array( $packages ) || empty( $packages ) || ! isset( $packages[0]['rates'] ) || ! is_array( $packages[0]['rates'] ) ) {
			return $rates;
		}

		foreach ( $packages[0]['rates'] as $rate_id => $rate_obj ) {
			if ( ! $rate_obj instanceof WC_Shipping_Rate ) {
				continue;
			}

			$rates[] = array(
				'identifier' => sanitize_text_field( (string) $rate_id ),
				'label'      => sanitize_text_field( (string) $rate_obj->get_label() ),
				'detail'     => '',
				'amount'     => wc_format_decimal( (float) $rate_obj->get_cost(), 2 ),
			);
		}

		return $rates;
	}

	/**
	 * Get accelerated checkout settings.
	 *
	 * @return array
	 */
	private function get_accelerated_settings() {
		$defaults = array(
			'applepay_enabled' => 'yes',
			'googlepay_enabled' => 'yes',
			'checkout_enabled' => 'yes',
			'cart_enabled'     => 'no',
			'product_enabled'  => 'no',
		);

		$settings = get_option( 'peachpay_convesiopay_accelerated_checkout_settings', array() );
		if ( ! is_array( $settings ) ) {
			$settings = array();
		}

		return wp_parse_args( $settings, $defaults );
	}

	/**
	 * Normalise a state/province value to the WooCommerce state code for the
	 * given country. Apple Pay often passes the full state name ("West Bengal",
	 * "California") instead of the code ("WB", "CA"). Passing a full name to
	 * WC()->customer->set_shipping_state() is silently accepted but produces an
	 * empty shipping-zone lookup, causing WC to return no zone-specific methods
	 * and fall back to free shipping instead of the intended rate.
	 *
	 * @param string $country_code  ISO 2-letter country code (already normalised).
	 * @param string $state_input   Raw state value from the request payload.
	 * @return string State code (uppercase) or the original value when no match.
	 */
	private function normalize_state_code( $country_code, $state_input ) {
		$trimmed = trim( (string) $state_input );
		if ( '' === $trimmed || '' === $country_code ) {
			return $trimmed;
		}

		// Already a short code (≤ 3 chars) — return it uppercased.
		if ( strlen( $trimmed ) <= 3 ) {
			return strtoupper( $trimmed );
		}

		// Reverse-lookup using WooCommerce's own state list for the country.
		if ( function_exists( 'WC' ) && WC()->countries ) {
			$states = WC()->countries->get_states( strtoupper( $country_code ) );
			if ( is_array( $states ) ) {
				foreach ( $states as $code => $name ) {
					if ( 0 === strcasecmp( $name, $trimmed ) ) {
						return strtoupper( (string) $code );
					}
				}
			}
		}

		return $trimmed;
	}

	/**
	 * Normalise a country value to a 2-letter ISO 3166-1 alpha-2 code.
	 *
	 * The ConvesioPay SDK (and some Apple Pay contact shapes) sometimes provides
	 * the full English country name ("United States", "India") instead of the
	 * 2-letter code that WooCommerce expects ("US", "IN"). Passing a full name to
	 * WC()->customer->set_shipping_country() silently does nothing, leaving WC on
	 * its default address — which causes zone mismatches and amount_mismatch errors.
	 *
	 * @param string $country_input Raw country value from the request payload.
	 * @return string 2-letter ISO code (uppercase), or the original input if
	 *                no match is found.
	 */
	private function normalize_country_code( $country_input ) {
		$trimmed = trim( (string) $country_input );
		if ( '' === $trimmed ) {
			return '';
		}

		// Already looks like a valid 2-letter code — return it uppercased.
		if ( 2 === strlen( $trimmed ) ) {
			return strtoupper( $trimmed );
		}

		// Reverse-lookup: full name → ISO code using WooCommerce's own list.
		if ( function_exists( 'WC' ) && WC()->countries ) {
			$wc_countries = WC()->countries->get_countries();
			if ( is_array( $wc_countries ) ) {
				foreach ( $wc_countries as $code => $name ) {
					if ( 0 === strcasecmp( $name, $trimmed ) ) {
						return strtoupper( (string) $code );
					}
				}
			}
		}

		// Fallback: uppercase whatever was passed (handles e.g. 3-letter codes).
		return strtoupper( $trimmed );
	}

	/**
	 * Split full name into first and last.
	 *
	 * @param string $full_name Full name.
	 * @return array
	 */
	private function split_name( $full_name ) {
		$full_name = trim( (string) $full_name );
		if ( '' === $full_name ) {
			return array(
				'first_name' => '',
				'last_name'  => '',
			);
		}

		$parts = preg_split( '/\s+/', $full_name );
		if ( ! is_array( $parts ) || empty( $parts ) ) {
			return array(
				'first_name' => $full_name,
				'last_name'  => '',
			);
		}

		$first = array_shift( $parts );
		$last  = implode( ' ', $parts );

		return array(
			'first_name' => sanitize_text_field( (string) $first ),
			'last_name'  => sanitize_text_field( (string) $last ),
		);
	}

}
