<?php
/**
 * PeachPay Square util class.
 *
 * @package PeachPay
 */

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

/**
 * Square payment utility class. Handles direct REST calls to Square for
 * creating payments, processing refunds, and managing customer records.
 */
final class PeachPay_Square {

	/**
	 * Zero-decimal currencies where amounts are already in the smallest unit.
	 * For these currencies, do NOT multiply by 100.
	 */
	const ZERO_DECIMAL_CURRENCIES = array(
		'BIF',
		'CLP',
		'DJF',
		'GNF',
		'JPY',
		'KMF',
		'KRW',
		'MGA',
		'PYG',
		'RWF',
		'UGX',
		'VND',
		'VUV',
		'XAF',
		'XOF',
		'XPF',
		'TWD',
	);

	/**
	 * Formats a WooCommerce amount (e.g. 10.50) into the smallest currency unit
	 * for Square API calls (e.g. 1050 for USD, 10 for JPY).
	 *
	 * @param string|float $amount The WooCommerce order amount.
	 * @param string       $currency_code The ISO currency code (e.g. 'USD').
	 * @return int Amount in smallest currency unit.
	 */
	public static function format_amount( $amount, $currency_code ) {
		$amount = floatval( $amount );

		if ( in_array( strtoupper( $currency_code ), self::ZERO_DECIMAL_CURRENCIES, true ) ) {
			return (int) round( $amount );
		}

		return (int) round( $amount * 100 );
	}

	/**
	 * Converts an amount from smallest currency unit back to display format.
	 * (e.g. 1050 -> 10.50 for USD, 1050 -> 1050 for JPY).
	 *
	 * @param int    $amount Amount in smallest currency unit.
	 * @param string $currency_code The ISO currency code.
	 * @return float Display amount.
	 */
	public static function display_amount( $amount, $currency_code ) {
		$amount = floatval( $amount );

		if ( ! in_array( strtoupper( $currency_code ), self::ZERO_DECIMAL_CURRENCIES, true ) ) {
			$amount = $amount / 100;
		}

		return $amount;
	}

	/**
	 * Builds a URL to the Square Dashboard for a given transaction.
	 *
	 * @param string $square_mode 'live' or 'test'.
	 * @param string $payment_id  The Square payment ID.
	 * @return string The dashboard URL.
	 */
	public static function dashboard_url( $square_mode, $payment_id ) {
		if ( 'test' === $square_mode ) {
			// Square sandbox has no deep-link to individual transactions.
			// Link to the developer console log for this app instead.
			$app_id = PeachPay_Square_Credentials::get_publishable_key( 'live' );
			if ( empty( $app_id ) ) {
				return 'https://developer.squareup.com/apps';
			}
			return 'https://developer.squareup.com/apps/' . rawurlencode( $app_id ) . '/log';
		}

		return 'https://app.squareup.com/dashboard/sales/transactions/' . rawurlencode( $payment_id );
	}

	/**
	 * Creates a Square payment directly via Square REST.
	 *
	 * @param WC_Order $order The WooCommerce order.
	 * @param string   $source_id The payment source nonce from the frontend Web Payments SDK.
	 * @param string   $verification_token SCA/3DS verification token (optional, from frontend).
	 * @param string   $mode Square mode ('test' or 'live').
	 * @param string   $customer_id Optional Square customer ID for saved cards.
	 * @return array|null Payment details array on success, error array on failure, null on config error.
	 */
	public static function create_payment( $order, $source_id, $verification_token = null, $mode = 'detect', $customer_id = null ) {
		$square_mode = PeachPay_Square_Integration::mode( $mode );
		$client      = PeachPay_Square_Credentials::get_rest_client( $square_mode );

		if ( ! $client ) {
			peachpay_log( 'error', '[Square] create_payment: Square REST client not configured (order #' . $order->get_id() . ')' );
			$order->update_status( 'failed', __( 'Square is not configured. Please add your API keys.', 'peachpay-for-woocommerce' ) );
			return null;
		}

		$currency    = strtoupper( $order->get_currency() );
		$amount      = self::format_amount( $order->get_total(), $currency );
		$location_id = PeachPay_Square_Credentials::get_location_id();

		$params = array(
			'source_id'       => $source_id,
			'idempotency_key' => wp_generate_uuid4(),
			'amount_money'    => array(
				'amount'   => $amount,
				'currency' => $currency,
			),
			'location_id'     => $location_id,
			'reference_id'    => (string) $order->get_id(),
			'note'            => sprintf(
				// translators: %1$s order number, %2$s site name.
				__( 'WooCommerce Order #%1$s via %2$s', 'peachpay-for-woocommerce' ),
				$order->get_order_number(),
				get_bloginfo( 'name' )
			),
		);

		if ( ! empty( $verification_token ) ) {
			$params['verification_token'] = $verification_token;
		}

		if ( ! empty( $customer_id ) ) {
			$params['customer_id'] = $customer_id;
		}

		$response = $client->create_payment( $params );
		if ( is_wp_error( $response ) ) {
			$error_data    = self::get_error_data( $response );
			$error_message = self::safe_error_message( $error_data, __( 'API request failed. Please verify your Square configuration and try again.', 'peachpay-for-woocommerce' ) );
			peachpay_log(
				'error',
				'[Square] create_payment REST error: code=' . ( isset( $error_data['code'] ) ? $error_data['code'] : '' )
				. ' category=' . ( isset( $error_data['category'] ) ? $error_data['category'] : '' )
				. ' detail=' . $error_message
				. ' (order #' . $order->get_id() . ')'
			);
			if ( function_exists( 'wc_add_notice' ) ) {
				wc_add_notice( __( 'Payment error: ', 'peachpay-for-woocommerce' ) . $error_message, 'error' );
			}
			// translators: %1$s Payment method title, %2$s error message.
			$order->update_status( 'failed', sprintf( __( 'Square %1$s payment failed: %2$s', 'peachpay-for-woocommerce' ), $order->get_payment_method_title(), $error_message ) );
			return array(
				'error'   => true,
				'message' => $error_message,
			);
		}

		$payment = isset( $response['payment'] ) && is_array( $response['payment'] ) ? $response['payment'] : null;
		if ( null === $payment ) {
			$serialized_errors = self::serialize_square_errors( isset( $response['errors'] ) ? $response['errors'] : array() );
			$message           = self::build_square_error_message( $serialized_errors, __( 'Payment failed with no details.', 'peachpay-for-woocommerce' ) );
			peachpay_log(
				'error',
				'[Square] create_payment: ' . $message
				. ' | errors=' . wp_json_encode( $serialized_errors )
				. ' (order #' . $order->get_id() . ')'
			);
			$order->update_status( 'failed', $message );
			return array(
				'error'   => true,
				'message' => $message,
			);
		}

		// Re-fetch the payment to keep processing_fee and refunded_money aggregates populated.
		$fetched_response = $client->get_payment( $payment['id'] );
		if ( is_wp_error( $fetched_response ) || empty( $fetched_response['payment'] ) || ! is_array( $fetched_response['payment'] ) ) {
			$payment_details = $payment;
		} else {
			$payment_details = $fetched_response['payment'];
		}

		$payment_details['mode'] = $square_mode;

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

		return $payment_details;
	}

	/**
	 * Serializes Square SDK error objects into log-safe arrays.
	 *
	 * @param array $errors Square error objects.
	 * @return array
	 */
	private static function serialize_square_errors( $errors ) {
		$serialized = array();
		if ( ! is_array( $errors ) ) {
			return $serialized;
		}

		foreach ( $errors as $error ) {
			if ( is_object( $error ) && method_exists( $error, 'getCode' ) ) {
				$serialized[] = array(
					'code'     => method_exists( $error, 'getCode' ) ? $error->getCode() : '',
					'category' => method_exists( $error, 'getCategory' ) ? $error->getCategory() : '',
					'detail'   => method_exists( $error, 'getDetail' ) ? $error->getDetail() : '',
					'field'    => method_exists( $error, 'getField' ) ? $error->getField() : '',
				);
				continue;
			}

			if ( is_array( $error ) ) {
				$serialized[] = array(
					'code'     => isset( $error['code'] ) ? $error['code'] : '',
					'category' => isset( $error['category'] ) ? $error['category'] : '',
					'detail'   => isset( $error['detail'] ) ? $error['detail'] : '',
					'field'    => isset( $error['field'] ) ? $error['field'] : '',
				);
			}
		}

		return $serialized;
	}

	/**
	 * Builds a user-safe message from Square errors, using error category to determine
	 * what is safe to show to the buyer vs. what should only go to logs.
	 *
	 * - AUTHENTICATION_ERROR: Merchant credential problem — never expose raw details to buyers.
	 * - PAYMENT_METHOD_ERROR: Card-level issue — safe to show code + detail to buyers.
	 * - All other categories: Return the fallback message.
	 *
	 * @param array  $errors Serialized Square errors (from serialize_square_errors()).
	 * @param string $fallback Fallback message if no actionable error details are available.
	 * @return string
	 */
	private static function build_square_error_message( $errors, $fallback ) {
		if ( ! is_array( $errors ) || empty( $errors[0] ) || ! is_array( $errors[0] ) ) {
			return $fallback;
		}

		$first    = $errors[0];
		$category = isset( $first['category'] ) ? trim( (string) $first['category'] ) : '';
		$code     = isset( $first['code'] ) ? trim( (string) $first['code'] ) : '';
		$detail   = isset( $first['detail'] ) ? trim( (string) $first['detail'] ) : '';

		// Authentication errors are a merchant configuration problem — the buyer cannot fix them.
		// Never expose the raw Square error code or detail to buyers.
		if ( 'AUTHENTICATION_ERROR' === $category ) {
			return __( 'Payment is temporarily unavailable. Please try another payment method or contact the store for assistance.', 'peachpay-for-woocommerce' );
		}

		// Payment method errors (card declined, invalid CVV, etc.) are buyer-actionable.
		if ( 'PAYMENT_METHOD_ERROR' === $category ) {
			if ( ! empty( $code ) && ! empty( $detail ) ) {
				return $code . ': ' . $detail;
			}
			if ( ! empty( $detail ) ) {
				return $detail;
			}
		}

		return $fallback;
	}

	/**
	 * Extract normalized error array from a WP_Error response.
	 *
	 * @param WP_Error $error WP error from REST adapter.
	 * @return array
	 */
	private static function get_error_data( $error ) {
		if ( ! is_wp_error( $error ) ) {
			return array();
		}

		$data = $error->get_error_data();
		return is_array( $data ) ? $data : array();
	}

	/**
	 * Builds a buyer-safe message from normalized error data.
	 *
	 * @param array  $error_data Normalized error data.
	 * @param string $fallback Fallback message.
	 * @return string
	 */
	private static function safe_error_message( $error_data, $fallback ) {
		if ( class_exists( 'PeachPay_Square_Error' ) ) {
			return PeachPay_Square_Error::safe_message( $error_data, $fallback );
		}

		return self::build_square_error_message( array( $error_data ), $fallback );
	}

	/**
	 * Refunds a Square payment directly via Square REST.
	 *
	 * @param WC_Order    $order The WooCommerce order to refund.
	 * @param int|null    $amount Amount to refund in smallest currency unit. Null for full refund.
	 * @param string|null $reason Reason for the refund.
	 * @return array Result with 'success' and 'message' keys.
	 */
	public static function refund_payment( $order, $amount = null, $reason = null ) {
		$mode   = PeachPay_Square_Order_Data::get_payment( $order, 'mode' );
		$client = PeachPay_Square_Credentials::get_rest_client( $mode );

		if ( ! $client ) {
			peachpay_log( 'error', '[Square] refund_payment: Square REST client not configured (order #' . $order->get_id() . ')' );
			return array(
				'success' => false,
				'message' => __( 'Square is not configured.', 'peachpay-for-woocommerce' ),
			);
		}

		$payment_id = PeachPay_Square_Order_Data::get_payment( $order, 'id' );
		if ( empty( $payment_id ) ) {
			peachpay_log( 'error', '[Square] refund_payment: No payment ID found (order #' . $order->get_id() . ')' );
			return array(
				'success' => false,
				'message' => __( 'No Square payment ID found for this order.', 'peachpay-for-woocommerce' ),
			);
		}

		$currency = strtoupper( $order->get_currency() );

		// If no amount specified, refund the full remaining amount.
		if ( null === $amount ) {
			$total_money = PeachPay_Square_Order_Data::get_payment( $order, 'total_money' );
			$amount      = isset( $total_money['amount'] ) ? intval( $total_money['amount'] ) : self::format_amount( $order->get_total(), $currency );
			$amount     -= PeachPay_Square_Order_Data::total_refunds( $order );
		}

		$refund_params = array(
			'idempotency_key' => wp_generate_uuid4(),
			'payment_id'      => $payment_id,
			'amount_money'    => array(
				'amount'   => $amount,
				'currency' => $currency,
			),
		);

		if ( ! empty( $reason ) ) {
			$refund_params['reason'] = $reason;
		}

		$refund_response = $client->refund_payment( $refund_params );
		if ( is_wp_error( $refund_response ) ) {
			$error_data = self::get_error_data( $refund_response );
			$message    = self::safe_error_message( $error_data, __( 'Refund failed.', 'peachpay-for-woocommerce' ) );
			peachpay_log( 'error', '[Square] refund_payment REST error: ' . $message . ' (order #' . $order->get_id() . ')' );
			return array(
				'success' => false,
				'message' => $message,
			);
		}

		$refund_data = isset( $refund_response['refund'] ) && is_array( $refund_response['refund'] ) ? $refund_response['refund'] : null;
		if ( null === $refund_data ) {
			$serialized_errors = self::serialize_square_errors( isset( $refund_response['errors'] ) ? $refund_response['errors'] : array() );
			$message           = self::build_square_error_message( $serialized_errors, __( 'Refund failed with no details.', 'peachpay-for-woocommerce' ) );
			peachpay_log( 'error', '[Square] refund_payment: ' . $message . ' (order #' . $order->get_id() . ')' );
			return array(
				'success' => false,
				'message' => $message,
			);
		}

		// Re-fetch the payment to get updated refunded_money and processing_fee.
		$updated_response = $client->get_payment( $payment_id );
		if ( ! is_wp_error( $updated_response ) && ! empty( $updated_response['payment'] ) && is_array( $updated_response['payment'] ) ) {
			$updated_details         = $updated_response['payment'];
			$updated_details['mode'] = $mode;
			PeachPay_Square_Order_Data::set_payment_details( $order, $updated_details );
		}

		// Add order note.
		$refund_display = wc_price(
			self::display_amount( $amount, $currency ),
			array( 'currency' => $currency )
		);
		$refund_id = isset( $refund_data['id'] ) ? $refund_data['id'] : '';

		// Record the Square refund ID so the webhook handler can identify WooCommerce-initiated refunds.
		if ( ! empty( $refund_id ) ) {
			PeachPay_Square_Order_Data::add_wc_initiated_refund_id( $order, $refund_id );
			peachpay_log( 'debug', '[Square] refund_payment: stored WC-initiated refund ID | ' . wp_json_encode( array( 'order_id' => $order->get_id(), 'order_number' => $order->get_order_number(), 'refund_id' => $refund_id ) ) );
		}

		// translators: %1$s Payment method title, %2$s Refund amount, %3$s Refund Id.
		$order->add_order_note(
			sprintf(
				__( 'Square %1$s payment refunded %2$s (Refund Id: %3$s)', 'peachpay-for-woocommerce' ),
				$order->get_payment_method_title(),
				$refund_display,
				$refund_id
			)
		);

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

	/**
	 * Retrieves a Square payment via REST.
	 *
	 * @param string                 $payment_id Payment id.
	 * @param "live"|"test"|"detect" $mode Square mode.
	 * @return array|null
	 */
	public static function get_payment( $payment_id, $mode = 'detect' ) {
		$square_mode = PeachPay_Square_Integration::mode( $mode );
		$client      = PeachPay_Square_Credentials::get_rest_client( $square_mode );

		if ( ! $client ) {
			peachpay_log( 'error', '[Square] get_payment: Square REST client not configured' );
			return null;
		}

		$response = $client->get_payment( $payment_id );
		if ( is_wp_error( $response ) ) {
			peachpay_log( 'error', '[Square] get_payment: ' . $response->get_error_message() );
			return null;
		}

		if ( empty( $response['payment'] ) || ! is_array( $response['payment'] ) ) {
			return null;
		}

		return $response['payment'];
	}

	/**
	 * Calculates the payment state and updates the WooCommerce order status.
	 * Maps Square payment statuses to WooCommerce order statuses.
	 *
	 * Square statuses: APPROVED, COMPLETED, PENDING, FAILED, CANCELED
	 *
	 * @param WC_Order   $order           The WooCommerce order.
	 * @param array|null $payment_details The Square payment details array.
	 * @param string     $reason          Optional reason message for status changes.
	 */
	public static function calculate_payment_state( $order, $payment_details = null, $reason = '' ) {
		if ( null === $payment_details ) {
			return;
		}

		// Store payment details in order meta.
		PeachPay_Square_Order_Data::set_payment_details( $order, $payment_details );

		$payment_id     = isset( $payment_details['id'] ) ? $payment_details['id'] : null;
		$payment_status = isset( $payment_details['status'] ) ? $payment_details['status'] : '';

		// Set Square payment ID as the WooCommerce transaction ID.
		if ( $payment_id ) {
			$order->set_transaction_id( $payment_id );
		}

		if ( 'COMPLETED' === $payment_status && ! $order->is_paid() ) {
			$order->payment_complete( $payment_id );
			// translators: %1$s Payment method title, %2$s payment id.
			$order->add_order_note(
				sprintf(
					__( 'Square %1$s payment complete (Payment Id: %2$s)', 'peachpay-for-woocommerce' ),
					$order->get_payment_method_title(),
					$payment_id
				)
			);
		} elseif ( 'APPROVED' === $payment_status ) {
			// translators: %1$s Payment method title, %2$s amount, %3$s payment id.
			$order->set_status(
				'on-hold',
				sprintf(
					__( 'Square %1$s payment authorized for %2$s (Payment Id: %3$s)', 'peachpay-for-woocommerce' ),
					$order->get_payment_method_title(),
					wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ),
					$payment_id
				)
			);
		} elseif ( 'PENDING' === $payment_status ) {
			// translators: %1$s Payment method title, %2$s payment id.
			$order->set_status(
				'on-hold',
				sprintf(
					__( 'Square %1$s payment is pending (Payment Id: %2$s)', 'peachpay-for-woocommerce' ),
					$order->get_payment_method_title(),
					$payment_id
				)
			);
		} elseif ( 'FAILED' === $payment_status ) {
			// translators: %1$s Payment method title, %2$s reason.
			$order->set_status(
				'failed',
				sprintf(
					__( 'Square %1$s payment failed. %2$s', 'peachpay-for-woocommerce' ),
					$order->get_payment_method_title(),
					$reason
				)
			);
		} elseif ( 'CANCELED' === $payment_status ) {
			// translators: %1$s Payment method title, %2$s reason.
			$order->set_status(
				'cancelled',
				sprintf(
					__( 'Square %1$s payment was canceled. %2$s', 'peachpay-for-woocommerce' ),
					$order->get_payment_method_title(),
					$reason
				)
			);
		}

		$order->save();
	}

	/**
	 * Gets the square customer for the logged in user or returns null if one does not exist.
	 *
	 * @param int                    $user_id The id of the logged in user.
	 * @param "live"|"test"|"detect" $mode The mode to store customer under.
	 */
	public static function get_customer( $user_id, $mode = 'detect' ) {
		$square_mode = PeachPay_Square_Integration::mode( $mode );

		$customer_id = get_user_meta( $user_id, "_peachpay_square_{$square_mode}_customer", true );
		if ( ! $customer_id || ! isset( $customer_id['customer_id'] ) ) {
			return null;
		}

		return $customer_id['customer_id'];
	}

	/**
	 * Adds a square customer for a WC customer.
	 *
	 * @param int                    $user_id The id of the logged in user.
	 * @param string                 $customer_id The square customer ID.
	 * @param "live"|"test"|"detect" $mode The mode to store customer under.
	 */
	public static function set_customer( $user_id, $customer_id, $mode = 'detect' ) {
		$square_mode = PeachPay_Square_Integration::mode( $mode );

		if ( ! self::user_needs_square_customer( $user_id ) ) {
			return false;
		}

		return add_user_meta(
			$user_id,
			"_peachpay_square_{$square_mode}_customer",
			array(
				'customer_id' => $customer_id,
			),
			true
		);
	}

	/**
	 * Unsets the square customer ID associated with the WC_Customer and permanently removes
	 * all associated cards with this customer!
	 *
	 * @param int                    $user_id The id of the logged in user.
	 * @param "live"|"test"|"detect" $mode Current square mode.
	 */
	public static function unset_customer( $user_id, $mode = 'detect' ) {
		$square_mode = PeachPay_Square_Integration::mode( $mode );

		delete_user_meta( $user_id, "_peachpay_square_{$square_mode}_customer" );
		foreach ( WC_Payment_Tokens::get_customer_tokens( $user_id ) as $token ) {
			if ( peachpay_starts_with( $token->get_gateway_id(), 'peachpay_square_' ) ) {
				$token->delete();
			}
		}
	}

	/**
	 * Creates a Square customer for the WooCommerce order's user and stores the customer ID
	 * in user meta. Returns the existing customer ID if one is already stored.
	 *
	 * @param WC_Order               $order The WooCommerce order.
	 * @param "live"|"test"|"detect" $mode  Square mode.
	 * @return string|null Square customer ID on success, null on failure.
	 */
	public static function create_customer( $order, $mode = 'detect' ) {
		$square_mode = PeachPay_Square_Integration::mode( $mode );
		$user_id     = $order->get_user_id();
		$recreate    = false;

		// Guests have no persistent user meta — skip customer creation to avoid orphaned Square customers.
		if ( 0 === $user_id ) {
			return null;
		}

		// Return existing customer ID if already stored — avoid creating duplicates.
		// But first verify the cached customer still exists in Square (guards against
		// stale meta from sandbox↔live switches, Square account reconnections, or
		// manual customer deletion from the Square Dashboard).
		$existing_customer_id = self::get_customer( $user_id, $square_mode );
		if ( ! empty( $existing_customer_id ) ) {
			$verify_client = PeachPay_Square_Credentials::get_rest_client( $square_mode );
			if ( ! $verify_client ) {
				return $existing_customer_id; // No client configured — conservative: keep meta.
			}

			$verify_response = $verify_client->get_customer( $existing_customer_id );
			if ( is_wp_error( $verify_response ) ) {
				$error_data = self::get_error_data( $verify_response );
				if ( isset( $error_data['status'] ) && 404 === absint( $error_data['status'] ) ) {
					// Stale meta — customer no longer exists in Square.
					peachpay_log( 'warning', '[Square] create_customer: cached customer ' . $existing_customer_id . ' not found in Square (stale meta), recreating.' );
					self::unset_customer( $user_id, $square_mode );
					$recreate = true;
					// Fall through to create a new customer below.
				} else {
					// Auth error, rate limit, network issue — do NOT delete meta.
					peachpay_log( 'error', '[Square] create_customer: customer verification failed for ' . $existing_customer_id . ', keeping cached ID.' );
					peachpay_notify_error( $verify_response );
					return $existing_customer_id;
				}
			} elseif ( ! empty( $verify_response['customer'] ) && is_array( $verify_response['customer'] ) ) {
				return $existing_customer_id; // Customer exists in Square — return cached ID as before.
			} else {
				return $existing_customer_id; // Unexpected shape — keep existing behavior.
			}
		}

		$client = PeachPay_Square_Credentials::get_rest_client( $square_mode );
		if ( ! $client ) {
			peachpay_log( 'error', '[Square] create_customer: Square REST client not configured (order #' . $order->get_id() . ')' );
			return null;
		}

		$customer_params = array(
			'given_name'      => $order->get_billing_first_name(),
			'family_name'     => $order->get_billing_last_name(),
			'email_address'   => $order->get_billing_email(),
			'reference_id'    => (string) $order->get_user_id(),
		);

		// Only send phone if it includes a country calling code (E.164-like) and
		// contains between 9–16 digits, as required by Square's Customers API.
		$phone        = $order->get_billing_phone();
		$phone_digits = preg_replace( '/\D/', '', $phone );
		if ( ! empty( $phone ) && 0 === strpos( trim( $phone ), '+' ) && strlen( $phone_digits ) >= 9 && strlen( $phone_digits ) <= 16 ) {
			$customer_params['phone_number'] = $phone;
		}

		$customer_params['idempotency_key'] = self::customer_idempotency_key( $customer_params, $square_mode, $user_id, $recreate, $existing_customer_id );

		$response = $client->create_customer( $customer_params );
		if ( is_wp_error( $response ) ) {
			$error_data = self::get_error_data( $response );
			$message    = self::safe_error_message( $error_data, __( 'API request failed while creating Square customer.', 'peachpay-for-woocommerce' ) );
			peachpay_log( 'error', '[Square] create_customer REST error: ' . $message . ' (order #' . $order->get_id() . ')' );
			peachpay_notify_error( $response );
			return null;
		}

		$customer = isset( $response['customer'] ) && is_array( $response['customer'] ) ? $response['customer'] : null;
		if ( null === $customer ) {
			$serialized_errors = self::serialize_square_errors( isset( $response['errors'] ) ? $response['errors'] : array() );
			$message           = self::build_square_error_message( $serialized_errors, __( 'Failed to create Square customer with no details.', 'peachpay-for-woocommerce' ) );
			peachpay_log(
				'error',
				'[Square] create_customer: ' . $message
				. ' | errors=' . wp_json_encode( $serialized_errors )
				. ' (order #' . $order->get_id() . ')'
			);
			return null;
		}

		$customer_id = isset( $customer['id'] ) ? $customer['id'] : null;
		if ( empty( $customer_id ) ) {
			return null;
		}

		// Persist the new customer ID in user meta for future orders.
		self::set_customer( $user_id, $customer_id, $square_mode );

		return $customer_id;
	}

	/**
	 * Build a deterministic idempotency key for customer create requests.
	 *
	 * @param array $customer_params Customer payload being sent to Square.
	 * @param string $square_mode Square mode ('live' or 'test').
	 * @param int $user_id WooCommerce user id.
	 * @param bool $recreate Whether this is a cached-customer recreation path.
	 * @param string|null $stale_customer_id Previously cached Square customer id, when recreating.
	 * @return string Square idempotency key.
	 */
	private static function customer_idempotency_key( $customer_params, $square_mode, $user_id, $recreate = false, $stale_customer_id = null ) {
		$payload_signature = hash(
			'sha256',
			wp_json_encode(
				array(
					'given_name'    => isset( $customer_params['given_name'] ) ? (string) $customer_params['given_name'] : '',
					'family_name'   => isset( $customer_params['family_name'] ) ? (string) $customer_params['family_name'] : '',
					'email_address' => isset( $customer_params['email_address'] ) ? (string) $customer_params['email_address'] : '',
					'reference_id'  => isset( $customer_params['reference_id'] ) ? (string) $customer_params['reference_id'] : '',
					'phone_number'  => isset( $customer_params['phone_number'] ) ? (string) $customer_params['phone_number'] : null,
				)
			)
		);

		$idempotency_key = 'pp-sq-cust-' . absint( $user_id ) . '-' . $square_mode . '-' . substr( $payload_signature, 0, 40 );

		if ( $recreate ) {
			$idempotency_key .= '-recreate-' . substr( hash( 'sha256', (string) $stale_customer_id . '|' . $payload_signature ), 0, 16 );
		}

		return $idempotency_key;
	}

	/**
	 * Stores a payment card on file with Square for a given customer.
	 *
	 * @param string                 $source_id          Payment nonce from the Square Web Payments SDK.
	 * @param string|null            $verification_token SCA/3DS verification token (optional).
	 * @param string                 $customer_id        Square customer ID to associate the card with.
	 * @param "live"|"test"|"detect" $mode               Square mode.
	 * @param WC_Order|null          $order              Optional order for billing address.
	 * @return array|null Card details array on success, null on failure.
	 */
	public static function store_card_on_file( $source_id, $verification_token, $customer_id, $mode = 'detect', $order = null ) {
		$square_mode = PeachPay_Square_Integration::mode( $mode );
		$client      = PeachPay_Square_Credentials::get_rest_client( $square_mode );

		if ( ! $client ) {
			peachpay_log( 'error', '[Square] store_card_on_file: Square REST client not configured' );
			return null;
		}

		$card_props = array(
			'customer_id' => $customer_id,
		);

		if ( null !== $order ) {
			$billing_address = array_filter(
				array(
					'address_line_1'                 => $order->get_billing_address_1() ?: null,
					'address_line_2'                 => $order->get_billing_address_2() ?: null,
					'locality'                       => $order->get_billing_city() ?: null,
					'administrative_district_level_1'=> $order->get_billing_state() ?: null,
					'postal_code'                    => $order->get_billing_postcode() ?: null,
					'country'                        => $order->get_billing_country() ?: null,
				)
			);

			if ( ! empty( $billing_address ) ) {
				$card_props['billing_address'] = $billing_address;
			}
		}

		$params = array(
			'idempotency_key' => wp_generate_uuid4(),
			'source_id'       => $source_id,
			'card'            => $card_props,
		);

		if ( ! empty( $verification_token ) ) {
			$params['verification_token'] = $verification_token;
		}

		$response = $client->create_card( $params );
		if ( is_wp_error( $response ) ) {
			$error_data = self::get_error_data( $response );
			$message    = self::safe_error_message( $error_data, __( 'API request failed while storing card on file.', 'peachpay-for-woocommerce' ) );
			peachpay_log( 'error', '[Square] store_card_on_file REST error: ' . $message );
			peachpay_notify_error( $response );
			return null;
		}

		$card = isset( $response['card'] ) && is_array( $response['card'] ) ? $response['card'] : null;
		if ( null === $card ) {
			$serialized_errors = self::serialize_square_errors( isset( $response['errors'] ) ? $response['errors'] : array() );
			$message           = self::build_square_error_message( $serialized_errors, __( 'Failed to store card on file with no details.', 'peachpay-for-woocommerce' ) );
			peachpay_log(
				'error',
				'[Square] store_card_on_file: ' . $message
				. ' | errors=' . wp_json_encode( $serialized_errors )
			);
			return null;
		}

		return array(
			'id'         => isset( $card['id'] ) ? $card['id'] : '',
			'last_4'     => isset( $card['last_4'] ) ? $card['last_4'] : '',
			'exp_month'  => isset( $card['exp_month'] ) ? $card['exp_month'] : '',
			'exp_year'   => isset( $card['exp_year'] ) ? $card['exp_year'] : '',
			'card_brand' => isset( $card['card_brand'] ) ? $card['card_brand'] : '',
		);
	}

	/**
	 * Helper for checking whether a new square customer ID can be added to the WC Customer.
	 *
	 * @param int $user_id The id of the logged in user.
	 */
	private static function user_needs_square_customer( $user_id ) {
		return 0 !== $user_id && empty( self::get_customer( $user_id ) );
	}
}
