<?php
/**
 * Class WC_Payments_Customer
 *
 * @package WooCommerce\Payments
 */

use WCPay\Database_Cache;
use WCPay\Exceptions\API_Exception;
use WCPay\Logger;
use WCPay\Constants\Payment_Method;

defined( 'ABSPATH' ) || exit;

/**
 * Class handling any customer functionality
 */
class WC_Payments_Customer_Service {
	/**
	 * Deprecated Stripe customer ID option.
	 *
	 * This option was used to store the customer_id in a WC_User options before we decoupled live and test customers.
	 */
	const DEPRECATED_WCPAY_CUSTOMER_ID_OPTION = '_wcpay_customer_id';

	/**
	 * Live Stripe customer ID option.
	 *
	 * This option is used to store new live mode customers in a WC_User options. Customers stored in the deprecated
	 * option are migrated to this one.
	 */
	const WCPAY_LIVE_CUSTOMER_ID_OPTION = '_wcpay_customer_id_live';

	/**
	 * Test Stripe customer ID option.
	 *
	 * This option is used to store new test mode customer IDs in a WC_User options.
	 */
	const WCPAY_TEST_CUSTOMER_ID_OPTION = '_wcpay_customer_id_test';

	/**
	 * Key used to store customer id for non logged in users in WooCommerce Session.
	 */
	const CUSTOMER_ID_SESSION_KEY = 'wcpay_customer_id';

	/**
	 * Client for making requests to the WooCommerce Payments API
	 *
	 * @var WC_Payments_API_Client
	 */
	private $payments_api_client;

	/**
	 * WC_Payments_Account instance to get information about the account
	 *
	 * @var WC_Payments_Account
	 */
	private $account;

	/**
	 * Database_Cache instance to get information about the account
	 *
	 * @var Database_Cache
	 */
	private $database_cache;

	/**
	 * WC_Payments_Session_Service instance for working with session information
	 *
	 * @var WC_Payments_Session_Service
	 */
	private $session_service;

	/**
	 * WC_Payments_Order_Service instance
	 *
	 * @var WC_Payments_Order_Service
	 */
	private $order_service;

	/**
	 * Class constructor
	 *
	 * @param WC_Payments_API_Client      $payments_api_client Payments API client.
	 * @param WC_Payments_Account         $account WC_Payments_Account instance.
	 * @param Database_Cache              $database_cache Database_Cache instance.
	 * @param WC_Payments_Session_Service $session_service Session Service class instance.
	 * @param WC_Payments_Order_Service   $order_service Order Service class instance.
	 */
	public function __construct(
		WC_Payments_API_Client $payments_api_client,
		WC_Payments_Account $account,
		Database_Cache $database_cache,
		WC_Payments_Session_Service $session_service,
		WC_Payments_Order_Service $order_service
	) {
		$this->payments_api_client = $payments_api_client;
		$this->account             = $account;
		$this->database_cache      = $database_cache;
		$this->session_service     = $session_service;
		$this->order_service       = $order_service;
	}

	/**
	 * Initialize hooks
	 */
	public function init_hooks() {
		/*
		 * Adds the WooCommerce Payments customer ID found in the user session
		 * to the WordPress user as metadata.
		 *
		 * This is helpful in scenarios where the shopper begins the checkout flow
		 * logged out (i.e. guest checkout) and a user account is created for them
		 * during checkout.
		 *
		 * This occurs when a user account is necessary for checkout, e.g. when the shopper
		 * purchases a subscription product.
		 */
		add_action( 'woocommerce_created_customer', [ $this, 'add_customer_id_to_user' ] );
	}

	/**
	 * Get WCPay customer ID for the given WordPress user ID
	 *
	 * @param int|null $user_id The user ID to look for a customer ID with.
	 *
	 * @return string|null WCPay customer ID or null if not found.
	 */
	public function get_customer_id_by_user_id( $user_id ) {
		// User ID might be 0 if fetched from a WP_User instance for a user who isn't logged in.
		if ( null === $user_id || 0 === $user_id ) {
			// Try to retrieve the customer id from the session if stored previously.
			$customer_id = WC()->session ? WC()->session->get( self::CUSTOMER_ID_SESSION_KEY ) : null;
			return is_string( $customer_id ) ? $customer_id : null;
		}

		$customer_id = get_user_option( $this->get_customer_id_option(), $user_id );

		// If customer_id is false it could mean that it hasn't been migrated from the deprecated key.
		if ( false === $customer_id ) {
			$this->maybe_migrate_deprecated_customer( $user_id );
			// Customer might've been migrated in maybe_migrate_deprecated_customer, so we need to fetch it again.
			$customer_id = get_user_option( $this->get_customer_id_option(), $user_id );
		}

		return $customer_id ? $customer_id : null;
	}

	/**
	 * Create a customer and associate it with a WordPress user.
	 *
	 * @param WP_User|null $user          User to create a customer for.
	 * @param array        $customer_data Customer data.
	 *
	 * @return string The created customer's ID
	 *
	 * @throws API_Exception Error creating customer.
	 */
	public function create_customer_for_user( ?WP_User $user, array $customer_data = [] ): string {
		// Include the session ID for the user.
		$customer_data['session_id'] = $this->session_service->get_sift_session_id() ?? null;

		// Create a customer on the WCPay server.
		$customer_id = $this->payments_api_client->create_customer( $customer_data );

		if ( $user instanceof WP_User && $user->ID > 0 ) {
			$this->update_user_customer_id( $user->ID, $customer_id );
		}

		if ( isset( WC()->session ) ) {
			// Save the customer id in the session for non logged in users to reuse it in payments.
			WC()->session->set( self::CUSTOMER_ID_SESSION_KEY, $customer_id );
		}

		return $customer_id;
	}

	/**
	 * Manages customer details held on WCPay server for WordPress user associated with an order.
	 *
	 * @param int|null $user_id ID of the WP user to associate with the customer.
	 * @param WC_Order $order   Woo Order.
	 *
	 * @return string           WooPayments customer ID.
	 * @throws API_Exception    Throws when server API request fails.
	 */
	public function get_or_create_customer_id_from_order( ?int $user_id, WC_Order $order ): string {
		// Determine the customer making the payment, create one if we don't have one already.
		$customer_id   = $this->get_customer_id_by_user_id( $user_id );
		$customer_data = self::map_customer_data( $order, new WC_Customer( $user_id ?? 0 ) );
		$user          = null === $user_id ? null : get_user_by( 'id', $user_id );

		if ( null !== $customer_id ) {
			$this->update_customer_for_user( $customer_id, $user, $customer_data );
			return $customer_id;
		}
		return $this->create_customer_for_user( $user, $customer_data );
	}

	/**
	 * Update the customer details held on the WCPay server associated with the given WordPress user.
	 *
	 * @param string       $customer_id WCPay customer ID.
	 * @param WP_User|null $user        WordPress user.
	 * @param array        $customer_data Customer data.
	 *
	 * @return string The updated customer's ID. Can be different to the ID parameter if the customer was re-created.
	 *
	 * @throws API_Exception Error updating the customer.
	 */
	public function update_customer_for_user( string $customer_id, ?WP_User $user, array $customer_data ): string {
		try {
			// Update the customer on the WCPay server.
			$this->payments_api_client->update_customer(
				$customer_id,
				$customer_data
			);

			// We successfully updated the existing customer, so return the passed in ID unchanged.
			return $customer_id;
		} catch ( API_Exception $e ) {
			// If we failed to find the customer we wanted to update, then create a new customer and associate it to the
			// current user instead. This might happen if the customer was deleted from the server, the linked WCPay
			// account was changed, or if users were imported from another site.
			if ( $e->get_error_code() === 'resource_missing' ) {
				// Create a new customer to associate with this user. We'll return the new customer ID.
				return $this->recreate_customer( $user, $customer_data );
			}

			// For any other type of exception, just re-throw.
			throw $e;
		}
	}

	/**
	 * Sets a payment method as default for a customer.
	 *
	 * @param string $customer_id       The customer ID.
	 * @param string $payment_method_id The payment method ID.
	 */
	public function set_default_payment_method_for_customer( $customer_id, $payment_method_id ) {
		$this->payments_api_client->update_customer(
			$customer_id,
			[
				'invoice_settings' => [
					'default_payment_method' => $payment_method_id,
				],
			]
		);
	}

	/**
	 * Gets all payment methods for a customer.
	 *
	 * @param string $customer_id The customer ID.
	 * @param string $type        Type of payment methods to fetch.
	 *
	 * @throws API_Exception We only handle 'resource_missing' code types and rethrow anything else.
	 */
	public function get_payment_methods_for_customer( $customer_id, $type = 'card' ) {
		if ( ! $customer_id ) {
			return [];
		}

		$cache_payment_methods = ! WC_Payments::is_network_saved_cards_enabled();
		$cache_key             = Database_Cache::PAYMENT_METHODS_KEY_PREFIX . $customer_id . '_' . $type;

		if ( $cache_payment_methods ) {
			$payment_methods = $this->database_cache->get( $cache_key );
			if ( is_array( $payment_methods ) ) {
				return $payment_methods;
			}
		}

		try {
			$payment_methods = $this->payments_api_client->get_payment_methods( $customer_id, $type )['data'];
			if ( $cache_payment_methods ) {
				$this->database_cache->add( $cache_key, $payment_methods );
			}
			return $payment_methods;

		} catch ( API_Exception $e ) {
			// If we failed to find the payment methods, we can simply return empty payment methods as this customer
			// will be recreated when the user successfully adds a payment method.
			if ( $e->get_error_code() === 'resource_missing' ) {
				return [];
			}

			// Rethrow for error codes we don't care about in this function.
			throw $e;
		}
	}

	/**
	 * Updates a customer payment method.
	 *
	 * @param string   $payment_method_id The payment method ID.
	 * @param WC_Order $order             Order to be used on the update.
	 */
	public function update_payment_method_with_billing_details_from_order( $payment_method_id, $order ) {
		$billing_details = $this->order_service->get_billing_data_from_order( $order );

		if ( ! empty( $billing_details ) ) {
			$this->payments_api_client->update_payment_method(
				$payment_method_id,
				[
					'billing_details' => $billing_details,
				]
			);
		}
	}

	/**
	 * Clear payment methods cache for a user.
	 *
	 * @param int $user_id WC user ID.
	 */
	public function clear_cached_payment_methods_for_user( $user_id ) {
		if ( WC_Payments::is_network_saved_cards_enabled() ) {
			return; // No need to do anything, payment methods will never be cached in this case.
		}

		$retrievable_payment_method_types = [ Payment_Method::CARD, Payment_Method::LINK, Payment_Method::SEPA ];
		$customer_id                      = $this->get_customer_id_by_user_id( $user_id );
		foreach ( $retrievable_payment_method_types as $type ) {
			$this->database_cache->delete( Database_Cache::PAYMENT_METHODS_KEY_PREFIX . $customer_id . '_' . $type );
		}
	}

	/**
	 * Given a WC_Order or WC_Customer, returns an array representing a Stripe customer object.
	 * At least one parameter has to not be null.
	 *
	 * @param WC_Order    $wc_order    The Woo order to parse.
	 * @param WC_Customer $wc_customer The Woo customer to parse.
	 *
	 * @return array Customer data.
	 */
	public static function map_customer_data( ?WC_Order $wc_order = null, ?WC_Customer $wc_customer = null ): array {
		if ( null === $wc_customer && null === $wc_order ) {
			return [];
		}

		// Where available, the order data takes precedence over the customer.
		$object_to_parse = $wc_order ?? $wc_customer;
		$name            = $object_to_parse->get_billing_first_name() . ' ' . $object_to_parse->get_billing_last_name();
		$description     = '';
		if ( null !== $wc_customer && ! empty( $wc_customer->get_username() ) ) {
			// We have a logged in user, so add their username to the customer description.
			// translators: %1$s Name, %2$s Username.
			$description = sprintf( __( 'Name: %1$s, Username: %2$s', 'woocommerce-payments' ), $name, $wc_customer->get_username() );
		} else {
			// Current user is not logged in.
			// translators: %1$s Name.
			$description = sprintf( __( 'Name: %1$s, Guest', 'woocommerce-payments' ), $name );
		}

		$data = [
			'name'        => $name,
			'description' => $description,
			'email'       => $object_to_parse->get_billing_email(),
			'phone'       => $object_to_parse->get_billing_phone(),
			'address'     => [
				'line1'       => $object_to_parse->get_billing_address_1(),
				'line2'       => $object_to_parse->get_billing_address_2(),
				'postal_code' => $object_to_parse->get_billing_postcode(),
				'city'        => $object_to_parse->get_billing_city(),
				'state'       => $object_to_parse->get_billing_state(),
				'country'     => $object_to_parse->get_billing_country(),
			],
		];

		if ( ! empty( $object_to_parse->get_shipping_postcode() ) ) {
			$data['shipping'] = [
				'name'    => $object_to_parse->get_shipping_first_name() . ' ' . $object_to_parse->get_shipping_last_name(),
				'address' => [
					'line1'       => $object_to_parse->get_shipping_address_1(),
					'line2'       => $object_to_parse->get_shipping_address_2(),
					'postal_code' => $object_to_parse->get_shipping_postcode(),
					'city'        => $object_to_parse->get_shipping_city(),
					'state'       => $object_to_parse->get_shipping_state(),
					'country'     => $object_to_parse->get_shipping_country(),
				],
			];
		}

		return $data;
	}

	/**
	 * Delete all saved payment methods that are stored inside the database cache driver.
	 *
	 * @return void
	 */
	public function delete_cached_payment_methods() {
		$this->database_cache->delete_by_prefix( Database_Cache::PAYMENT_METHODS_KEY_PREFIX );
	}

	/**
	 * Recreates the customer for this user.
	 *
	 * @param WP_User|null $user          User to recreate a customer for.
	 * @param array        $customer_data Customer data.
	 *
	 * @return string The newly created customer's ID
	 *
	 * @throws API_Exception Error creating customer.
	 */
	private function recreate_customer( ?WP_User $user, array $customer_data ): string {
		if ( $user instanceof WP_User && $user->ID > 0 ) {
			$result = delete_user_option( $user->ID, $this->get_customer_id_option() );
			if ( ! $result ) {
				// Log the error, but continue since we'll be trying to update this option in create_customer.
				Logger::error( 'Failed to delete old customer ID for user ' . $user->ID );
			}
		}

		return $this->create_customer_for_user( $user, $customer_data );
	}

	/**
	 * Returns the name of the customer option meta, taking test mode into account.
	 *
	 * @return string The customer ID option name.
	 */
	private function get_customer_id_option(): string {
		return WC_Payments::mode()->is_test()
			? self::WCPAY_TEST_CUSTOMER_ID_OPTION
			: self::WCPAY_LIVE_CUSTOMER_ID_OPTION;
	}

	/**
	 * Migrate any customer ID that might be in the DEPRECATED_WCPAY_CUSTOMER_ID_OPTION.
	 *
	 * @param int $user_id The user ID to look for a customer ID with.
	 */
	private function maybe_migrate_deprecated_customer( $user_id ) {
		$customer_id = get_user_option( self::DEPRECATED_WCPAY_CUSTOMER_ID_OPTION, $user_id );
		if ( false !== $customer_id ) {
			// A customer was found in the deprecated key. Migrate it to the appropriate one and delete the old meta.
			// If an account is live mode, we optimistically assume that the customer is live mode, to avoid losing
			// live mode customer data. If the account is not live mode, it can only have test mode objects, so we
			// can safely migrate them to the test key.
			// If is_live cannot be determined, default it to true to avoid considering a live account as test.
			$account_is_live    = null === $this->account->get_is_live() || $this->account->get_is_live();
			$customer_option_id = $account_is_live
				? self::WCPAY_LIVE_CUSTOMER_ID_OPTION
				: self::WCPAY_TEST_CUSTOMER_ID_OPTION;
			if ( update_user_option( $user_id, $customer_option_id, $customer_id ) ) {
				delete_user_option( $user_id, self::DEPRECATED_WCPAY_CUSTOMER_ID_OPTION );
			} else {
				Logger::error( 'Failed to store new customer ID for user ' . $user_id . '; legacy customer was kept.' );
			}
		}
	}

	/**
	 * Get the WCPay customer ID associated with an order, or create one if none found.
	 *
	 * @param WC_Order $order WC Order object.
	 *
	 * @return string|null    WCPay customer ID.
	 * @throws API_Exception  If there's an error creating customer.
	 */
	public function get_customer_id_for_order( $order ) {
		$customer_id = null;
		$user        = $order->get_user();

		if ( false !== $user ) {
			// Determine the customer making the payment, create one if we don't have one already.
			$customer_id = $this->get_customer_id_by_user_id( $user->ID );

			if ( null === $customer_id ) {
				$customer_data = self::map_customer_data( $order, new WC_Customer( $user->ID ) );
				$customer_id   = $this->create_customer_for_user( $user, $customer_data );
			}
		}

		return $customer_id;
	}

	/**
	 * Updates the given user with the given WooCommerce Payments
	 * customer ID.
	 *
	 * @param int    $user_id     The WordPress user ID.
	 * @param string $customer_id The WooCommerce Payments customer ID.
	 */
	public function update_user_customer_id( int $user_id, string $customer_id ) {
		$global = WC_Payments::is_network_saved_cards_enabled();
		$result = update_user_option( $user_id, $this->get_customer_id_option(), $customer_id, $global );
		if ( ! $result ) {
			Logger::error( 'Failed to update customer ID for user ' . $user_id );
		}
	}

	/**
	 * Adds the WooCommerce Payments customer ID found in the user session
	 * to the WordPress user as metadata.
	 *
	 * @param int $user_id The WordPress user ID.
	 */
	public function add_customer_id_to_user( $user_id ) {
		// Not processing a checkout, bail.
		if (
			! ( defined( 'WOOCOMMERCE_CHECKOUT' ) && WOOCOMMERCE_CHECKOUT ) &&
			! ( function_exists( 'wcs_is_checkout_blocks_api_request' ) && wcs_is_checkout_blocks_api_request( 'v1/checkout' ) )
		) {
			return;
		}

		// Retrieve the WooCommerce Payments customer ID from the user session.
		$customer_id = WC()->session ? WC()->session->get( self::CUSTOMER_ID_SESSION_KEY ) : null;

		if ( ! $customer_id ) {
			return;
		}

		$this->update_user_customer_id( $user_id, $customer_id );
	}

	/**
	 * Prepares customer data to be used on 'Pay for Order' or 'Add Payment Method' pages.
	 * Customer data is retrieved from order when on Pay for Order.
	 * Customer data is retrieved from customer when on 'Add Payment Method'.
	 *
	 * @return array|null An array with customer data or nothing.
	 */
	public function get_prepared_customer_data() {
		if ( ! isset( $_GET['pay_for_order'] ) && ! is_add_payment_method_page() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return null;
		}

		global $wp;
		$user_email      = '';
		$firstname       = '';
		$lastname        = '';
		$billing_country = '';
		$address         = null;

		if ( isset( $_GET['pay_for_order'] ) && 'true' === $_GET['pay_for_order'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$order_id = absint( $wp->query_vars['order-pay'] );
			$order    = wc_get_order( $order_id );

			if ( is_a( $order, 'WC_Order' ) ) {
				$firstname       = $order->get_billing_first_name();
				$lastname        = $order->get_billing_last_name();
				$user_email      = $order->get_billing_email();
				$billing_country = $order->get_billing_country();
				$address         = [
					'city'        => $order->get_billing_city(),
					'country'     => $order->get_billing_country(),
					'line1'       => $order->get_billing_address_1(),
					'line2'       => $order->get_billing_address_2(),
					'postal_code' => $order->get_billing_postcode(),
					'state'       => $order->get_billing_state(),
				];
			}
		}

		if ( is_add_payment_method_page() ) {
			$user = wp_get_current_user();

			if ( $user->ID ) {
				$firstname       = $user->user_firstname;
				$lastname        = $user->user_lastname;
				$user_email      = get_user_meta( $user->ID, 'billing_email', true );
				$user_email      = ! empty( $user_email ) ? $user_email : $user->user_email;
				$billing_country = get_user_meta( $user->ID, 'billing_country', true );
			}
		}

		return [
			'name'            => $firstname . ' ' . $lastname,
			'email'           => $user_email,
			'billing_country' => $billing_country,
			'address'         => $address,
		];
	}
}
