<?php
/**
 * PeachPay Authorize.net util class.
 *
 * @package PeachPay
 */

defined( 'PEACHPAY_ABSPATH' ) || exit;

require_once PEACHPAY_ABSPATH . 'core/payments/authnet/utils/class-peachpay-authnet-rest-client.php';

/**
 * Authnet API Client class.
 *
 * Calls the Authorize.net JSON API directly via wp_remote_post().
 * No PeachPay proxy involvement.
 */
class PeachPay_Authnet {

	const ZERO_DECIMAL_CURRENCIES = array(
		'BIF' => 1,
		'CLP' => 1,
		'DJF' => 1,
		'GNF' => 1,
		'JPY' => 1,
		'KMF' => 1,
		'KRW' => 1,
		'MGA' => 1,
		'PYG' => 1,
		'RWF' => 1,
		'UGX' => 1,
		'VND' => 1,
		'VUV' => 1,
		'XAF' => 1,
		'XOF' => 1,
		'XPF' => 1,
	);

	// -------------------------------------------------------------------------
	// Private helpers
	// -------------------------------------------------------------------------

	/**
	 * Posts a JSON request to the Authorize.net API.
	 *
	 * @param string $mode 'test' or 'live'.
	 * @param array  $body The request body (will be JSON-encoded).
	 * @return array|WP_Error Decoded response array, or WP_Error on HTTP failure.
	 */
	private static function request( $mode, $body ) {
		return self::client( $mode )->request( $body );
	}

	/**
	 * Creates a REST client for the selected Authorize.net mode.
	 *
	 * @param string $mode 'test' or 'live'.
	 * @return PeachPay_Authnet_REST_Client
	 */
	private static function client( $mode ) {
		return new PeachPay_Authnet_REST_Client( $mode );
	}

	/**
	 * Builds the merchantAuthentication block from stored credentials.
	 *
	 * @param string $mode 'test' or 'live'.
	 * @return array
	 */
	private static function merchant_auth( $mode ) {
		return array(
			'name'           => PeachPay_Authnet_Credentials::get_login_id( $mode ),
			'transactionKey' => PeachPay_Authnet_Credentials::get_transaction_key( $mode ),
		);
	}

	// -------------------------------------------------------------------------
	// Public API
	// -------------------------------------------------------------------------

	/**
	 * Authenticates a login_id / transaction_key pair against the
	 * Authorize.net authenticateTestRequest endpoint.
	 *
	 * @param string $mode            'test' or 'live'.
	 * @param string $login_id        API Login ID to verify.
	 * @param string $transaction_key Transaction Key to verify.
	 * @return array {
	 *     @type bool   $success True if credentials are valid.
	 *     @type string $message Human-readable result message.
	 * }
	 */
	public static function authenticate( $mode, $login_id, $transaction_key ) {
		$body = array(
			'authenticateTestRequest' => array(
				'merchantAuthentication' => array(
					'name'           => $login_id,
					'transactionKey' => $transaction_key,
				),
				'refId' => 'validate',
			),
		);

		$response = self::request( $mode, $body );

		if ( is_wp_error( $response ) ) {
			return array(
				'success' => false,
				'message' => sprintf(
					// translators: %s: Error message.
					__( 'Connection error: %s', 'peachpay-for-woocommerce' ),
					$response->get_error_message()
				),
			);
		}

		if ( ! is_array( $response ) || empty( $response ) ) {
			return array(
				'success' => false,
				'message' => __( 'Invalid response from Authorize.net API.', 'peachpay-for-woocommerce' ),
			);
		}

		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';

		if ( 'Ok' !== $result_code ) {
			$error_code = isset( $response['messages']['message'][0]['code'] )
				? $response['messages']['message'][0]['code']
				: '';
			$error_text = isset( $response['messages']['message'][0]['text'] )
				? $response['messages']['message'][0]['text']
				: __( 'Unknown error from Authorize.net.', 'peachpay-for-woocommerce' );

			$known_errors = array(
				'E00003' => __( 'Invalid credentials format. Please verify your API Login ID and Transaction Key are correct.', 'peachpay-for-woocommerce' ),
				'E00007' => __( 'Authentication failed. Please verify your API Login ID and Transaction Key are correct.', 'peachpay-for-woocommerce' ),
				'E00013' => __( 'Invalid field value. Please verify your credentials.', 'peachpay-for-woocommerce' ),
			);

			if ( isset( $known_errors[ $error_code ] ) ) {
				$message = $known_errors[ $error_code ];
			} else {
				$message = sprintf(
					// translators: %1$s: Error code. %2$s: Error message.
					__( 'Authorize.net error (%1$s): %2$s', 'peachpay-for-woocommerce' ),
					$error_code,
					$error_text
				);
			}

			return array(
				'success' => false,
				'message' => $message,
			);
		}

		return array(
			'success' => true,
			'message' => __( 'Credentials verified.', 'peachpay-for-woocommerce' ),
		);
	}

	/**
	 * Fetches merchant details from the Authorize.net getMerchantDetailsRequest endpoint.
	 *
	 * Used to cross-validate the Public Client Key against the value stored
	 * in the merchant's Authorize.net account.
	 *
	 * @param string $mode            'test' or 'live'.
	 * @param string $login_id        API Login ID.
	 * @param string $transaction_key Transaction Key.
	 * @return array|null The full response array on success, or null on failure.
	 */
	public static function get_merchant_details( $mode, $login_id, $transaction_key ) {
		$body = array(
			'getMerchantDetailsRequest' => array(
				'merchantAuthentication' => array(
					'name'           => $login_id,
					'transactionKey' => $transaction_key,
				),
			),
		);

		$response = self::request( $mode, $body );

		if ( is_wp_error( $response ) || ! is_array( $response ) || empty( $response ) ) {
			return null;
		}

		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';
		if ( 'Ok' !== $result_code ) {
			return null;
		}

		return $response;
	}

	/**
	 * Fetches full transaction details from the Authorize.net API.
	 *
	 * @param string $mode   'test' or 'live'.
	 * @param string $txn_id Authorize.net transaction ID.
	 * @return array|null The transaction array from Authorize.net, or null on failure.
	 */
	public static function get_transaction_details( $mode, $txn_id ) {
		$body = array(
			'getTransactionDetailsRequest' => array(
				'merchantAuthentication' => self::merchant_auth( $mode ),
				'transId'                => $txn_id,
			),
		);

		$response = self::request( $mode, $body );

		if ( is_wp_error( $response ) ) {
			return null;
		}

		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';

		if ( 'Ok' !== $result_code ) {
			return null;
		}

		return isset( $response['transaction'] ) ? $response['transaction'] : null;
	}

	/**
	 * Creates an Authorize.net transaction.
	 *
	 * Replaces the proxy-based create_payment. The $transaction_parameters
	 * array is passed through directly as the transactionRequest body — its
	 * shape is built by the gateway (see PeachPay_Authnet_Payment_Gateway).
	 *
	 * @param WC_Order $order                  The order to create a payment for.
	 * @param array    $transaction_parameters  Parameters on how to create the payment (transactionType, amount, payment, etc).
	 * @param array    $order_details           Standardized order details (unused here, kept for API compatibility).
	 * @param string   $mode                   'test' or 'live'.
	 * @return array|null Transaction data array on success, null on failure.
	 */
	public static function create_payment( $order, $transaction_parameters, $order_details, $mode ) {
		$body = array(
			'createTransactionRequest' => array(
				'merchantAuthentication' => self::merchant_auth( $mode ),
				'transactionRequest'     => $transaction_parameters,
			),
		);

		$response = self::request( $mode, $body );

		if ( is_wp_error( $response ) ) {
			$order->update_status(
				'failed',
				sprintf(
					// translators: The failure reason.
					__( 'Authorize.net API request failed: %s', 'peachpay-for-woocommerce' ),
					$response->get_error_message()
				)
			);
			return null;
		}

		// Top-level messages resultCode — covers authentication errors etc.
		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';
		if ( 'Ok' !== $result_code ) {
			$error_text = isset( $response['messages']['message'][0]['text'] )
				? $response['messages']['message'][0]['text']
				: __( 'Unknown error from Authorize.net.', 'peachpay-for-woocommerce' );

			if ( function_exists( 'wc_add_notice' ) ) {
				wc_add_notice( __( 'Payment error: ', 'peachpay-for-woocommerce' ) . $error_text, 'error' );
			}

			// translators: %1$s: Payment method title. %2$s: Failure reason.
			$order->update_status( 'failed', sprintf( __( '%1$s payment failed: %2$s', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $error_text ) );
			return null;
		}

		$txn_response  = isset( $response['transactionResponse'] ) ? $response['transactionResponse'] : array();
		// responseCode is a STRING: '1' = approved, '2' = declined, '3' = error, '4' = held for review.
		$response_code = isset( $txn_response['responseCode'] ) ? $txn_response['responseCode'] : '';
		$txn_id        = isset( $txn_response['transId'] ) ? $txn_response['transId'] : '';

		if ( '2' === $response_code || '3' === $response_code ) {
			$errors = isset( $txn_response['errors'] ) ? $txn_response['errors'] : array();
			$msg    = isset( $errors[0]['errorText'] ) ? $errors[0]['errorText'] : __( 'Transaction declined or errored.', 'peachpay-for-woocommerce' );

			if ( function_exists( 'wc_add_notice' ) ) {
				wc_add_notice( __( 'Payment error: ', 'peachpay-for-woocommerce' ) . $msg, 'error' );
			}

			// translators: %1$s: Payment method title. %2$s: Failure reason.
			$order->update_status( 'failed', sprintf( __( '%1$s payment failed: %2$s', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $msg ) );
			return null;
		}

		// For approved ('1') and held-for-review ('4') transactions, fetch full
		// transaction details so we have everything needed for future captures/refunds.
		$transaction_details = null;
		if ( $txn_id && '0' !== $txn_id ) {
			$transaction_details = self::get_transaction_details( $mode, $txn_id );
		}

		// Build the $data shape that calculate_payment_state() expects.
		$data = array(
			'transaction_details' => $transaction_details ? $transaction_details : array(
				'transId'             => $txn_id,
				'transactionStatus'   => self::response_code_to_status( $response_code, $txn_response ),
				'responseCode'        => $response_code,
				'settleAmount'        => $order->get_total(),
			),
			'profile_response'    => isset( $response['profileResponse'] ) ? $response['profileResponse'] : null,
			'network_trans_id'    => isset( $txn_response['networkTransId'] ) ? $txn_response['networkTransId'] : null,
		);

		self::calculate_payment_state( $order, $data );

		return $data;
	}

	/**
	 * Captures a previously authorized Authorize.net transaction.
	 *
	 * @param WC_Order $order          The order with an authorized transaction.
	 * @param float    $capture_amount The amount to capture.
	 * @return array {
	 *     @type bool   $success
	 *     @type string $message
	 * }
	 */
	public static function capture_payment( $order, $capture_amount ) {
		$authnet_mode           = PeachPay_Authnet_Order_Data::get_peachpay( $order, 'authnet_mode' );
		$authnet_transaction_id = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'transId' );

		$body = array(
			'createTransactionRequest' => array(
				'merchantAuthentication' => self::merchant_auth( $authnet_mode ),
				'transactionRequest'     => array(
					'transactionType' => 'priorAuthCaptureTransaction',
					'amount'          => self::format_amount( $capture_amount, $order->get_currency() ),
					'refTransId'      => $authnet_transaction_id,
				),
			),
		);

		$response = self::request( $authnet_mode, $body );

		if ( is_wp_error( $response ) ) {
			return array(
				'success' => false,
				'message' => sprintf(
					// translators: The failure reason.
					__( 'Authorize.net API request failed: %s', 'peachpay-for-woocommerce' ),
					$response->get_error_message()
				),
			);
		}

		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';
		if ( 'Ok' !== $result_code ) {
			$error_text = isset( $response['messages']['message'][0]['text'] )
				? $response['messages']['message'][0]['text']
				: __( 'Unknown error from Authorize.net.', 'peachpay-for-woocommerce' );

			return array(
				'success' => false,
				'message' => $error_text,
			);
		}

		$txn_response  = isset( $response['transactionResponse'] ) ? $response['transactionResponse'] : array();
		$response_code = isset( $txn_response['responseCode'] ) ? $txn_response['responseCode'] : '';
		$txn_id        = isset( $txn_response['transId'] ) ? $txn_response['transId'] : $authnet_transaction_id;

		if ( '2' === $response_code || '3' === $response_code ) {
			$errors = isset( $txn_response['errors'] ) ? $txn_response['errors'] : array();
			$msg    = isset( $errors[0]['errorText'] ) ? $errors[0]['errorText'] : __( 'Capture failed.', 'peachpay-for-woocommerce' );

			return array(
				'success' => false,
				'message' => $msg,
			);
		}

		// Refresh transaction details after capture.
		$transaction_details = self::get_transaction_details( $authnet_mode, $txn_id );
		$data                = array(
			'transaction_details' => $transaction_details ? $transaction_details : array(
				'transId'           => $txn_id,
				'transactionStatus' => 'capturedPendingSettlement',
				'settleAmount'      => $capture_amount,
			),
		);

		self::calculate_payment_state( $order, $data );

		return array(
			'success' => true,
			'message' => 'Success.',
		);
	}

	/**
	 * Voids an Authorize.net transaction.
	 *
	 * @param WC_Order $order The order with an Authorize.net transaction.
	 * @return array {
	 *     @type bool   $success
	 *     @type string $message
	 * }
	 */
	public static function void_payment( $order ) {
		$authnet_mode           = PeachPay_Authnet_Order_Data::get_peachpay( $order, 'authnet_mode' );
		$authnet_transaction_id = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'transId' );

		$body = array(
			'createTransactionRequest' => array(
				'merchantAuthentication' => self::merchant_auth( $authnet_mode ),
				'transactionRequest'     => array(
					'transactionType' => 'voidTransaction',
					'refTransId'      => $authnet_transaction_id,
				),
			),
		);

		$response = self::request( $authnet_mode, $body );

		if ( is_wp_error( $response ) ) {
			return array(
				'success' => false,
				'message' => sprintf(
					// translators: The failure reason.
					__( 'Authorize.net API request failed: %s', 'peachpay-for-woocommerce' ),
					$response->get_error_message()
				),
			);
		}

		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';
		if ( 'Ok' !== $result_code ) {
			$error_text = isset( $response['messages']['message'][0]['text'] )
				? $response['messages']['message'][0]['text']
				: __( 'Unknown error from Authorize.net.', 'peachpay-for-woocommerce' );

			return array(
				'success' => false,
				'message' => $error_text,
			);
		}

		$txn_response  = isset( $response['transactionResponse'] ) ? $response['transactionResponse'] : array();
		$response_code = isset( $txn_response['responseCode'] ) ? $txn_response['responseCode'] : '';
		$txn_id        = isset( $txn_response['transId'] ) ? $txn_response['transId'] : $authnet_transaction_id;

		if ( '2' === $response_code || '3' === $response_code ) {
			$errors = isset( $txn_response['errors'] ) ? $txn_response['errors'] : array();
			$msg    = isset( $errors[0]['errorText'] ) ? $errors[0]['errorText'] : __( 'Void failed.', 'peachpay-for-woocommerce' );

			return array(
				'success' => false,
				'message' => $msg,
			);
		}

		// Refresh transaction details after void.
		$transaction_details = self::get_transaction_details( $authnet_mode, $txn_id );
		$data                = array(
			'transaction_details' => $transaction_details ? $transaction_details : array(
				'transId'           => $txn_id,
				'transactionStatus' => 'voided',
			),
		);

		self::calculate_payment_state( $order, $data );

		return array(
			'success' => true,
			'message' => 'Success.',
		);
	}

	/**
	 * Refunds an Authorize.net transaction.
	 *
	 * The $refund_params array must contain at minimum:
	 *   - transactionType => 'refundTransaction'
	 *   - amount          => numeric string
	 *
	 * This method injects the required payment info (creditCard last 4 or
	 * bankAccount details) and refTransId from stored transaction details.
	 *
	 * @param WC_Order $order         The order with an Authorize.net transaction.
	 * @param array    $refund_params Refund parameters built by the gateway.
	 * @return array|WP_Error {
	 *     @type bool   $success
	 *     @type string $message
	 * }
	 */
	public static function refund_payment( $order, $refund_params ) {
		$authnet_mode = PeachPay_Authnet_Order_Data::get_peachpay( $order, 'authnet_mode' );
		$payment      = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'payment' );

		// Inject payment info required by Authorize.net refundTransaction.
		if ( null !== $payment && isset( $payment['creditCard'] ) ) {
			$card_num                 = $payment['creditCard']['cardNumber'];
			$refund_params['payment'] = array(
				'creditCard' => array(
					'cardNumber'     => $card_num,
					'expirationDate' => 'XXXX',
				),
			);
		} elseif ( null !== $payment && isset( $payment['bankAccount'] ) ) {
			$refund_params['payment'] = array(
				'bankAccount' => array(
					'accountType'   => 'checking',
					'routingNumber' => $payment['bankAccount']['routingNumber'],
					'accountNumber' => $payment['bankAccount']['accountNumber'],
					'nameOnAccount' => $payment['bankAccount']['nameOnAccount'],
					'echeckType'    => $payment['bankAccount']['echeckType'],
				),
			);
		}

		$ref_transaction_id = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'refTransId' );
		$transaction_id     = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'transId' );
		$id                 = $ref_transaction_id ? $ref_transaction_id : $transaction_id;

		if ( ! $id ) {
			return new \WP_Error( 'wc_' . $order->get_id() . '_refund_failed', __( 'Refund error: Invalid Transaction ID', 'peachpay-for-woocommerce' ) );
		}

		$refund_params['refTransId'] = $id;

		$refund_params['order'] = array(
			'invoiceNumber' => $order->get_order_number(),
			'description'   => PeachPay_Authnet_Payment_Gateway::get_payment_description( $order ),
		);

		$body = array(
			'createTransactionRequest' => array(
				'merchantAuthentication' => self::merchant_auth( $authnet_mode ),
				'transactionRequest'     => $refund_params,
			),
		);

		$response = self::request( $authnet_mode, $body );

		if ( is_wp_error( $response ) ) {
			return array(
				'success' => false,
				'message' => sprintf(
					// translators: The failure reason.
					__( 'Authorize.net API request failed: %s', 'peachpay-for-woocommerce' ),
					$response->get_error_message()
				),
			);
		}

		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';
		if ( 'Ok' !== $result_code ) {
			$error_text = isset( $response['messages']['message'][0]['text'] )
				? $response['messages']['message'][0]['text']
				: __( 'Unknown error from Authorize.net.', 'peachpay-for-woocommerce' );

			return array(
				'success' => false,
				'message' => $error_text,
			);
		}

		$txn_response  = isset( $response['transactionResponse'] ) ? $response['transactionResponse'] : array();
		$response_code = isset( $txn_response['responseCode'] ) ? $txn_response['responseCode'] : '';
		$txn_id        = isset( $txn_response['transId'] ) ? $txn_response['transId'] : $transaction_id;

		if ( '2' === $response_code || '3' === $response_code ) {
			$errors = isset( $txn_response['errors'] ) ? $txn_response['errors'] : array();
			$msg    = isset( $errors[0]['errorText'] ) ? $errors[0]['errorText'] : __( 'Refund failed.', 'peachpay-for-woocommerce' );

			return array(
				'success' => false,
				'message' => $msg,
			);
		}

		// Refresh transaction details after refund.
		$transaction_details = self::get_transaction_details( $authnet_mode, $txn_id );
		$data                = array(
			'transaction_details' => $transaction_details ? $transaction_details : array(
				'transId'      => $txn_id,
				'settleAmount' => $refund_params['amount'],
			),
		);

		self::calculate_payment_state( $order, $data );

		$refunded      = $order->get_remaining_refund_amount();
		$refund_id     = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'transId' );
		$settle_amount = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'settleAmount' );
		$refund_amount = wc_price( $settle_amount, array( 'currency' => $order->get_currency() ) );

		if ( $refunded <= 0 ) {
			// translators: %1$s the payment method title, %2$s Refund amount, %3$s Refund Id.
			$order->add_order_note( sprintf( __( 'Authorize.net %1$s payment refunded %2$s (Transaction Id: %3$s)', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $refund_amount, $refund_id ) );
		} else {
			// translators: %1$s the payment method title, %2$s Refund amount, %3$s Refund Id.
			$order->add_order_note( sprintf( __( 'Authorize.net %1$s payment partially refunded %2$s (Transaction Id: %3$s)', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $refund_amount, $refund_id ) );
		}

		return array(
			'success' => true,
			'message' => 'Success.',
		);
	}

	// -------------------------------------------------------------------------
	// CIM (Customer Information Manager) — subscription support
	// -------------------------------------------------------------------------

	/**
	 * Creates a CIM customer profile in Authorize.net from an opaqueData token.
	 *
	 * Called after a successful initial subscription checkout charge. Failure
	 * does NOT reverse the payment — it is logged as a warning.
	 *
	 * @deprecated Superseded by the `createProfile: true` flag in the `createTransactionRequest`
	 *             body. Profile creation now happens inline with the transaction. This method is
	 *             no longer called and can be removed in a future cleanup pass.
	 *
	 * @param WC_Order $order       The order.
	 * @param array    $opaque_data Array with 'descriptor' and 'value' keys from Accept.js.
	 * @param string   $mode        'test' or 'live'.
	 * @return array{customer_profile_id: string, payment_profile_id: string}|null
	 */
	public static function create_customer_profile( $order, $opaque_data, $mode ) {
		$user_id              = $order->get_user_id();
		$merchant_customer_id = $user_id ? 'wp_' . $user_id : 'guest_' . $order->get_id();

		$body = array(
			'createCustomerProfileRequest' => array(
				'merchantAuthentication' => self::merchant_auth( $mode ),
				'profile'                => array(
					'merchantCustomerId' => $merchant_customer_id,
					'email'              => $order->get_billing_email(),
					'paymentProfiles'    => array(
						// WARNING: The paymentProfiles parameters are ordered this way because the API
						// endpoint rejects out of order elements. Required order: customerType, billTo, payment.
						'customerType' => 'individual',
						'billTo'       => array(
							'firstName' => peachpay_truncate_str( $order->get_billing_first_name(), 50 ),
							'lastName'  => peachpay_truncate_str( $order->get_billing_last_name(), 50 ),
							'address'   => peachpay_truncate_str( $order->get_billing_address_1(), 60 ),
							'city'      => peachpay_truncate_str( $order->get_billing_city(), 40 ),
							'state'     => peachpay_truncate_str( $order->get_billing_state(), 40 ),
							'zip'       => peachpay_truncate_str( $order->get_billing_postcode(), 20 ),
							'country'   => peachpay_truncate_str( $order->get_billing_country(), 60 ),
						),
						'payment'      => array(
							'opaqueData' => array(
								'dataDescriptor' => $opaque_data['descriptor'],
								'dataValue'      => $opaque_data['value'],
							),
						),
					),
				),
			),
		);

		$response = self::request( $mode, $body );

		if ( is_wp_error( $response ) ) {
			peachpay_log( 'warning', 'Authnet CIM profile creation HTTP error: ' . $response->get_error_message(), array( 'order_id' => $order->get_id() ) );
			return null;
		}

		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';

		if ( 'Ok' !== $result_code ) {
			$error_code = isset( $response['messages']['message'][0]['code'] ) ? $response['messages']['message'][0]['code'] : '';

			// E00039 = duplicate profile — extract existing customerProfileId.
			if ( 'E00039' === $error_code ) {
				return self::handle_duplicate_profile( $response, $opaque_data, $mode );
			}

			$error_text = isset( $response['messages']['message'][0]['text'] ) ? $response['messages']['message'][0]['text'] : '';
			peachpay_log( 'warning', 'Authnet CIM profile creation failed: ' . $error_text, array( 'order_id' => $order->get_id(), 'error_code' => $error_code ) );
			return null;
		}

		$customer_profile_id = isset( $response['customerProfileId'] ) ? $response['customerProfileId'] : null;
		$payment_profile_id  = isset( $response['customerPaymentProfileIdList'][0] ) ? $response['customerPaymentProfileIdList'][0] : null;

		if ( ! $customer_profile_id || ! $payment_profile_id ) {
			peachpay_log( 'warning', 'Authnet CIM profile response missing IDs.', array( 'order_id' => $order->get_id() ) );
			return null;
		}

		return array(
			'customer_profile_id' => $customer_profile_id,
			'payment_profile_id'  => $payment_profile_id,
		);
	}

	/**
	 * Handles the E00039 duplicate profile error by creating a new payment profile
	 * under the existing customer profile.
	 *
	 * @param array  $response    The E00039 API response.
	 * @param array  $opaque_data Array with 'descriptor' and 'value' keys.
	 * @param string $mode        'test' or 'live'.
	 * @return array{customer_profile_id: string, payment_profile_id: string}|null
	 */
	private static function handle_duplicate_profile( $response, $opaque_data, $mode ) {
		$error_text = isset( $response['messages']['message'][0]['text'] ) ? $response['messages']['message'][0]['text'] : '';

		// Authorize.net embeds the existing ID in the error text:
		// "A duplicate record with ID 1234567890 already exists."
		preg_match( '/A duplicate record with ID (\d+) already exists/', $error_text, $matches );
		$customer_profile_id = isset( $matches[1] ) ? $matches[1] : null;

		if ( ! $customer_profile_id ) {
			peachpay_log( 'warning', 'Authnet CIM E00039 but could not extract customerProfileId from: ' . $error_text );
			return null;
		}

		$body = array(
			'createCustomerPaymentProfileRequest' => array(
				'merchantAuthentication' => self::merchant_auth( $mode ),
				'customerProfileId'      => $customer_profile_id,
				'paymentProfile'         => array(
					'payment' => array(
						'opaqueData' => array(
							'dataDescriptor' => $opaque_data['descriptor'],
							'dataValue'      => $opaque_data['value'],
						),
					),
				),
				'validationMode'         => 'none',
			),
		);

		$pp_response = self::request( $mode, $body );

		if ( is_wp_error( $pp_response ) ) {
			peachpay_log( 'warning', 'Authnet createCustomerPaymentProfileRequest HTTP error: ' . $pp_response->get_error_message() );
			return null;
		}

		$result_code = isset( $pp_response['messages']['resultCode'] ) ? $pp_response['messages']['resultCode'] : '';

		if ( 'Ok' !== $result_code ) {
			$pp_error_text = isset( $pp_response['messages']['message'][0]['text'] ) ? $pp_response['messages']['message'][0]['text'] : '';
			peachpay_log( 'warning', 'Authnet createCustomerPaymentProfileRequest failed: ' . $pp_error_text );
			return null;
		}

		$payment_profile_id = isset( $pp_response['customerPaymentProfileId'] ) ? $pp_response['customerPaymentProfileId'] : null;

		if ( ! $payment_profile_id ) {
			peachpay_log( 'warning', 'Authnet createCustomerPaymentProfileRequest response missing payment profile ID.' );
			return null;
		}

		return array(
			'customer_profile_id' => $customer_profile_id,
			'payment_profile_id'  => $payment_profile_id,
		);
	}

	/**
	 * Charges a stored CIM profile for a subscription renewal.
	 *
	 * Uses authCaptureTransaction with MIT flags (isSubsequentAuth + authorizationIndicator).
	 * Does NOT modify order state — the caller handles success/failure.
	 *
	 * @param WC_Order $order               The parent order (used for networkTransId and currency).
	 * @param string   $customer_profile_id Authorize.net customerProfileId.
	 * @param string   $payment_profile_id  Authorize.net customerPaymentProfileId.
	 * @param float    $amount              The renewal amount.
	 * @param string   $mode               'test' or 'live'.
	 * @return array{success: bool, message: string, trans_id?: string, transaction_details?: array}
	 */
	public static function charge_customer_profile( $order, $customer_profile_id, $payment_profile_id, $amount, $mode ) {
		// WARNING: element order matches Authorize.net XSD sequence.
		// subsequentAuthInformation must appear before authorizationIndicatorType.
		$transaction_params = array(
			'transactionType'   => 'authCaptureTransaction',
			'amount'            => self::format_amount( $amount, $order->get_currency() ),
			'profile'           => array(
				'customerProfileId' => $customer_profile_id,
				'paymentProfile'    => array(
					'paymentProfileId' => $payment_profile_id,
				),
			),
			'order'             => array(
				'invoiceNumber' => $order->get_order_number(),
			),
		);

		// Only include processingOptions + subsequentAuthInformation when we have the original
		// network transaction ID — Authorize.net rejects isSubsequentAuth=true without it (E00027).
		$network_trans_id = PeachPay_Authnet_Order_Data::get_network_trans_id( $order );
		if ( $network_trans_id ) {
			$transaction_params['processingOptions']         = array(
				'isSubsequentAuth' => 'true',
			);
			$transaction_params['subsequentAuthInformation'] = array(
				'originalNetworkTransId' => $network_trans_id,
				'reason'                 => 'resubmission',
			);
		}

		$transaction_params['authorizationIndicatorType'] = array(
			'authorizationIndicator' => 'final',
		);

		$body = array(
			'createTransactionRequest' => array(
				'merchantAuthentication' => self::merchant_auth( $mode ),
				'transactionRequest'     => $transaction_params,
			),
		);

		$response = self::request( $mode, $body );

		if ( is_wp_error( $response ) ) {
			return array(
				'success' => false,
				'message' => sprintf(
					// translators: %s: error message.
					__( 'Authorize.net API request failed: %s', 'peachpay-for-woocommerce' ),
					$response->get_error_message()
				),
			);
		}

		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';

		if ( 'Ok' !== $result_code ) {
			$error_text = isset( $response['messages']['message'][0]['text'] )
				? $response['messages']['message'][0]['text']
				: __( 'Unknown error from Authorize.net.', 'peachpay-for-woocommerce' );

			return array(
				'success' => false,
				'message' => $error_text,
			);
		}

		$txn_response  = isset( $response['transactionResponse'] ) ? $response['transactionResponse'] : array();
		$response_code = isset( $txn_response['responseCode'] ) ? $txn_response['responseCode'] : '';
		$txn_id        = isset( $txn_response['transId'] ) ? $txn_response['transId'] : '';

		if ( '2' === $response_code || '3' === $response_code ) {
			$errors = isset( $txn_response['errors'] ) ? $txn_response['errors'] : array();
			$msg    = isset( $errors[0]['errorText'] ) ? $errors[0]['errorText'] : __( 'Transaction declined or errored.', 'peachpay-for-woocommerce' );

			return array(
				'success' => false,
				'message' => $msg,
			);
		}

		$transaction_details = null;
		if ( $txn_id && '0' !== $txn_id ) {
			$transaction_details = self::get_transaction_details( $mode, $txn_id );
		}

		return array(
			'success'             => true,
			'message'             => '',
			'trans_id'            => $txn_id,
			'transaction_details' => $transaction_details ? $transaction_details : array(
				'transId'           => $txn_id,
				'transactionStatus' => self::response_code_to_status( $response_code, $txn_response ),
				'responseCode'      => $response_code,
				'settleAmount'      => $amount,
			),
		);
	}

	/**
	 * Fetches an existing CIM customer profile and returns the first payment profile ID.
	 *
	 * @param string $customer_profile_id Authorize.net customerProfileId.
	 * @param string $mode 'live' or 'sandbox'.
	 * @return array{customer_profile_id: string, payment_profile_id: string}|null
	 */
	public static function get_customer_profile( $customer_profile_id, $mode ) {
		$body = array(
			'getCustomerProfileRequest' => array(
				'merchantAuthentication' => self::merchant_auth( $mode ),
				'customerProfileId'      => $customer_profile_id,
				'includeIssuerInfo'      => 'false',
			),
		);

		$response = self::request( $mode, $body );

		if ( is_wp_error( $response ) ) {
			peachpay_log( 'warning', 'Authnet getCustomerProfileRequest HTTP error: ' . $response->get_error_message() );
			return null;
		}

		$result_code = isset( $response['messages']['resultCode'] ) ? $response['messages']['resultCode'] : '';
		if ( 'Ok' !== $result_code ) {
			$error_text = isset( $response['messages']['message'][0]['text'] ) ? $response['messages']['message'][0]['text'] : 'unknown';
			peachpay_log( 'warning', 'Authnet getCustomerProfileRequest failed: ' . $error_text );
			return null;
		}

		$payment_profile_id = isset( $response['profile']['paymentProfiles'][0]['customerPaymentProfileId'] )
			? $response['profile']['paymentProfiles'][0]['customerPaymentProfileId']
			: null;

		if ( ! $payment_profile_id ) {
			peachpay_log( 'warning', 'Authnet getCustomerProfileRequest: no payment profiles found for customerProfileId ' . $customer_profile_id );
			return null;
		}

		return array(
			'customer_profile_id' => $customer_profile_id,
			'payment_profile_id'  => $payment_profile_id,
		);
	}

	// -------------------------------------------------------------------------
	// Helpers
	// -------------------------------------------------------------------------

	/**
	 * Formats an amount within the constraints that Authnet expects.
	 *
	 * @param float  $amount        The amount to format.
	 * @param string $currency_code The currency to format the amount for.
	 * @return string
	 */
	public static function format_amount( $amount, $currency_code = 'USD' ) {
		if ( array_key_exists( $currency_code, self::ZERO_DECIMAL_CURRENCIES ) ) {
			return number_format( $amount, 0, '.', '' );
		}

		return number_format( $amount, 2, '.', '' );
	}

	/**
	 * Maps an Authorize.net responseCode string to a transactionStatus string
	 * that calculate_payment_state() understands.
	 *
	 * This is used as a fallback when getTransactionDetailsRequest fails or
	 * returns no data (e.g. for a $0 test).
	 *
	 * responseCode values (always strings):
	 *   '1' => approved
	 *   '2' => declined
	 *   '3' => error
	 *   '4' => held for review (FDSPendingReview)
	 *
	 * @param string $response_code The responseCode string from Authorize.net.
	 * @param array  $txn_response  The full transactionResponse array.
	 * @return string
	 */
	private static function response_code_to_status( $response_code, $txn_response ) {
		switch ( $response_code ) {
			case '1':
				return 'capturedPendingSettlement';
			case '2':
				return 'declined';
			case '4':
				return 'FDSPendingReview';
			default:
				return 'declined';
		}
	}

	/**
	 * Calculates the correct order status from a transaction.
	 *
	 * @param WC_Order $order The WC order.
	 * @param array    $data  The Authnet transaction data — must contain ['transaction_details' => [...]].
	 */
	public static function calculate_payment_state( $order, $data ) {
		$old_transaction_status = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'transactionStatus' );

		if ( null !== $data && isset( $data['transaction_details'] ) ) {
			PeachPay_Authnet_Order_Data::set_transaction_details( $order, $data['transaction_details'] );
		}

		$authnet_transaction_id = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'transId' );
		$new_transaction_status = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'transactionStatus' );

		if ( $authnet_transaction_id ) {
			$order->set_transaction_id( $authnet_transaction_id );
		}

		if ( $new_transaction_status === $old_transaction_status && 'declined' !== $new_transaction_status ) {
			$order->save();
			return;
		}

		if ( 'capturedPendingSettlement' === $new_transaction_status ) {
			if ( $authnet_transaction_id ) {
				$order->payment_complete( $authnet_transaction_id );
				$amount = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'settleAmount' );
				// translators: %1$s Payment method title, %2$s amount ,%3$s transaction id.
				$order->add_order_note( sprintf( __( 'Authorize.net %1$s payment is complete for %2$s. (Transaction Id: %3$s)', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), wc_price( $amount, array( 'currency' => $order->get_currency() ) ), $authnet_transaction_id ) );
			}
		} elseif ( 'authorizedPendingCapture' === $new_transaction_status ) {
			if ( $order->get_status() === 'on-hold' ) {
				// translators: %1$s Payment method title, %2$s amount ,%3$s transaction id.
				$order->add_order_note( sprintf( __( 'Authorize.net %1$s payment authorized for %2$s. (Transaction Id: %3$s)', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ), $authnet_transaction_id ) );
			} else {
				// translators: %1$s Payment method title, %2$s amount ,%3$s transaction id.
				$order->set_status( 'on-hold', sprintf( __( 'Authorize.net %1$s payment authorized for %2$s. (Transaction Id: %3$s)', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ), $authnet_transaction_id ) );
			}
		} elseif ( 'voided' === $new_transaction_status ) {
			// translators: %1$s Payment method title, %2$s transaction id.
			$order->set_status( 'cancelled', sprintf( __( 'Authorize.net %1$s payment was voided. (Transaction Id: %2$s)', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $authnet_transaction_id ) );
		} elseif ( 'FDSAuthorizedPendingReview' === $new_transaction_status ) {
			// translators: %1$s Payment method title, %2$s transaction id.
			$order->set_status( 'on-hold', sprintf( __( 'Authorize.net %1$s payment authorized but held pending review. Manage the payment in the Authorize.net MINT dashboard. (Transaction Id: %2$s)', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $authnet_transaction_id ) );
		} elseif ( 'FDSPendingReview' === $new_transaction_status ) {
			// translators: %1$s Payment method title, %2$s transaction id.
			$order->set_status( 'on-hold', sprintf( __( 'Authorize.net %1$s payment held pending review. Manage the payment in the Authorize.net MINT dashboard. (Transaction Id: %2$s)', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $authnet_transaction_id ) );
		} elseif ( 'declined' === $new_transaction_status ) {
			$order->set_status( 'failed' );
			$decline_reason = PeachPay_Authnet_Order_Data::get_transaction_details( $order, 'responseReasonDescription' );
			if ( $decline_reason ) {
				if ( function_exists( 'wc_add_notice' ) ) {
					wc_add_notice( __( 'Payment error: ', 'peachpay-for-woocommerce' ) . $decline_reason, 'error' );
				}
				// translators: %1$s Payment method title, %2$s transaction id , %3$s reason
				$order->add_order_note( sprintf( __( 'Authorize.net %1$s payment was declined (Transaction Id: %2$s). Reason: %3$s', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $authnet_transaction_id, $decline_reason ) );
			} else {
				// translators: %1$s Payment method title, %2$s transaction id
				$order->add_order_note( sprintf( __( 'Authorize.net %1$s payment was declined (Transaction Id: %2$s).', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $authnet_transaction_id ) );
			}
		} elseif ( 'underReview' === $new_transaction_status ) {
			// translators: %1$s Payment method title, %2$s transaction id.
			$order->set_status( 'on-hold', sprintf( __( 'Authorize.net %1$s payment under review. Manage the payment in the Authorize.net MINT dashboard. (Transaction Id: %2$s)', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $authnet_transaction_id ) );
		}

		$order->save();
	}

}
