<?php // phpcs:ignore WordPress.Files.FileName.NotHyphenatedLowercase, WordPress.Files.FileName.InvalidClassFileName
/**
 * WooPayments API Client wrapper.
 *
 * Thin wrapper over `\WC_Payments::get_payments_api_client()` so the charge
 * service can be unit-tested without the WooPayments plugin loaded, and so
 * the call surface stays in one place if the upstream API shifts across
 * WCPay releases.
 *
 * @package Autoship Cloud
 * @since 2.13.0
 */

namespace Autoship\Modules\Payments\Services;

use Autoship\Services\Logging\Logger;
use Exception;
use WC_Order;

/**
 * Wraps WCPay's PaymentIntent creation for off-session recurring charges.
 *
 * Uses the Request-class API (`\WCPay\Core\Server\Request\Create_And_Confirm_Intention`)
 * introduced in WCPay 7.x. The legacy `WC_Payments_API_Client::create_and_confirm_intention`
 * method was removed in WCPay 10.x, so direct calls against the API client no longer work.
 *
 * @package Autoship\Modules\Payments\Services
 * @since 2.13.0
 */
class WooPaymentsApiClient {

	/**
	 * Whether the WooPayments plugin is loaded and exposes the expected API.
	 *
	 * @return bool
	 */
	public function is_available(): bool {
		if ( ! class_exists( '\WC_Payments' ) ) {
			return false;
		}

		// WCPay 10.x removed WC_Payments_API_Client::create_and_confirm_intention.
		// Gate on the Request class (present since WCPay 7.x) instead.
		if ( ! class_exists( '\WCPay\Core\Server\Request\Create_And_Confirm_Intention' ) ) {
			return false;
		}

		return null !== $this->resolve_api_client();
	}

	/**
	 * Create and confirm a PaymentIntent off-session against the merchant's
	 * WooPayments-connected Stripe account.
	 *
	 * Returns a normalized array regardless of WCPay return type. All keys are
	 * always present; missing values are null/empty.
	 *
	 * @param int    $amount_cents      The charge amount in the smallest currency unit.
	 * @param string $currency          ISO 4217 currency code (e.g. "USD").
	 * @param string $payment_method_id The Stripe PaymentMethod ID (pm_...).
	 * @param string $customer_id       The Stripe Customer ID (cus_...).
	 * @param int    $wc_order_id       The WooCommerce order ID this charge corresponds to.
	 *                                  Required by WCPay 10.x — it propagates to filter
	 *                                  callbacks (WooPay's Checkout_Service expects it as
	 *                                  the second arg) and lets WCPay's webhook handler
	 *                                  link the resulting PaymentIntent back to the order.
	 * @param array  $metadata          Metadata to attach to the intent.
	 * @param string $return_url_hint   Optional. URL Stripe should redirect to
	 *                                  after a 3DS challenge. QPilot supplies
	 *                                  its merchant-center 3DS landing page so
	 *                                  the customer returns to a QPilot-controlled
	 *                                  page that confirms and finalises the
	 *                                  renewal. Falls back to the WC order's
	 *                                  order-received URL when empty.
	 *
	 * @return array {
	 *     @type string|null $intentId      The intent ID (pi_...).
	 *     @type string|null $chargeId      The charge ID (ch_...) on success.
	 *     @type string      $status        The intent status (succeeded / requires_action / failed / ...).
	 *     @type string|null $nextActionUrl The 3DS redirect URL when requires_action.
	 *     @type string|null $errorCode     The WCPay/Stripe error code on failure.
	 *     @type string|null $errorMessage  The error message on failure.
	 *     @type string|null $declineCode   The card decline code on a card_error.
	 * }
	 */
	public function create_off_session_intent(
		int $amount_cents,
		string $currency,
		string $payment_method_id,
		string $customer_id,
		int $wc_order_id,
		array $metadata,
		string $return_url_hint = ''
	): array {
		if ( ! $this->is_available() ) {
			return $this->failure_shape( 'failed', null, 'woopayments_unavailable', 'WooPayments API client is not available.', null );
		}

		$wc_order = function_exists( 'wc_get_order' ) ? wc_get_order( $wc_order_id ) : null;
		if ( ! $wc_order ) {
			Logger::error( 'WooPayments Charge', sprintf( 'WC order #%d not found for off-session charge.', $wc_order_id ) );
			return $this->failure_shape( 'failed', null, 'wc_order_not_found', sprintf( 'WooCommerce order #%d not found.', $wc_order_id ), null );
		}

		// PaymentIntent captured from the raw Stripe error body during
		// $request->send(). Stripe attaches the full `error.payment_intent`
		// object — including next_action.redirect_to_url.url — to its
		// authentication_required 402 responses, but WCPay throws a generic
		// API_Exception that strips it. Sniffing the HTTP response before
		// WCPay parses it is currently the only reliable way to recover both
		// the intent ID and the 3DS redirect URL.
		$captured_payment_intent = null;

		try {
			// Constructor signature: payment_method, order, payment_type, token,
			// payment_initiated_by, manual_capture, cvc_confirmation, fingerprint,
			// payment_method_stripe_id, customer_id. Setting the enum context
			// fields explicitly aligns Stripe Radar / risk evaluation with how
			// WCPay handles its own subscription renewals.
			$payment_info = new \WCPay\Payment_Information(
				$payment_method_id,
				$wc_order,
				\WCPay\Constants\Payment_Type::RECURRING(),
				null,
				\WCPay\Constants\Payment_Initiated_By::MERCHANT(),
				\WCPay\Constants\Payment_Capture_Type::AUTOMATIC(),
				null,
				'',
				$payment_method_id,
				$customer_id
			);

			$request = \WCPay\Core\Server\Request\Create_And_Confirm_Intention::create( $wc_order_id );
			$request->set_amount( $amount_cents );
			$request->set_currency_code( strtolower( $currency ) );
			$request->set_payment_method( $payment_method_id );
			$request->set_customer( $customer_id );
			$request->set_metadata( $metadata );
			$request->set_off_session( true );

			// `set_hook_args` is variadic — pass Payment_Information as a single value,
			// not wrapped in an array. WCPay's send() spreads `...$this->hook_args` into
			// its apply_filters call, which spreads again into WordPress's apply_filters.
			// WooPay's Checkout_Service::create_intention_request($request, $payment_info)
			// receives the Payment_Information as its second positional arg.
			$request->set_hook_args( $payment_info );

			// WooPay's Checkout_Service registers filter callbacks that resolve a
			// platform-check service via WCPay's DI container. That service only
			// initialises during an on-screen checkout (WC session + cart present),
			// so it returns false in our headless off-session context and triggers
			// `Call to a member function should_use_stripe_platform_on_checkout_page() on false`
			// inside is_platform_payment_method(). WooPay's checks are not meaningful
			// for subscription renewals — temporarily unregister the offending callback
			// during send() and restore it after so normal checkout flows are unaffected.
			$removed_woopay_filters = $this->unregister_filter_callbacks(
				'\WCPay\WooPay\Service\Checkout_Service',
				'create_intention_request'
			);

			$capture_callback = function ( $response ) use ( &$captured_payment_intent ) {
				$extracted = $this->extract_intent_from_http_response( $response );
				if ( null !== $extracted ) {
					$captured_payment_intent = $extracted;
				}
				return $response;
			};
			add_filter( 'http_response', $capture_callback, 10, 1 );

			try {
				$intent = $request->send();
			} finally {
				remove_filter( 'http_response', $capture_callback, 10 );
				$this->reregister_filter_callbacks( $removed_woopay_filters );
			}

			Logger::log( 'WooPayments Charge', sprintf( 'Create_And_Confirm_Intention returned %s', is_object( $intent ) ? get_class( $intent ) : gettype( $intent ) ) );

			return $this->normalize_intent( $intent );
		} catch ( \Throwable $e ) {
			// Catch Throwable, not Exception — WCPay's Request classes can raise
			// \Error / TypeError when called with mismatched setter signatures.
			// Letting those bubble up turns a single bad charge into a 500.
			Logger::error(
				'WooPayments Charge',
				sprintf(
					'%s in %s:%d during Create_And_Confirm_Intention: %s' . PHP_EOL . 'Trace: %s',
					get_class( $e ),
					$e->getFile(),
					$e->getLine(),
					$e->getMessage(),
					$e->getTraceAsString()
				)
			);

			$error_code = $this->extract_error_code( $e );

			// Off-session 3DS recovery: when the card requires authentication,
			// Stripe creates the PaymentIntent in `requires_action` state but
			// returns a 402 with code `authentication_required` rather than a
			// successful response. WCPay propagates that as an exception that
			// strips the intent ID, so we rely on the http_response capture
			// above (and fall back to message parsing) to recover it. Once we
			// have the ID, Get_Intention pulls the intent — which carries the
			// 3DS redirect URL we surface to QPilot.
			if ( 'authentication_required' === $error_code ) {
				Logger::log(
					'WooPayments Charge',
					sprintf(
						'authentication_required on off-session intent for order #%d. captured=%s captured_status=%s',
						$wc_order_id,
						null === $captured_payment_intent ? 'none' : ( $captured_payment_intent['id'] ?? '?' ),
						null === $captured_payment_intent ? 'n/a' : ( $captured_payment_intent['status'] ?? '?' )
					)
				);

				// Path A: Stripe may have left the off-session intent in
				// `requires_action` with a usable redirect URL — recover it.
				$recovered = $this->recover_requires_action_intent( $e, $captured_payment_intent );
				if ( null !== $recovered ) {
					return $recovered;
				}

				// Path B: For cards where Stripe rejects the off-session 3DS
				// attempt outright (intent transitions straight to
				// `requires_payment_method` with no next_action), the off-session
				// intent is terminal. Per Stripe's documented recovery flow,
				// create a fresh on-session intent so Stripe returns a
				// requires_action state with a populated redirect URL the
				// customer can use to authenticate.
				Logger::log( 'WooPayments Charge', sprintf( 'Off-session intent unrecoverable for order #%d; creating fresh on-session intent for 3DS authentication.', $wc_order_id ) );
				$on_session = $this->create_on_session_intent_for_3ds(
					$amount_cents,
					$currency,
					$payment_method_id,
					$customer_id,
					$wc_order_id,
					$metadata,
					$wc_order,
					$return_url_hint
				);
				if ( null !== $on_session ) {
					return $on_session;
				}

				Logger::error(
					'WooPayments Charge',
					sprintf( 'All 3DS recovery paths exhausted for order #%d — falling through to failure response.', $wc_order_id )
				);
			}

			return $this->failure_shape(
				'failed',
				null,
				$error_code,
				$e->getMessage(),
				$this->extract_decline_code( $e )
			);
		}
	}

	/**
	 * Retrieve a PaymentIntent's current state from WCPay/Stripe.
	 *
	 * QPilot calls this after a customer completes 3DS via the email link
	 * so it can confirm the renewal succeeded and finalise the processing
	 * cycle (mirrors the native Stripe `validAuth` lookup, but routes the
	 * intent fetch through the merchant's WCPay-connected account because
	 * QPilot has no direct Stripe API key for WooPayments).
	 *
	 * @param string $intent_id The PaymentIntent ID (`pi_...`).
	 *
	 * @return array {
	 *     @type bool        $success      Whether the retrieval itself succeeded.
	 *     @type string|null $intentId     The intent ID on success.
	 *     @type string|null $status       The intent status (succeeded / requires_action / requires_payment_method / failed / ...).
	 *     @type string|null $chargeId     The charge ID when present.
	 *     @type array       $metadata     The intent metadata. Includes `qpilot_processing_id` so QPilot can verify provenance.
	 *     @type string|null $errorCode    Set on failure.
	 *     @type string|null $errorMessage Set on failure.
	 * }
	 */
	public function get_intent_status( string $intent_id ): array {
		if ( '' === $intent_id || 0 !== strpos( $intent_id, 'pi_' ) ) {
			return $this->intent_status_failure( 'invalid_intent_id', 'A PaymentIntent ID starting with `pi_` is required.' );
		}

		if ( ! $this->is_available() ) {
			return $this->intent_status_failure( 'woopayments_unavailable', 'WooPayments API client is not available on this site.' );
		}

		try {
			$intent = $this->retrieve_intent( $intent_id );
		} catch ( \Throwable $e ) {
			Logger::error( 'WooPayments Intent Status', sprintf( 'Get_Intention threw for %s: %s', $intent_id, $e->getMessage() ) );
			return $this->intent_status_failure( 'retrieval_failed', $e->getMessage() );
		}

		if ( null === $intent ) {
			return $this->intent_status_failure( 'intent_not_found', sprintf( 'PaymentIntent %s could not be retrieved.', $intent_id ) );
		}

		$normalized = $this->normalize_intent( $intent );
		$metadata   = $this->extract_metadata( $intent );

		return array(
			'success'      => true,
			'intentId'     => $normalized['intentId'],
			'status'       => $normalized['status'],
			'chargeId'     => $normalized['chargeId'],
			'metadata'     => $metadata,
			'errorCode'    => null,
			'errorMessage' => null,
		);
	}

	/**
	 * Build the failure shape returned by `get_intent_status()`.
	 *
	 * @param string $code    The error code.
	 * @param string $message The error message.
	 *
	 * @return array
	 */
	private function intent_status_failure( string $code, string $message ): array {
		return array(
			'success'      => false,
			'intentId'     => null,
			'status'       => null,
			'chargeId'     => null,
			'metadata'     => array(),
			'errorCode'    => $code,
			'errorMessage' => $message,
		);
	}

	/**
	 * Pull a string-keyed metadata array off a WCPay/Stripe intent. The model
	 * and array shapes both expose `metadata` either via `get_metadata()` or
	 * the `metadata` key/property.
	 *
	 * @param mixed $intent The intent value.
	 *
	 * @return array<string, string>
	 */
	private function extract_metadata( $intent ): array {
		$metadata = $this->read( $intent, array( 'get_metadata', 'metadata' ) );
		if ( ! is_array( $metadata ) && ! is_object( $metadata ) ) {
			return array();
		}

		$result = array();
		foreach ( (array) $metadata as $key => $value ) {
			if ( is_scalar( $value ) || null === $value ) {
				$result[ (string) $key ] = (string) $value;
			}
		}
		return $result;
	}

	/**
	 * Find every filter callback that is an instance method of `$class_name::$method_name`
	 * and remove each one from its hook, returning a manifest that can be passed to
	 * `reregister_filter_callbacks()` to restore them after our work is done.
	 *
	 * Used to surgically silence WCPay/WooPay filter callbacks that assume an
	 * on-screen checkout context during our headless off-session charges. The
	 * callbacks are restored immediately afterwards so concurrent customer-facing
	 * checkout flows on the same site are not affected.
	 *
	 * @param string $class_name  Fully qualified class name (with leading backslash).
	 * @param string $method_name Method name on that class.
	 *
	 * @return array<int, array{hook:string, priority:int, callback:array, accepted_args:int}>
	 */
	private function unregister_filter_callbacks( string $class_name, string $method_name ): array {
		$removed = array();

		if ( ! isset( $GLOBALS['wp_filter'] ) || ! is_array( $GLOBALS['wp_filter'] ) ) {
			return $removed;
		}

		foreach ( $GLOBALS['wp_filter'] as $hook_name => $hook ) {
			if ( ! is_object( $hook ) || ! isset( $hook->callbacks ) || ! is_array( $hook->callbacks ) ) {
				continue;
			}

			foreach ( $hook->callbacks as $priority => $callbacks ) {
				if ( ! is_array( $callbacks ) ) {
					continue;
				}

				foreach ( $callbacks as $cb ) {
					$fn = $cb['function'] ?? null;
					if ( ! is_array( $fn ) || 2 !== count( $fn ) ) {
						continue;
					}
					if ( ! is_object( $fn[0] ) || ! ( $fn[0] instanceof $class_name ) ) {
						continue;
					}
					if ( $fn[1] !== $method_name ) {
						continue;
					}

					$accepted_args = isset( $cb['accepted_args'] ) ? (int) $cb['accepted_args'] : 1;

					remove_filter( $hook_name, $fn, $priority );

					$removed[] = array(
						'hook'          => $hook_name,
						'priority'      => (int) $priority,
						'callback'      => $fn,
						'accepted_args' => $accepted_args,
					);
				}
			}
		}

		return $removed;
	}

	/**
	 * Restore previously unregistered filter callbacks.
	 *
	 * @param array<int, array{hook:string, priority:int, callback:array, accepted_args:int}> $removed The manifest returned by `unregister_filter_callbacks()`.
	 *
	 * @return void
	 */
	private function reregister_filter_callbacks( array $removed ): void {
		foreach ( $removed as $entry ) {
			add_filter( $entry['hook'], $entry['callback'], $entry['priority'], $entry['accepted_args'] );
		}
	}

	/**
	 * Resolve the WCPay API client safely.
	 *
	 * @return object|null The WCPay API client or null when unavailable.
	 */
	private function resolve_api_client() {
		if ( ! class_exists( '\WC_Payments' ) || ! method_exists( '\WC_Payments', 'get_payments_api_client' ) ) {
			return null;
		}

		try {
			$api_client = \WC_Payments::get_payments_api_client();
		} catch ( Exception $e ) {
			return null;
		}

		return is_object( $api_client ) ? $api_client : null;
	}

	/**
	 * Convert a WCPay intent (object or array) into the normalized response shape.
	 *
	 * @param mixed $intent The WCPay return value.
	 *
	 * @return array The normalized response.
	 */
	private function normalize_intent( $intent ): array {
		$intent_id      = $this->read( $intent, array( 'get_id', 'id' ) );
		$status         = (string) ( $this->read( $intent, array( 'get_status', 'status' ) ) ?? '' );
		$next_action    = $this->read( $intent, array( 'get_next_action', 'next_action' ) );
		$charge         = $this->read( $intent, array( 'get_charge', 'charge', 'charges' ) );
		$charge_id      = $this->extract_charge_id( $charge );
		$next_action_url = $this->extract_next_action_url( $next_action );

		return array(
			'intentId'      => $intent_id,
			'chargeId'      => $charge_id,
			'status'        => $status,
			'nextActionUrl' => $next_action_url,
			'errorCode'     => null,
			'errorMessage'  => null,
			'declineCode'   => null,
		);
	}

	/**
	 * Build a failure shape with all keys present.
	 *
	 * @param string      $status         The status string.
	 * @param string|null $intent_id      Intent ID if known.
	 * @param string|null $error_code     Error code.
	 * @param string|null $error_message  Error message.
	 * @param string|null $decline_code   Decline code.
	 *
	 * @return array
	 */
	private function failure_shape( string $status, ?string $intent_id, ?string $error_code, ?string $error_message, ?string $decline_code ): array {
		return array(
			'intentId'      => $intent_id,
			'chargeId'      => null,
			'status'        => $status,
			'nextActionUrl' => null,
			'errorCode'     => $error_code,
			'errorMessage'  => $error_message,
			'declineCode'   => $decline_code,
		);
	}

	/**
	 * Read a property/getter from an object or array.
	 *
	 * @param mixed $source     The source value.
	 * @param array $candidates Ordered list of getter method names or array keys to try.
	 *
	 * @return mixed The first match or null.
	 */
	private function read( $source, array $candidates ) {
		if ( is_object( $source ) ) {
			foreach ( $candidates as $candidate ) {
				if ( method_exists( $source, $candidate ) ) {
					return $source->{$candidate}();
				}
				if ( property_exists( $source, $candidate ) ) {
					return $source->{$candidate};
				}
			}
		}

		if ( is_array( $source ) ) {
			foreach ( $candidates as $candidate ) {
				if ( array_key_exists( $candidate, $source ) ) {
					return $source[ $candidate ];
				}
			}
		}

		return null;
	}

	/**
	 * Extract a charge ID from a WCPay charge value.
	 *
	 * @param mixed $charge The charge value.
	 *
	 * @return string|null
	 */
	private function extract_charge_id( $charge ): ?string {
		if ( null === $charge ) {
			return null;
		}

		// `charges` may be a list-shaped collection: { data: [ { id, ... } ] }.
		if ( is_array( $charge ) && isset( $charge['data'][0] ) ) {
			$charge = $charge['data'][0];
		}

		$id = $this->read( $charge, array( 'get_id', 'id' ) );

		return is_string( $id ) && '' !== $id ? $id : null;
	}

	/**
	 * Extract the 3DS redirect URL from a `next_action` value.
	 *
	 * @param mixed $next_action The next action object or array.
	 *
	 * @return string|null
	 */
	private function extract_next_action_url( $next_action ): ?string {
		if ( null === $next_action ) {
			return null;
		}

		// Stripe shape: next_action.redirect_to_url.url.
		if ( is_object( $next_action ) ) {
			$redirect = $this->read( $next_action, array( 'redirect_to_url' ) );
		} elseif ( is_array( $next_action ) ) {
			$redirect = $next_action['redirect_to_url'] ?? null;
		} else {
			$redirect = null;
		}

		if ( null === $redirect ) {
			return null;
		}

		$url = $this->read( $redirect, array( 'url' ) );

		return is_string( $url ) && '' !== $url ? $url : null;
	}

	/**
	 * Extract a Stripe error code from a throwable (best effort).
	 *
	 * @param \Throwable $e The throwable.
	 *
	 * @return string|null
	 */
	private function extract_error_code( \Throwable $e ): ?string {
		if ( method_exists( $e, 'get_error_code' ) ) {
			$code = $e->get_error_code();
			return is_string( $code ) && '' !== $code ? $code : null;
		}

		return null;
	}

	/**
	 * Resolve a return URL to hand to Stripe so 3DS challenges produce a
	 * redirect_to_url next_action rather than use_stripe_sdk.
	 *
	 * Preference order:
	 *   1. Caller hint — QPilot's merchant-center 3DS landing page. Lets the
	 *      customer return to a QPilot-controlled surface that confirms the
	 *      renewal cycle (mirrors the native Stripe flow).
	 *   2. WC order's canonical order-received URL — falls back to a
	 *      customer-facing status page on the merchant site when no hint is
	 *      provided.
	 *   3. WP home URL — last resort.
	 *
	 * @param WC_Order $order The order being charged.
	 * @param string   $hint  Caller-supplied URL preferred over local fallbacks.
	 *
	 * @return string A fully-qualified URL, or '' when none can be resolved.
	 */
	private function resolve_return_url( WC_Order $order, string $hint = '' ): string {
		if ( '' !== $hint ) {
			return $hint;
		}

		if ( method_exists( $order, 'get_checkout_order_received_url' ) ) {
			$url = $order->get_checkout_order_received_url();
			if ( is_string( $url ) && '' !== $url ) {
				return $url;
			}
		}

		if ( function_exists( 'home_url' ) ) {
			$home = home_url( '/' );
			if ( is_string( $home ) && '' !== $home ) {
				return $home;
			}
		}

		return '';
	}

	/**
	 * Extract a Stripe decline code from a throwable (best effort).
	 *
	 * @param \Throwable $e The throwable.
	 *
	 * @return string|null
	 */
	private function extract_decline_code( \Throwable $e ): ?string {
		if ( method_exists( $e, 'get_decline_code' ) ) {
			$code = $e->get_decline_code();
			return is_string( $code ) && '' !== $code ? $code : null;
		}

		return null;
	}

	/**
	 * Create a fresh on-session PaymentIntent for 3DS authentication.
	 *
	 * Invoked when an off-session create_and_confirm has failed with
	 * `authentication_required` and the resulting intent was left in a
	 * terminal `requires_payment_method` state — i.e. Stripe rejected
	 * off-session authentication entirely. Per Stripe's documented recovery
	 * pattern, we create a brand-new PaymentIntent for the same payment
	 * method + customer with `off_session=false`. Stripe then returns the
	 * intent in `requires_action` state with a populated
	 * next_action.redirect_to_url that the customer can complete from the
	 * QPilot 3DS email link.
	 *
	 * The first (off-session) intent is left in `requires_payment_method`
	 * and is auto-cancelled by Stripe after 24h — Stripe does not bill for
	 * uncompleted intents.
	 *
	 * @param int      $amount_cents      Charge amount in the smallest currency unit.
	 * @param string   $currency          ISO 4217 currency code.
	 * @param string   $payment_method_id The Stripe PaymentMethod ID.
	 * @param string   $customer_id       The Stripe Customer ID.
	 * @param int      $wc_order_id       The WooCommerce order ID.
	 * @param array    $metadata          Metadata to attach to the intent.
	 * @param WC_Order $wc_order          The resolved WC order object.
	 * @param string   $return_url_hint   Optional. Preferred return URL passed
	 *                                    in by the caller (QPilot's 3DS landing
	 *                                    page). Falls back to the WC order's
	 *                                    order-received URL when empty.
	 *
	 * @return array|null Normalized intent shape (requires_action with redirect URL,
	 *                    or succeeded if the on-session attempt cleared without 3DS),
	 *                    or null when the retry didn't produce a usable result.
	 */
	private function create_on_session_intent_for_3ds(
		int $amount_cents,
		string $currency,
		string $payment_method_id,
		string $customer_id,
		int $wc_order_id,
		array $metadata,
		WC_Order $wc_order,
		string $return_url_hint = ''
	): ?array {
		try {
			$payment_info = new \WCPay\Payment_Information(
				$payment_method_id,
				$wc_order,
				\WCPay\Constants\Payment_Type::RECURRING(),
				null,
				\WCPay\Constants\Payment_Initiated_By::MERCHANT(),
				\WCPay\Constants\Payment_Capture_Type::AUTOMATIC(),
				null,
				'',
				$payment_method_id,
				$customer_id
			);

			$request = \WCPay\Core\Server\Request\Create_And_Confirm_Intention::create( $wc_order_id );
			$request->set_amount( $amount_cents );
			$request->set_currency_code( strtolower( $currency ) );
			$request->set_payment_method( $payment_method_id );
			$request->set_customer( $customer_id );
			$request->set_metadata( $metadata );
			// Material difference vs the primary off-session attempt: leave
			// off_session false so Stripe issues a usable redirect URL.
			$request->set_off_session( false );

			// Critical for the redirect URL: without a return_url, Stripe
			// defaults the next_action to `use_stripe_sdk` (a JS-SDK
			// challenge with no URL) instead of `redirect_to_url`. The WC
			// order-received page is the canonical post-payment landing
			// URL; Stripe redirects the customer there after they complete
			// 3DS, and WCPay's `payment_intent.succeeded` webhook handles
			// the actual order completion asynchronously.
			$return_url     = $this->resolve_return_url( $wc_order, $return_url_hint );
			$return_url_set = false;
			if ( '' !== $return_url && method_exists( $request, 'set_return_url' ) ) {
				$request->set_return_url( $return_url );
				$return_url_set = true;
			}
			Logger::log(
				'WooPayments Charge',
				sprintf( 'On-session retry: return_url=%s set_via_setter=%s', '' === $return_url ? 'unresolved' : $return_url, $return_url_set ? 'yes' : 'no' )
			);

			$request->set_hook_args( $payment_info );

			$removed_woopay_filters = $this->unregister_filter_callbacks(
				'\WCPay\WooPay\Service\Checkout_Service',
				'create_intention_request'
			);

			// Capture the raw Stripe response body for the retry. On a 200
			// the body is the full PaymentIntent — including next_action —
			// regardless of what WCPay's deserialized model object exposes,
			// which dodges the same model-getter blindspot we hit on the
			// off-session error path. On a 4xx the http_response filter still
			// fires and we can capture the failed intent if Stripe included
			// it (e.g. another authentication_required).
			$captured_intent_payload = null;
			$capture_callback        = function ( $response ) use ( &$captured_intent_payload ) {
				$extracted = $this->extract_intent_from_http_response( $response );
				if ( null !== $extracted ) {
					$captured_intent_payload = $extracted;
				}
				return $response;
			};
			add_filter( 'http_response', $capture_callback, 10, 1 );

			$intent         = null;
			$send_exception = null;
			try {
				$intent = $request->send();
			} catch ( \Throwable $send_error ) {
				$send_exception = $send_error;
			} finally {
				remove_filter( 'http_response', $capture_callback, 10 );
				$this->reregister_filter_callbacks( $removed_woopay_filters );
			}

			if ( null !== $send_exception ) {
				Logger::error(
					'WooPayments Charge',
					sprintf(
						'On-session retry send() threw %s in %s:%d: %s (captured=%s)',
						get_class( $send_exception ),
						$send_exception->getFile(),
						$send_exception->getLine(),
						$send_exception->getMessage(),
						null === $captured_intent_payload ? 'none' : ( $captured_intent_payload['id'] ?? '?' )
					)
				);

				// Even when send() throws, the captured body may carry a
				// usable requires_action intent — use it if so.
				if ( null !== $captured_intent_payload && $this->is_actionable_3ds_intent( $captured_intent_payload ) ) {
					Logger::log( 'WooPayments Charge', sprintf( 'On-session retry threw but captured an actionable 3DS intent %s.', $captured_intent_payload['id'] ?? '?' ) );
					return $this->normalize_intent( $captured_intent_payload );
				}

				return null;
			}

			// Prefer the captured raw payload over WCPay's deserialized
			// object whenever the captured one is usable — it always carries
			// next_action.redirect_to_url, while the model may not expose
			// it. Fall back to whatever WCPay returned otherwise.
			$source = ( null !== $captured_intent_payload && $this->is_actionable_3ds_intent( $captured_intent_payload ) )
				? $captured_intent_payload
				: $intent;

			$normalized = $this->normalize_intent( $source );
			$status     = $normalized['status'] ?? '';

			Logger::log(
				'WooPayments Charge',
				sprintf(
					'On-session retry result: status=%s intentId=%s nextActionUrl=%s capturedStatus=%s',
					$status,
					$normalized['intentId'] ?? '?',
					( ! empty( $normalized['nextActionUrl'] ) ) ? 'present' : 'missing',
					null === $captured_intent_payload ? 'none' : ( $captured_intent_payload['status'] ?? '?' )
				)
			);

			if ( ( 'requires_action' === $status && ! empty( $normalized['nextActionUrl'] ) ) || 'succeeded' === $status ) {
				return $normalized;
			}

			return null;
		} catch ( \Throwable $e ) {
			Logger::error(
				'WooPayments Charge',
				sprintf(
					'On-session retry setup threw %s in %s:%d: %s',
					get_class( $e ),
					$e->getFile(),
					$e->getLine(),
					$e->getMessage()
				)
			);
			return null;
		}
	}

	/**
	 * Recover a `requires_action` PaymentIntent after an
	 * `authentication_required` failure during off-session charging.
	 *
	 * Preference order:
	 *   1. The payment_intent object captured straight from the Stripe error
	 *      body — it already carries next_action.redirect_to_url.url, so no
	 *      second API call is needed and we avoid depending on whether
	 *      WCPay's API_Intention model exposes a `get_next_action()` getter.
	 *   2. If only the intent ID is known (capture missed the full object, or
	 *      we got the ID from exception scraping), re-fetch via the WCPay
	 *      Get_Intention request as a fallback.
	 *
	 * @param \Throwable $e                       The exception thrown by Create_And_Confirm_Intention::send().
	 * @param array|null $captured_payment_intent The payment_intent object captured from the raw HTTP response, or null.
	 *
	 * @return array|null The normalized intent shape, or null when recovery isn't possible.
	 */
	private function recover_requires_action_intent( \Throwable $e, ?array $captured_payment_intent = null ): ?array {
		// Path 1: full intent captured. If Stripe left it in a usable
		// requires_action state, normalize and return — the array-shaped raw
		// Stripe payload feeds through normalize_intent's array branches
		// natively.
		if ( null !== $captured_payment_intent && $this->is_actionable_3ds_intent( $captured_payment_intent ) ) {
			Logger::log( 'WooPayments Charge', sprintf( 'Recovering 3DS intent %s from captured Stripe error body.', $captured_payment_intent['id'] ?? '?' ) );
			return $this->normalize_intent( $captured_payment_intent );
		}

		// If we captured the intent but it's not actionable (e.g. Stripe
		// transitioned it straight to `requires_payment_method` because the
		// card cannot be authenticated off-session), don't bother re-fetching
		// — the state is terminal and Get_Intention will return the same
		// payload. Caller should create a fresh on-session intent instead.
		if ( null !== $captured_payment_intent ) {
			Logger::trace(
				'WooPayments Charge',
				sprintf( 'Captured intent %s is in non-actionable state %s; off-session recovery unavailable.', $captured_payment_intent['id'] ?? '?', $captured_payment_intent['status'] ?? '?' )
			);
			return null;
		}

		// Path 2: no intent captured (http_response filter never fired —
		// different transport layer, or the response was post-processed
		// before our filter ran). Try to find an ID in the exception itself
		// and retrieve via Get_Intention.
		$intent_id = $this->extract_intent_id_from_exception( $e );
		if ( '' === $intent_id ) {
			Logger::trace( 'WooPayments Charge', 'authentication_required raised but no PaymentIntent ID could be extracted from the HTTP response or exception.' );
			return null;
		}

		try {
			$intent = $this->retrieve_intent( $intent_id );
		} catch ( \Throwable $retrieval_error ) {
			Logger::error(
				'WooPayments Charge',
				sprintf( 'Could not retrieve PaymentIntent %s after authentication_required: %s', $intent_id, $retrieval_error->getMessage() )
			);
			return null;
		}

		if ( null === $intent ) {
			return null;
		}

		$normalized = $this->normalize_intent( $intent );

		// Only return a recovery result if the retrieved intent is usable
		// for 3DS. Otherwise let the caller fall through to the on-session
		// retry path — claiming requires_action with no URL just produces a
		// dead-end response.
		if ( 'requires_action' === ( $normalized['status'] ?? '' ) && ! empty( $normalized['nextActionUrl'] ) ) {
			return $normalized;
		}

		Logger::trace(
			'WooPayments Charge',
			sprintf( 'Retrieved intent %s is in non-actionable state %s; off-session recovery unavailable.', $intent_id, $normalized['status'] ?? '?' )
		);
		return null;
	}

	/**
	 * Whether a raw Stripe payment_intent payload is in `requires_action`
	 * state and carries a usable redirect URL.
	 *
	 * @param array $payment_intent The decoded payment_intent value.
	 *
	 * @return bool
	 */
	private function is_actionable_3ds_intent( array $payment_intent ): bool {
		if ( 'requires_action' !== ( $payment_intent['status'] ?? '' ) ) {
			return false;
		}

		$next_action = $payment_intent['next_action'] ?? null;
		if ( ! is_array( $next_action ) ) {
			return false;
		}

		$redirect = $next_action['redirect_to_url'] ?? null;
		if ( ! is_array( $redirect ) ) {
			return false;
		}

		$url = $redirect['url'] ?? '';
		return is_string( $url ) && '' !== $url;
	}

	/**
	 * Extract a PaymentIntent payload from any raw WP HTTP response — both
	 * successful 200-OK intent bodies and Stripe error bodies that nest the
	 * intent under `error.payment_intent`. Used by the on-session retry,
	 * which can return either shape depending on whether 3DS is needed.
	 *
	 * @param mixed $response The value passed to the `http_response` filter.
	 *
	 * @return array|null The payment_intent payload, or null when none can be extracted.
	 */
	private function extract_intent_from_http_response( $response ): ?array {
		if ( ! is_array( $response ) || ! isset( $response['body'] ) ) {
			return null;
		}

		$body = $response['body'];
		if ( ! is_string( $body ) || '' === $body ) {
			return null;
		}

		if ( false === strpos( $body, 'pi_' ) ) {
			return null;
		}

		$decoded = json_decode( $body, true );
		if ( ! is_array( $decoded ) ) {
			return null;
		}

		// 200 OK shape: the body IS the PaymentIntent.
		if ( isset( $decoded['id'] ) && is_string( $decoded['id'] ) && 0 === strpos( $decoded['id'], 'pi_' ) ) {
			return $decoded;
		}

		// Error shape: PaymentIntent is nested under `error.payment_intent`.
		$error = $decoded['error'] ?? null;
		if ( is_array( $error ) ) {
			$payment_intent = $error['payment_intent'] ?? null;
			if ( is_array( $payment_intent ) && isset( $payment_intent['id'] ) ) {
				return $payment_intent;
			}
		}

		return null;
	}

	/**
	 * Best-effort extraction of a PaymentIntent ID from a WCPay/Stripe exception.
	 *
	 * Tries WCPay's structured error-data accessors first — the carrier
	 * property has shifted across WCPay versions, so we probe a few known
	 * shapes before falling back to scanning the exception message for a
	 * `pi_...` token, which Stripe's error responses generally include.
	 *
	 * @param \Throwable $e The exception.
	 *
	 * @return string The PaymentIntent ID, or '' when none can be extracted.
	 */
	private function extract_intent_id_from_exception( \Throwable $e ): string {
		foreach ( array( 'get_error_data', 'get_additional_data' ) as $getter ) {
			if ( ! method_exists( $e, $getter ) ) {
				continue;
			}

			$data = $e->{$getter}();
			if ( ! is_array( $data ) ) {
				continue;
			}

			foreach ( array( 'payment_intent_id', 'intent_id' ) as $key ) {
				if ( isset( $data[ $key ] ) && is_string( $data[ $key ] ) && '' !== $data[ $key ] ) {
					return $data[ $key ];
				}
			}

			if ( isset( $data['payment_intent'] ) ) {
				$payment_intent = $data['payment_intent'];

				if ( is_string( $payment_intent ) && '' !== $payment_intent ) {
					return $payment_intent;
				}

				if ( is_array( $payment_intent ) && isset( $payment_intent['id'] ) && is_string( $payment_intent['id'] ) ) {
					return $payment_intent['id'];
				}

				if ( is_object( $payment_intent ) ) {
					$intent_id = $this->read( $payment_intent, array( 'get_id', 'id' ) );
					if ( is_string( $intent_id ) && '' !== $intent_id ) {
						return $intent_id;
					}
				}
			}
		}

		if ( preg_match( '/pi_[A-Za-z0-9]+/', $e->getMessage(), $matches ) ) {
			return $matches[0];
		}

		return '';
	}

	/**
	 * Retrieve a PaymentIntent from WCPay by ID.
	 *
	 * Prefers the Request-class API (consistent with how we create intents);
	 * falls back to the legacy `WC_Payments_API_Client::get_intent()` method
	 * when the Request class is unavailable. We only surface an error if both
	 * paths fail.
	 *
	 * @param string $intent_id The PaymentIntent ID (`pi_...`).
	 *
	 * @return mixed The PaymentIntent value (object or array), or null when unretrievable.
	 */
	private function retrieve_intent( string $intent_id ) {
		if ( class_exists( '\WCPay\Core\Server\Request\Get_Intention' ) ) {
			try {
				$request = \WCPay\Core\Server\Request\Get_Intention::create( $intent_id );
				return $request->send();
			} catch ( \Throwable $e ) {
				Logger::trace( 'WooPayments Charge', sprintf( 'Get_Intention request failed for %s: %s', $intent_id, $e->getMessage() ) );
			}
		}

		$api_client = $this->resolve_api_client();
		if ( null === $api_client || ! method_exists( $api_client, 'get_intent' ) ) {
			return null;
		}

		return $api_client->get_intent( $intent_id );
	}
}
