<?php
/**
 * This class handles PayPal IPN button only for now.
 * Everything except the button adding is handled in includes/payments/class-learndash-paypal-ipn.php.
 *
 * @since 4.5.0
 *
 * @package LearnDash
 */

use LearnDash\Core\Models\Product;
use LearnDash\Core\Models\Transaction;
use LearnDash\Core\Modules\Payments\Gateways;
use LearnDash\Core\Template\Template;
use LearnDash\Core\Utilities\Cast;
use StellarWP\Learndash\StellarWP\SuperGlobals\SuperGlobals;
use StellarWP\Learndash\StellarWP\AdminNotices\AdminNotices;
use StellarWP\Learndash\StellarWP\AdminNotices\AdminNotice;
use LearnDash\Core\Modules\Payments\Gateways\Paypal\Payment_Gateway;
use StellarWP\Learndash\StellarWP\Arrays\Arr;
use LearnDash\Core\Modules\Payments\Gateways\Paypal_Standard\Migration\User_Data as Migration_User_Data;

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

if ( ! class_exists( 'Learndash_Paypal_IPN_Gateway' ) && class_exists( 'Learndash_Payment_Gateway' ) ) {
	/**
	 * PayPal IPN gateway class.
	 *
	 * @since 4.5.0
	 *
	 * @property WP_User|null $user
	 *
	 * @property array{
	 *      enabled         : string,
	 *      paypal_cancelurl: string,
	 *      paypal_country  : string,
	 *      paypal_email    : string,
	 *      paypal_notifyurl: string,
	 *      paypal_returnurl: string,
	 *      paypal_sandbox  : string,
	 * } $settings
	 *
	 * @phpstan-type User_Hash_Type array{
	 *     currency_code?    : string,
	 *     learndash_version?: string,
	 *     nonce?            : string,
	 *     paid_price?       : float,
	 *     post_id?          : int,
	 *     pricing_info?     : array<string,mixed>,
	 *     product_id?       : int,
	 *     return-success?   : string|int,
	 *     subscr_id?        : string,
	 *     txn_type?         : string,
	 *     user_id?          : int,
	 * } | array{}
	 *
	 * @phpstan-type Transaction_Data_Type array{
	 *     business_email?  : string,
	 *     business?        : string,
	 *     custom?          : int,
	 *     first_name?      : string,
	 *     item_number?     : int,
	 *     last_name?       : string,
	 *     mc_amount1?      : float,
	 *     mc_amount3?      : float,
	 *     mc_currency?     : string,
	 *     mc_gross?        : float,
	 *     notify_version?  : int,
	 *     payer_email?     : string,
	 *     payer_id?        : string,
	 *     payment_status?  : string,
	 *     period1?         : string,
	 *     period3?         : string,
	 *     post_id?         : int,
	 *     post_type_prefix?: string,
	 *     post_type?       : string|false,
	 *     receiver_email?  : string,
	 *     recur_times?     : int,
	 *     recurring?       : string,
	 *     subscr_id?       : string,
	 *     txn_id?          : string,
	 *     txn_type?        : string,
	 *     user_id?         : int
	 * }
	 */
	class Learndash_Paypal_IPN_Gateway extends Learndash_Payment_Gateway {
		private const GATEWAY_NAME = 'paypal_ipn';

		private const PAYPAL_IPN_PAYMENT_STATUS_COMPLETED = 'completed';

		private const TRANSIENT_KEY_PREFIX_USER_HASH = 'ld_purchase_nonce_';

		private const RETURN_ACTION_NAME_SUCCESS = 'return-success';
		private const RETURN_ACTION_NAME_CANCEL  = 'return-cancel';
		private const RETURN_ACTION_NAME_NOTIFY  = 'return-notify';

		private const TRANSIENT_STORAGE_PERIOD = DAY_IN_SECONDS;

		/**
		 * Settings Section Key used to configure this Payment Gateway.
		 *
		 * @since 4.18.0
		 *
		 * @var string
		 */
		protected string $settings_section_key = 'settings_paypal';

		/**
		 * User hash details generated by the site.
		 *
		 * @since 4.5.0
		 *
		 * @var User_Hash_Type
		 */
		private $user_hash;

		/**
		 * User hash nonce string sent back by PayPal.
		 *
		 * @since 4.5.0
		 *
		 * @var string
		 */
		private $hash_nonce;

		/**
		 * Key name where hash is stored in transient.
		 *
		 * @since 4.5.0
		 *
		 * @var string
		 */
		private $hash_key;

		/**
		 * Webhook action type.
		 *
		 * @since 4.5.0
		 *
		 * @var string
		 */
		private $webhook_action;

		/**
		 * PayPal IPN transaction data.
		 *
		 * @since 4.5.0
		 *
		 * @var Transaction_Data_Type
		 */
		private $transaction_data;

		/**
		 * Products being processed by this gateway
		 *
		 * @since 4.5.0
		 *
		 * @var Product[]
		 */
		private $products;

		/**
		 * Returns a flag to easily identify if the gateway supports transactions management.
		 *
		 * @since 4.5.0
		 *
		 * @return bool True if a gateway supports managing subscriptions/other transactions. False otherwise.
		 */
		public function supports_transactions_management(): bool {
			return false;
		}

		/**
		 * Cancels a subscription.
		 *
		 * @since 4.5.0
		 *
		 * @param string $subscription_id Subscription ID.
		 *
		 * @return WP_Error
		 */
		public function cancel_subscription( string $subscription_id ): WP_Error {
			return new WP_Error(
				self::$wp_error_code,
				__( 'Subscription management is not supported by PayPal IPN payment gateway.', 'learndash' )
			);
		}

		/**
		 * Returns the gateway name.
		 *
		 * @since 4.5.0
		 *
		 * @return string
		 */
		public static function get_name(): string {
			return self::GATEWAY_NAME;
		}

		/**
		 * Returns the gateway label.
		 *
		 * @since 4.5.0
		 * @since 4.25.0 Renamed from "PayPal" to "PayPal Standard".
		 *
		 * @return string
		 */
		public static function get_label(): string {
			return esc_html__( 'PayPal Standard', 'learndash' );
		}

		/**
		 * Returns the gateway label for checkout activities.
		 *
		 * @since 4.16.0
		 *
		 * @return string
		 */
		public function get_checkout_label(): string {
			return __( 'Pay with PayPal', 'learndash' );
		}

		/**
		 * Returns the gateway meta HTML that appears near the payment selector.
		 *
		 * @since 4.16.0
		 *
		 * @return string
		 */
		public function get_checkout_meta_html(): string {
			return Template::get_template( 'components/logos/paypal.php' );
		}

		/**
		 * Returns the gateway info text for checkout activities.
		 *
		 * @since 4.16.0
		 *
		 * @param string $product_type Type of product being purchased.
		 *
		 * @return string
		 */
		public function get_checkout_info_text( string $product_type ): string {
			if ( $product_type === 'course' ) {
				$info = _x(
					'You will be redirected to PayPal to complete your payment with your debit card, credit card, or PayPal account. Once complete, you will be redirected back to this site to continue your course.',
					'Message displayed when purchasing a course.',
					'learndash'
				);
			} elseif ( $product_type === 'group' ) {
				$info = _x(
					'You will be redirected to PayPal to complete your payment with your debit card, credit card, or PayPal account. Once complete, you will be redirected back to this site to continue your group.',
					'Message displayed when purchasing a group.',
					'learndash'
				);
			} else {
				$info = _x(
					'You will be redirected to PayPal to complete your payment with your debit card, credit card, or PayPal account. Once complete, you will be redirected back to this site.',
					'Message displayed when purchasing a a product that is neither a course nor a group.',
					'learndash'
				);
			}

			/**
			 * Filters the PayPal IPN checkout info text.
			 *
			 * @since 4.16.0
			 *
			 * @param string $info         The Stripe checkout info text.
			 * @param string $product_type The product type.
			 *
			 * @return string The checkout info text.
			 */
			$info = apply_filters( 'learndash_paypal_ipn_checkout_info_text', $info, $product_type );

			return Cast::to_string( $info );
		}

		/**
		 * Adds hooks.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		public function add_extra_hooks(): void {
			add_action(
				'learndash_coupon_attached',
				[ $this, 'generate_user_purchase_hash_when_coupon_is_attached' ],
				10,
				4
			);

			add_action(
				'learndash_coupon_detached',
				[ $this, 'generate_user_purchase_hash_when_coupon_is_detached' ],
				10,
				2
			);

			add_action( 'wp_footer', [ $this, 'show_successful_message' ] );

			add_action(
				'learndash_settings_page_before_form',
				[ $this, 'show_deprecated_warning' ],
				10,
				2
			);
			add_action(
				'learndash_settings_page_inside_form_bottom',
				[ $this, 'show_deprecated_warning' ],
				10,
				2
			);
			add_action(
				'learndash_settings_page_before_form',
				[ $this, 'show_migration_warning' ],
				10,
				2
			);
		}

		/**
		 * Show deprecated warning.
		 *
		 * @since 4.25.0
		 *
		 * @param string $setting_screen_id Settings screen ID.
		 * @param string $setting_page_id   Settings page ID.
		 *
		 * @return void
		 */
		public function show_deprecated_warning( string $setting_screen_id, string $setting_page_id ): void {
			$notice = new AdminNotice(
				'learndash_paypal_ipn_gateway_deprecated_warning',
				sprintf(
					'<strong>%s</strong> %s <a href="https://go.learndash.com/paypal/" target="_blank">%s</a>',
					esc_html__( 'IMPORTANT:', 'learndash' ),
					esc_html__( 'PayPal Standard is a deprecated gateway. It is recommended to migrate to PayPal Checkout.', 'learndash' ),
					esc_html__( 'Learn more', 'learndash' ),
				)
			);

			$notice->notDismissible()
				->asWarning()
				->alternateStyles()
				->inline()
				->autoParagraph()
				->on( 'admin.php?page=learndash_lms_payments' )
				->when(
					function () use ( $setting_screen_id, $setting_page_id ) {
						$section = Cast::to_string( SuperGlobals::get_get_var( 'section-payment', '' ) );

						// Get PayPal Checkout settings to check if it's enabled.
						$paypal_checkout_settings = Payment_Gateway::get_settings();

						// Only show the warning on the Payments page or the PayPal Standard settings page.
						return $setting_screen_id === 'admin_page_learndash_lms_payments'
							&& $setting_page_id === 'learndash_lms_payments'
							&& in_array( $section, [ 'settings_paypal', '' ], true )
							&& ! Cast::to_bool( Arr::get( $paypal_checkout_settings, 'enabled', false ) );
					}
				);

			AdminNotices::render( $notice );
		}

		/**
		 * Show migration warning.
		 *
		 * @since 4.25.3
		 *
		 * @param string $setting_screen_id Settings screen ID.
		 * @param string $setting_page_id   Settings page ID.
		 *
		 * @return void
		 */
		public function show_migration_warning( string $setting_screen_id, string $setting_page_id ): void {
			$content = sprintf(
				'<strong>%s</strong><br /> %s',
				__( 'PayPal is sunsetting PayPal Standard at the end of 2026, so it\'s important to migrate affected subscriptions before then to prevent service interruptions.', 'learndash' ),
				sprintf(
					// translators: 1: Course label. 2: Group label. 3: Link to the Migration Guide.
					__( 'Below is a list of users with subscriptions using the legacy PayPal Standard gateway for %1$s or %2$s access. Use the %3$s with full instructions on how to use the shortcode and direct users through the process, including page and email content templates.', 'learndash' ),
					learndash_get_custom_label( 'course' ),
					learndash_get_custom_label( 'group' ),
					'<a href="https://go.learndash.com/paypal-migration/" target="_blank">' . __( 'Migration Guide', 'learndash' ) . '</a>'
				)
			);

			$notice = new AdminNotice(
				'learndash_paypal_ipn_gateway_migration_warning',
				wp_kses(
					$content,
					[
						'strong' => [],
						'br'     => [],
						'a'      => [
							'href'   => [],
							'target' => [],
						],
					],
				)
			);

			$notice->notDismissible()
				->asWarning()
				->alternateStyles()
				->inline()
				->autoParagraph()
				->on( 'admin.php?page=learndash_lms_payments' )
				->when(
					function () use ( $setting_screen_id, $setting_page_id ) {
						$section = Cast::to_string( SuperGlobals::get_get_var( 'section-payment', '' ) );

						// Only show the warning on the PayPal Standard settings page.
						return $setting_screen_id === 'admin_page_learndash_lms_payments'
							&& $setting_page_id === 'learndash_lms_payments'
							&& $section === 'settings_paypal';
					}
				);

			AdminNotices::render( $notice );
		}

		/**
		 * Generates a user purchase hash when a coupon is attached.
		 *
		 * @since 4.20.1
		 *
		 * @param int                  $product_id Product ID.
		 * @param int                  $coupon_id  Coupon ID.
		 * @param int                  $user_id    User ID.
		 * @param Learndash_Coupon_DTO $coupon_dto Coupon DTO.
		 *
		 * @return void
		 */
		public function generate_user_purchase_hash_when_coupon_is_attached( int $product_id, int $coupon_id, int $user_id, Learndash_Coupon_DTO $coupon_dto ): void {
			// Update the price to the discounted one.
			try {
				$product = Product::find( $product_id );
				if ( ! $product ) {
					return;
				}

				$pricing = $product->get_pricing( $user_id );
			} catch ( Exception $e ) {
				return;
			}

			$this->generate_user_purchase_hash( $product_id, $pricing, $coupon_dto->discounted_price );
		}

		/**
		 * Generates a user purchase hash when a coupon is detached.
		 *
		 * @since 4.20.1
		 *
		 * @param int $product_id Product ID.
		 * @param int $user_id    User ID.
		 *
		 * @return void
		 */
		public function generate_user_purchase_hash_when_coupon_is_detached( int $product_id, int $user_id ): void {
			// Revert the price to the original one.
			try {
				$product = Product::find( $product_id );

				if ( ! $product ) {
					return;
				}

				$pricing = $product->get_pricing( $user_id );
			} catch ( Exception $e ) {
				return;
			}

			$this->generate_user_purchase_hash( $product_id, $pricing, $pricing->price );
		}

		/**
		 * Runs a JS alert with a successful transaction message.
		 *
		 * @since 4.20.1
		 *
		 * @return void
		 */
		public function show_successful_message(): void {
			/**
			 * Show message only if the user is redirected from PayPal and doesn't have access yet.
			 * To achieve this, we check the 'user_hash' property that is set when we create the payment button
			 * and is deleted when we finish the IPN processing.
			 */

			if (
				empty( $_GET['ld_paypal_success'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We can't use normal nonce verification here.
				|| ! isset( $_GET['nonce'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We can't use normal nonce verification here.
				|| ! get_transient( self::TRANSIENT_KEY_PREFIX_USER_HASH . sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We can't use normal nonce verification here.
			) {
				return;
			}

			// Using wp_strip_all_tags() because the message is displayed in an alert.

			$message = wp_strip_all_tags(
				__(
					"Thank you! We're processing your payment. You'll be enrolled automatically once PayPal confirms the transaction. This typically takes a few minutes.",
					'learndash'
				)
			);

			wp_print_inline_script_tag( sprintf( 'alert("%s");', esc_js( $message ) ) );
		}

		/**
		 * Enqueues scripts.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		public function enqueue_scripts(): void {
			// No scripts.
		}

		/**
		 * Creates a session/order/subscription on backend if needed.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		public function setup_payment(): void {
			// No actions.
		}

		/**
		 * Returns true if everything is configured and payment gateway can be used, otherwise false.
		 *
		 * @since 4.5.0
		 *
		 * @return bool
		 */
		public function is_ready(): bool {
			$enabled = 'on' === ( $this->settings['enabled'] ?? '' );

			return $enabled && ! empty( $this->settings['paypal_email'] );
		}

		/**
		 * Returns true it's a test mode, otherwise false.
		 *
		 * @since 4.5.0
		 *
		 * @return bool
		 */
		protected function is_test_mode(): bool {
			return isset( $this->settings['paypal_sandbox'] ) && 'yes' === $this->settings['paypal_sandbox'];
		}

		/**
		 * Configures settings.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		protected function configure(): void {
			$this->settings = LearnDash_Settings_Section::get_section_settings_all( 'LearnDash_Settings_Section_PayPal' );
		}

		/**
		 * Handles the webhook.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		public function process_webhook(): void {
			if (
			( ! isset( $_SERVER['REQUEST_URI'] ) && ! isset( $_GET['learndash-integration'] ) && ! isset( $_GET['sfwd-lms'] ) ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			( isset( $_GET['learndash-integration'] ) && $this->get_name() !== $_GET['learndash-integration'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			( isset( $_GET['sfwd-lms'] ) && 'paypal' !== $_GET['sfwd-lms'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			( isset( $_SERVER['REQUEST_URI'] ) && ! isset( $_GET['learndash-integration'] ) && ! isset( $_GET['sfwd-lms'] ) && strpos( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'sfwd-lms/paypal' ) === false ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			) {
				return;
			}

			$this->log_info( 'Webhook received.' );

			if ( isset( $_GET[ self::RETURN_ACTION_NAME_SUCCESS ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$this->webhook_action = self::RETURN_ACTION_NAME_SUCCESS;

				// phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$hash_nonce = sanitize_text_field( wp_unslash( $_GET[ self::RETURN_ACTION_NAME_SUCCESS ] ) );
			} elseif ( isset( $_GET[ self::RETURN_ACTION_NAME_CANCEL ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$this->webhook_action = self::RETURN_ACTION_NAME_CANCEL;

				// phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$hash_nonce = sanitize_text_field( wp_unslash( $_GET[ self::RETURN_ACTION_NAME_CANCEL ] ) );
			} elseif ( isset( $_GET[ self::RETURN_ACTION_NAME_NOTIFY ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$this->webhook_action = self::RETURN_ACTION_NAME_NOTIFY;

				// phpcs:ignore WordPress.Security.NonceVerification.Recommended
				$hash_nonce = sanitize_text_field( wp_unslash( $_GET[ self::RETURN_ACTION_NAME_NOTIFY ] ) );
			} else {
				$message = 'Unknown webhook action.';
				$this->log_error( $message );
				// We should return 200 to PayPal to avoid multiple retries of the same IPN message that is not processed.
				$this->exit( '', true, $message, 200 );
			}

			$this->process_webhook_data_init();

			if ( ! empty( $hash_nonce ) ) {
				$this->hash_nonce = $hash_nonce;
				$this->hash_key   = self::TRANSIENT_KEY_PREFIX_USER_HASH . $hash_nonce;

				// Set and verify hash nonce.
				$this->set_user_purchase_hash();

				if ( ! empty( $this->user_hash['user_id'] ) ) {
					$user_id = absint( $this->user_hash['user_id'] );

					$user = get_user_by( 'ID', $user_id );

					if ( is_object( $user ) && is_a( $user, 'WP_User' ) ) {
						$this->user = $user;
					}
				}

				if ( ! $this->verify_user_purchase_hash() ) {
					// Revoke access if the hash is not valid.

					$this->products = isset( $this->user_hash['product_id'] ) ?
						Product::find_many( [ $this->user_hash['product_id'] ] )
						: [];

					$this->revoke_access();

					// Delete the hash to avoid a user bypassing the validation with other notifications with an empty payload.
					$this->delete_user_purchase_hash();

					$message = 'User purchase verification failed.';
					$this->log_error( $message );
					// It's an error, but we return 200 to avoid PayPal to retry the webhook as it's not a server error.
					$this->exit( '', true, $message, 200 );
				}
			}

			if ( ! $this->is_ready() ) {
				$message = 'PayPal gateway is not enabled yet or PayPal email address is empty.';

				$this->log_error( $message );
				// We return 422 to PayPal to retry the webhook as it's a configuration error.
				$this->exit( '', true, $message, 422 );
			}

			// phpcs:ignore WordPress.Security.NonceVerification.Missing
			if ( $this->maybe_ignore_event( $_POST ) ) {
				$this->finish_webhook_processing( $_POST, false ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
			}

			$this->process_webhook_action();

			$this->process_webhook_ipn_listener();

			$this->process_webhook_data_validation();

			$this->process_webhook_cancel_subscription_listener();

			$this->process_webhook_data();

			if ( ! empty( $this->transaction_data['post_id'] ) ) {
				/**
				 * Set products being processed by this gateway.
				 *
				 * Use find_many() method to prepare for LearnDash cart system update.
				 * The post_id will be changed later to posts_ids that holds
				 * multiple post_id values after cart system update.
				 */
				$this->products = Product::find_many( array( $this->transaction_data['post_id'] ) );
			}

			// Set parent order ID according to payment type.
			if ( ! empty( $this->transaction_data['txn_type'] ) ) {
				$user_id   = ! empty( $this->transaction_data['user_id'] ) ? $this->transaction_data['user_id'] : null;
				$post_id   = ! empty( $this->transaction_data['post_id'] ) ? $this->transaction_data['post_id'] : null;
				$subscr_id = ! empty( $this->transaction_data['subscr_id'] ) ? $this->transaction_data['subscr_id'] : null;

				switch ( $this->transaction_data['txn_type'] ) {
					case 'web_accept':
					case 'subscr_signup':
						$this->parent_transaction_id = 0;
						break;

					case 'subscr_payment':
						$transactions = Transaction::find_many_by_meta(
							array(
								'user_id'   => $user_id,
								'post_id'   => $post_id,
								'txn_type'  => 'subscr_signup',
								'subscr_id' => $subscr_id,
							)
						);

						if ( ! empty( $transactions ) ) {
							foreach ( $transactions as $transaction ) {
								if ( $transaction->is_parent() ) {
									$this->parent_transaction_id = $transaction->get_id();

									add_filter(
										'learndash_transaction_post_title',
										function ( $title, $post, $user, $meta_fields ) {
											if ( isset( $meta_fields['txn_type'] )
											&& $meta_fields['txn_type'] === 'subscr_payment'
											) {
												$title = sprintf( 'Payment for %s', $post->post_title );
											}

											return $title;
										},
										10,
										4
									);
									break;
								}
							}
						}
						break;
				}
			}

			$this->process_webhook_user_data();

			$this->process_webhook_completion();

			$this->finish_webhook_processing( $this->transaction_data, true );
		}

		/**
		 * Returns payment button HTML markup.
		 *
		 * @since 4.5.0
		 *
		 * @param array   $params Payment params.
		 * @param WP_Post $post   Post being processing.
		 *
		 * @phpstan-param array{
		 *     type: string,
		 *     price: float,
		 *     custom_button_url: string
		 * } $params
		 *
		 * @return string Payment button HTML markup.
		 */
		protected function map_payment_button_markup( array $params, WP_Post $post ): string {
			// We don't show the PayPal standard button if the PayPal Checkout is activated and supported.

			$paypal_checkout_gateway = Learndash_Payment_Gateway::get_active_payment_gateway_by_name( Gateways\Paypal\Payment_Gateway::get_name() );

			if (
				$paypal_checkout_gateway instanceof Gateways\Paypal\Payment_Gateway
				&& $paypal_checkout_gateway->is_supported()
			) {
				return '';
			}

			try {
				$product = Product::create_from_post( $post );
				$pricing = $product->get_pricing( $this->user );
			} catch ( Exception $e ) {
				return '';
			}

			$user_id = $this->user->ID ?? null;

			$price               = $product->get_final_price( $user_id );
			$button_label        = esc_html(
				$this->map_payment_button_label( __( 'Use PayPal', 'learndash' ), $post )
			);
			$post_title_filtered = str_replace( array( '[', ']' ), array( '', '' ), $post->post_title );
			$is_test_mode        = (int) $this->is_test_mode();

			$user_hash                          = $this->generate_user_purchase_hash( $product->get_id(), $pricing, $price );
			$this->settings['paypal_returnurl'] = esc_url_raw(
				add_query_arg( self::RETURN_ACTION_NAME_SUCCESS, $user_hash, $this->settings['paypal_notifyurl'] )
			);
			$this->settings['paypal_cancelurl'] = esc_url_raw(
				add_query_arg( self::RETURN_ACTION_NAME_CANCEL, $user_hash, $this->settings['paypal_notifyurl'] )
			);
			$this->settings['paypal_notifyurl'] = esc_url_raw(
				add_query_arg( self::RETURN_ACTION_NAME_NOTIFY, $user_hash, $this->settings['paypal_notifyurl'] )
			);

			include_once LEARNDASH_LMS_LIBRARY_DIR . '/paypal/enhanced-paypal-shortcodes.php';

			$button_aria_label = $button_label . '. ' . $this->get_checkout_info_text( $post->post_type );

			if ( $product->is_price_type_paynow() ) {
				$shortcode_content = do_shortcode( '[paypal type="paynow" button_label="' . $button_label . '" button_aria_label="' . $button_aria_label . '"  amount="' . $price . '" sandbox="' . $is_test_mode . '" email="' . $this->settings['paypal_email'] . '" itemno="' . $product->get_id() . '" name="' . $post_title_filtered . '" noshipping="1" nonote="1" qty="1" currencycode="' . $this->currency_code . '" rm="2" notifyurl="' . $this->settings['paypal_notifyurl'] . '" returnurl="' . $this->settings['paypal_returnurl'] . '" cancelurl="' . $this->settings['paypal_cancelurl'] . '" imagewidth="100px" pagestyle="paypal" lc="' . $this->settings['paypal_country'] . '" cbt="' . esc_html__( 'Complete Your Purchase', 'learndash' ) . '" custom="' . $user_id . '"]' );
			} elseif ( $product->is_price_type_subscribe() ) {
				$shortcode_content = do_shortcode( '[paypal type="subscribe" button_label="' . $button_label . '" button_aria_label="' . $button_aria_label . '" a1="' . $pricing->trial_price . '" p1="' . $pricing->trial_duration_value . '" t1="' . $pricing->trial_duration_length . '" a3="' . $price . '" p3="' . $pricing->duration_value . '" t3="' . $pricing->duration_length . '" sandbox="' . $is_test_mode . '" email="' . $this->settings['paypal_email'] . '" itemno="' . $product->get_id() . '" name="' . $post_title_filtered . '" noshipping="1" nonote="1" qty="1" currencycode="' . $this->currency_code . '" rm="2" notifyurl="' . $this->settings['paypal_notifyurl'] . '" cancelurl="' . $this->settings['paypal_cancelurl'] . '" returnurl="' . $this->settings['paypal_returnurl'] . '" imagewidth="100px" pagestyle="paypal" lc="' . $this->settings['paypal_country'] . '" cbt="' . esc_html__( 'Complete Your Purchase', 'learndash' ) . '" custom="' . $user_id . '" srt="' . $pricing->recurring_times . '"]' );
			} else {
				return ''; // For phpstan only. Really it will never happen as this filter is called with the types above only.
			}

			return wptexturize( $shortcode_content );
		}

		/**
		 * Creates a unique hash for the pre-purchase action that will validate the return transaction logic.
		 *
		 * @since 4.5.0
		 * @since 4.20.1 Added the $final_price parameter.
		 *
		 * @param int                   $product_id      Product ID.
		 * @param Learndash_Pricing_DTO $product_pricing Product pricing DTO.
		 * @param float                 $final_price     Product final price (e.g., after applying coupon).
		 *
		 * @return string
		 */
		private function generate_user_purchase_hash( int $product_id, Learndash_Pricing_DTO $product_pricing, float $final_price ): string {
			$hash_nonce = wp_create_nonce(
				( isset( $this->user->ID ) && $this->user->ID > 0 ? $this->user->ID : time() ) . '-' . $product_id
			);

			// Defining the price paid by the user (to be used in the validation process).

			$paid_price = $product_pricing->trial_price > 0
				? $product_pricing->trial_price
				: $final_price;

			set_transient(
				self::TRANSIENT_KEY_PREFIX_USER_HASH . $hash_nonce,
				array(
					'user_id'                           => $this->user->ID ?? 0,
					'product_id'                        => $product_id,
					'time'                              => time(),
					'nonce'                             => $hash_nonce,
					'paid_price'                        => $paid_price,
					'currency_code'                     => $this->currency_code,
					'learndash_version'                 => LEARNDASH_VERSION,
					Transaction::$meta_key_pricing_info => $product_pricing->to_array(),
				),
				self::TRANSIENT_STORAGE_PERIOD
			);

			return $hash_nonce;
		}

		/**
		 * Get user hash details for transaction logic.
		 *
		 * @since 4.5.0
		 *
		 * @return User_Hash_Type Hash array if it is available, empty array otherwise.
		 */
		private function set_user_purchase_hash(): array {
			/**
			 * User hash.
			 *
			 * @var User_Hash_Type|false $user_hash
			 */
			$user_hash = get_transient( $this->hash_key );

			if ( false !== $user_hash ) {
				$this->user_hash = $user_hash;

				return $this->user_hash;
			}

			return array();
		}

		/**
		 * Verify user hash nonce.
		 *
		 * @since 4.5.0
		 *
		 * @return bool
		 */
		private function verify_user_purchase_hash(): bool {
			$txn_type = $this->transaction_data['txn_type'] ?? '';

			// We don't need to validate the nonce for canceled transactions.
			if (
				$this->webhook_action === self::RETURN_ACTION_NAME_NOTIFY
				&& in_array( $txn_type, [ 'subscr_cancel', 'subscr_eot', 'subscr_failed' ], true )
			) {
				return true;
			}

			/**
			 * Note we can't use wp_nonce_verify() here because it uses the user_id and time() as
			 * part of the calculation logic. So we stored the nonce in the transient and
			 * can only compare it here.
			 */
			$valid = ! empty( $this->user_hash['nonce'] ) && ! empty( $this->hash_nonce ) && $this->user_hash['nonce'] === $this->hash_nonce;

			if ( ! $valid ) {
				$this->log_error( 'User purchase validation: nonce verification failed.' );

				return false;
			}

			// We should not validate data if it's a canceled transaction.

			if ( $this->webhook_action === self::RETURN_ACTION_NAME_CANCEL ) {
				$this->log_info( 'User purchase validation: skipped for canceled transaction.' );

				return true;
			}

			/**
			 * We don't have enough data to validate the transaction in the return-success action.
			 * That's why since LD v4.20.1 we stopped enrolling users before the IPN notification.
			 * We will validate and enroll users only during the IPN notification.
			 */

			if ( $this->webhook_action === self::RETURN_ACTION_NAME_SUCCESS ) {
				$this->log_info( 'User purchase validation: skipped for successful transaction. It will be validated with the IPN notification from PayPal.' );

				return true;
			}

			// Before LD v4.20.1, we didn't store the LearnDash version and other validation data. Then, we should ignore extra validations.

			if ( empty( $this->user_hash['learndash_version'] ) ) {
				return true; // No extra validations needed.
			}

			// Validate the currency code.

			if (
				! isset( $this->user_hash['currency_code'] )
				|| ! isset( $this->transaction_data['mc_currency'] )
				|| $this->user_hash['currency_code'] !== $this->transaction_data['mc_currency']
			) {
				$this->log_error( 'User purchase validation: currency code verification failed.' );

				return false;
			}

			// Validate the price.

			$is_price_valid = $this->validate_price_paid_by_user();

			if ( ! $is_price_valid ) {
				$this->log_error( 'User purchase validation: price verification failed.' );

				return false;
			}

			// All validations passed.

			$this->log_info( 'User purchase validation: all validations passed.' );

			return true;
		}

		/**
		 * Delete user hash meta values.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function delete_user_purchase_hash(): void {
			if ( ! empty( $this->hash_key ) ) {
				delete_transient( $this->hash_key );
			}
		}

		/**
		 * Update user hash meta values.
		 *
		 * @since 4.5.0
		 *
		 * @return bool True if user purchase hash transient is set, false otherwise.
		 */
		private function update_user_purchase_hash(): bool {
			if ( ! empty( $this->hash_key ) && ! empty( $this->user_hash ) ) {
				return set_transient( $this->hash_key, $this->user_hash, self::TRANSIENT_STORAGE_PERIOD );
			}

			return false;
		}

		/**
		 * Validates the price paid by the user.
		 *
		 * @since 4.20.1
		 *
		 * @return bool
		 */
		private function validate_price_paid_by_user(): bool {
			if ( ! isset( $this->user_hash['paid_price'] ) ) {
				$this->log_error( 'User purchase validation: price verification failed. Paid price is not set.' );

				return false;
			}

			$paid_price = Cast::to_float( $this->user_hash['paid_price'] );

			// Validating not recurring payments.

			if (
				! isset( $this->transaction_data['recurring'] )
				|| Cast::to_bool( $this->transaction_data['recurring'] ) === false
			) {
				// It's a one-time payment. The PayPal IPN sends the price in the 'mc_gross' field.
				if ( ! isset( $this->transaction_data['mc_gross'] ) ) {
					$this->log_error( 'User purchase validation: price verification failed. Transaction price is not set.' );

					return false;
				}

				if ( $paid_price !== Cast::to_float( $this->transaction_data['mc_gross'] ) ) {
					$this->log_error( 'User purchase validation: price verification failed. The expected paid price is different from the transaction price.' );

					return false;
				}
			} else {
				// Validating recurring payments.

				// Validating trial price.

				if (
					! empty( $this->user_hash[ Transaction::$meta_key_pricing_info ]['trial_price'] )
					|| ! empty( $this->user_hash[ Transaction::$meta_key_pricing_info ]['trial_duration_value'] )
					|| ! empty( $this->user_hash[ Transaction::$meta_key_pricing_info ]['trial_duration_length'] )
				) {
					if (
						! isset( $this->transaction_data['mc_amount1'] )
						|| ! isset( $this->transaction_data['period1'] )
					) {
						// The trial data is not set. It's an error.
						$this->log_error( 'User purchase validation: price verification failed. Transaction trial data is not set.' );

						return false;
					}

					// The trial price is sent in the 'mc_amount1' field.

					if ( $this->user_hash[ Transaction::$meta_key_pricing_info ]['trial_price'] !== Cast::to_float( $this->transaction_data['mc_amount1'] ) ) {
						$this->log_error( 'User purchase validation: price verification failed. The expected trial price is different from the transaction trial price.' );

						return false;
					}

					// Validating the trial period.

					$expected_period_value = $this->user_hash[ Transaction::$meta_key_pricing_info ]['trial_duration_value'] . ' ' . $this->user_hash[ Transaction::$meta_key_pricing_info ]['trial_duration_length'];

					if ( $expected_period_value !== $this->transaction_data['period1'] ) {
						$this->log_error( 'User purchase validation: price verification failed. The expected trial period is different from the transaction trial period.' );

						return false;
					}
				} elseif (
					// We don't have trial. It's an error if the transaction has trial data.
					(
						isset( $this->transaction_data['mc_amount1'] )
						|| isset( $this->transaction_data['period1'] )
					)
					&& (
						! empty( $this->transaction_data['mc_amount1'] )
						&& ! empty( $this->transaction_data['period1'] )
					)
				) {
					$this->log_error( 'User purchase validation: price verification failed. The transaction has trial data but the product does not have it.' );

					return false;
				}

				// Validating the subscription price.

				if (
					! isset( $this->transaction_data['mc_amount3'] )
					|| ! isset( $this->transaction_data['period3'] )
				) {
					// The subscription data is not set. It's an error.
					$this->log_error( 'User purchase validation: price verification failed. Transaction subscription data is not set.' );

					return false;
				}

				// The subscription price is sent in the 'mc_amount3' field.

				if (
					! isset( $this->user_hash[ Transaction::$meta_key_pricing_info ]['price'] )
					|| $this->user_hash[ Transaction::$meta_key_pricing_info ]['price'] !== Cast::to_float( $this->transaction_data['mc_amount3'] )
				) {
					$this->log_error( 'User purchase validation: price verification failed. The expected subscription price is different from the transaction subscription price.' );

					return false;
				}

				// Validating the subscription recurring times.

				if (
					(
						! empty( $this->user_hash[ Transaction::$meta_key_pricing_info ]['recurring_times'] )
						&& (
							! isset( $this->transaction_data['recur_times'] )
							|| $this->user_hash[ Transaction::$meta_key_pricing_info ]['recurring_times'] !== Cast::to_int( $this->transaction_data['recur_times'] )
						)
					)
					|| (
						empty( $this->user_hash[ Transaction::$meta_key_pricing_info ]['recurring_times'] )
						&& isset( $this->transaction_data['recur_times'] )
						&& Cast::to_int( $this->transaction_data['recur_times'] ) > 0
					)
				) {
					$this->log_error( 'User purchase validation: price verification failed. The expected subscription recurring times is different from the transaction subscription recurring times.' );

					return false;
				}

				// Validating the subscription period.

				$expected_period_value = $this->user_hash[ Transaction::$meta_key_pricing_info ]['duration_value'] . ' ' . $this->user_hash[ Transaction::$meta_key_pricing_info ]['duration_length'];

				if ( $expected_period_value !== $this->transaction_data['period3'] ) {
					$this->log_error( 'User purchase validation: price verification failed. The expected subscription period is different from the transaction subscription period.' );

					return false;
				}
			}

			// All validations passed.

			return true;
		}

		/**
		 * Process the PayPal webhook action.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function process_webhook_action(): void {
			$item_number = intval(
				! empty( $this->transaction_data['item_number'] ) ? $this->transaction_data['item_number'] : 0
			);

			switch ( $this->webhook_action ) {
				case self::RETURN_ACTION_NAME_SUCCESS:
					$this->log_info( 'Starting Processing action: ' . $this->webhook_action );

					/**
					 * If success we set the 'return-success' timestamp. This
					 * will be used to check for the 'return-notify' action.
					 */
					if ( ! isset( $this->user_hash[ self::RETURN_ACTION_NAME_SUCCESS ] ) ) {
						$this->user_hash[ self::RETURN_ACTION_NAME_SUCCESS ] = time();
					}

					$this->update_user_purchase_hash();

					$this->transaction_data['ld_ipn_action'] = $this->webhook_action;
					$this->transaction_data['ld_ipn_hash']   = $this->user_hash['nonce'] ?? '';
					$this->transaction_data['user_id']       = 0;
					$this->transaction_data['post_id']       = 0;
					$this->transaction_data['post_type']     = '';

					if ( isset( $this->user_hash['product_id'] ) ) {
						$product_id = absint( $this->user_hash['product_id'] );

						$this->transaction_data['post_id']   = $product_id;
						$this->transaction_data['post_type'] = get_post_type( $product_id );

						if ( learndash_get_post_type_slug( 'course' ) === $this->transaction_data['post_type'] ) {
							$this->transaction_data['course_id'] = $product_id;
						} elseif ( learndash_get_post_type_slug( 'group' ) === $this->transaction_data['post_type'] ) {
							$this->transaction_data['group_id'] = $product_id;
						}

						/**
						 * Set products being processed by this gateway.
						 *
						 * Use find_many() method to prepare for LearnDash cart system update.
						 * The post_id will be changed later to posts_ids that holds
						 * multiple post_id values after cart system update.
						 */
						$this->products = Product::find_many( array( $product_id ) );
					}

					if ( ! empty( $this->user_hash['user_id'] ) ) {
						$user_id = absint( $this->user_hash['user_id'] );

						$this->transaction_data['user_id'] = $user_id;

						$user = get_user_by( 'ID', $user_id );
						if ( is_object( $user ) && is_a( $user, 'WP_User' ) ) {
							$this->user = $user;
						}
					}

					if ( ! empty( $this->user->ID ) ) {
						$this->transaction_data['user_id'] = $this->user->ID;
					}

					// @phpstan-ignore-next-line
					$this->transaction_data[ $this->hash_key ] = $this->user_hash;

					if ( empty( $this->products ) ) {
						$this->products = Product::find_many( array( $item_number ) );
					}

					$redirect_url = $this->get_url_success( $this->products, $this->settings['paypal_returnurl'] ?? '' );

					/**
					 * Since LD v4.20.1 we stopped enrolling users before the IPN notification.
					 * Including a flag to show an alert message to the user.
					 */

					$redirect_url = add_query_arg(
						[
							'ld_paypal_success' => 1,
							'nonce'             => $this->hash_nonce,
						],
						$redirect_url
					);

					$this->exit( $redirect_url );
					break;

				case self::RETURN_ACTION_NAME_CANCEL:
					$this->delete_user_purchase_hash();

					if ( isset( $this->user_hash['product_id'] ) ) {
						$product_id = absint( $this->user_hash['product_id'] );

						$this->products = Product::find_many( array( $product_id ) );
					}

					$redirect_url = $this->get_url_fail( $this->products, $this->settings['paypal_cancelurl'] );
					$this->exit( $redirect_url );
					break;

				case self::RETURN_ACTION_NAME_NOTIFY:
					$this->delete_user_purchase_hash();

					// Check if transaction has been created for similar transaction.
					$query_args = array(
						'user_id' => $this->transaction_data['custom'] ?? null,
						'post_id' => $item_number,
					);

					if ( ! empty( $this->transaction_data['txn_id'] ) ) {
						$query_args['txn_id'] = $this->transaction_data['txn_id'];
					}

					if (
						! empty( $this->transaction_data['txn_type'] )
						&& $this->transaction_data['txn_type'] === 'subscr_signup'
						&& ! empty( $this->transaction_data['subscr_id'] )
					) {
						$query_args['txn_type']  = 'subscr_signup';
						$query_args['subscr_id'] = $this->transaction_data['subscr_id'];
					}

					$transactions = Transaction::find_many_by_meta( $query_args );

					/**
					 * When we support recurring payment transaction record, we
					 * need to remove 'subscr_payment' check and add logic to
					 * handle subscription payment.
					 */
					if ( ! empty( $transactions ) || ( ! empty( $this->transaction_data['txn_type'] ) && $this->transaction_data['txn_type'] === 'subscr_payment' ) ) {
						$message = 'Transaction is already created for this IPN transaction.';
						$this->log_error( $message );
						// We should return 200 to PayPal to avoid multiple retries of the same IPN message that is not processed.
						$this->exit( '', true, $message, 200 );
					}

					break;

				default:
					$this->delete_user_purchase_hash();

					$message = 'Unknown hash action: ' . $this->webhook_action;
					$this->log_error( $message );
					// We should return 200 to PayPal to avoid multiple retries of the same IPN message that is not processed.
					$this->exit( '', true, $message, 200 );
			}
		}

		/**
		 * Initialize the `$transaction_data` from the IPN POST data.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function process_webhook_data_init(): void {
			// @phpstan-ignore-next-line
			$this->transaction_data = array_map( 'sanitize_text_field', $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing

			// First log our incoming vars.
			$this->log_info( 'IPN Post vars ' . print_r( $this->transaction_data, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r

			$this->log_info( 'IPN Get vars ' . print_r( $_GET, true ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.PHP.DevelopmentFunctions.error_log_print_r

			$this->log_info( 'LearnDash Version: ' . LEARNDASH_VERSION ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
		}

		/**
		 * Initialize the PayPal IPN Listener.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function process_webhook_ipn_listener(): void {
			$this->log_info( 'IPN Listener Loading...' );

			if ( ! file_exists( LEARNDASH_LMS_LIBRARY_DIR . '/paypal/ipnlistener.php' ) ) {
				$message = 'Required file not found: ' . LEARNDASH_LMS_LIBRARY_DIR . '/paypal/ipnlistener.php';
				$this->log_error( $message );
				// We should return 404 to PayPal to retry the webhook as it's a server error.
				$this->exit( '', true, $message, 404 );
			}

			if ( ! class_exists( 'IpnListener' ) ) {
				require LEARNDASH_LMS_LIBRARY_DIR . '/paypal/ipnlistener.php';
			}

			$learndash_paypal_ipn_listener = new IpnListener();

			/**
			 * Fires after instantiating an ipn listener object to allow override of public attributes.
			 *
			 * @since 2.2.1.2
			 *
			 * @param Object  $learndash_paypal_ipn_listener An instance of IpnListener Class.
			 */
			do_action_ref_array( 'learndash_ipnlistener_init', array( &$learndash_paypal_ipn_listener ) );

			$this->log_info( 'IPN Listener Loaded' );

			if ( $this->is_test_mode() ) {
				$this->log_info( 'PayPal Sandbox Enabled.' );
				$learndash_paypal_ipn_listener->use_sandbox = true;
			} else {
				$this->log_info( 'PayPal Live Enabled.' );
				$learndash_paypal_ipn_listener->use_sandbox = false;
			}

			try {
				$this->log_info( 'Checking IPN Post Method.' );

				$learndash_paypal_ipn_listener->requirePostMethod();
				$learndash_paypal_ipn_verified = $learndash_paypal_ipn_listener->processIpn();

				$this->log_info( 'IPN Post method check completed.' );

				if ( ! $learndash_paypal_ipn_verified ) {
					/**
					 * An Invalid IPN *may* be caused by a fraudulent transaction
					 * attempt. It's a good idea to have a developer or sys admin
					 * manually investigate any invalid IPN.
					 */
					$message = 'Invalid IPN, shutting down processing.';
					$this->log_error( $message );
					$this->exit( '', true, $message, 401 );
				}
			} catch ( Exception $e ) {
				$this->log_error( 'IPN Post method error: ' . print_r( $e->getMessage(), true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
				$message = 'Invalid IPN parameters.';
				$this->log_error( $message );
				$this->exit( '', true, $message, 422 );
			}
		}

		/**
		 * Validate the IPN POST data.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function process_webhook_data_validation(): void {
			$this->ipn_validate_payment_type();

			if ( empty( $this->transaction_data['notify_version'] ) ) {
				$message = 'PayPal POST parameter "notify_version" missing or empty. Aborting.';
				$this->log_error( $message );
				$this->exit( '', true, $message, 422 );
			}

			if ( ! empty( $this->transaction_data['txn_type'] ) && in_array( $this->transaction_data['txn_type'], array( 'web_accept', 'subscr_payment' ), true ) ) {
				$this->ipn_validate_payment_status();

				if ( empty( $this->transaction_data['mc_gross'] ) ) {
					$message = 'Missing or empty "mc_gross" in IPN data.';
					$this->log_error( $message );
					$this->exit( '', true, $message, 422 );
				} else {
					$this->log_info( "Valid IPN 'mc_gross' : " . $this->transaction_data['mc_gross'] );
				}
			}

			if ( empty( $this->transaction_data['item_number'] ) ) {
				$message = 'Invalid or missing "item_number" in IPN data.';
				$this->log_error( $message );
				$this->exit( '', true, $message, 422 );
			}

			$this->ipn_validate_customer_data();
			$this->ipn_validate_receiver_data();
		}

		/**
		 * Process the IPN cancel subscription listener.
		 *
		 * This function will revoke the user access when the subscription is cancelled, expired, or failed.
		 *
		 * @since 4.25.3
		 *
		 * @return void
		 */
		private function process_webhook_cancel_subscription_listener(): void {
			$txn_type = $this->transaction_data['txn_type'] ?? '';

			if ( ! in_array( $txn_type, [ 'subscr_cancel', 'subscr_eot', 'subscr_failed' ], true ) ) {
				return;
			}

			$type = $txn_type === 'subscr_cancel'
				? 'canceled'
				: (
					$txn_type === 'subscr_eot'
						? 'expired'
						: 'failed'
				);

			$message = sprintf(
				'Subscription %s %s for course/group ID: %s',
				$this->transaction_data['subscr_id'] ?? '',
				$type,
				$this->transaction_data['item_number'] ?? ''
			);

			$this->log_info( $message );

			$this->products = isset( $this->transaction_data['item_number'] ) ?
				Product::find_many( [ $this->transaction_data['item_number'] ] )
				: [];

			if ( isset( $this->transaction_data['custom'] ) ) {
				$user = get_user_by( 'id', Cast::to_int( $this->transaction_data['custom'] ) );

				if ( $user instanceof WP_User ) {
					$this->user = $user;
				}
			}

			// We should skip the revoke access process if it's a migrated subscription.
			if (
				$this->user instanceof WP_User
				&& Migration_User_Data::has_migrated_products( $this->user->ID, $this->products, $this->is_test_mode() )
			) {
				$this->log_info( 'Subscription is a migrated subscription. Skipping the revoke access process.' );

				$this->exit( '', false, 'Migrated subscription. Skipping the revoke access process.', 200 );
				return;
			}

			// Revoke access.
			$this->revoke_access();

			$this->exit( '', false, 'Subscription cancelled.', 200 );
		}

		/**
		 * Process the IPN POST data.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function process_webhook_data(): void {
			$this->transaction_data['post_type']        = '';
			$this->transaction_data['post_type_prefix'] = '';
			$this->transaction_data['post_id']          = ! empty( $this->transaction_data['item_number'] ) ? absint( $this->transaction_data['item_number'] ) : 0;
			$this->transaction_data['post_type']        = get_post_type( $this->transaction_data['post_id'] );
			$this->transaction_data['post_type_prefix'] = is_string( $this->transaction_data['post_type'] ) ? LDLMS_Post_Types::get_post_type_key( $this->transaction_data['post_type'] ) : '';

			if ( learndash_is_course_post( $this->transaction_data['post_id'] ) ) {
				$this->log_info( 'Purchased Course access [' . $this->transaction_data['post_id'] . ']' );
			} elseif ( learndash_is_group_post( $this->transaction_data['post_id'] ) ) {
				$this->log_info( 'Purchased Group access [' . $this->transaction_data['post_id'] . ']' );
			}

			if ( empty( $this->transaction_data['post_id'] ) ) {
				$message = 'Invalid "post_id" in IPN data. Unable to determine related Course/Group post.';
				$this->log_error( $message );
				// We should return 200 to PayPal to avoid multiple retries of the same IPN message that is broken.
				$this->exit( '', true, $message, 200 );
			}

			if ( empty( $this->transaction_data['post_type'] ) ) {
				$message = 'Invalid "post_id" in IPN data. Unable to determine related Course/Group post.';
				$this->log_error( $message );
				// We should return 200 to PayPal to avoid multiple retries of the same IPN message that is broken.
				$this->exit( '', true, $message, 200 );
			}
		}

		/**
		 * Process the user data.
		 *
		 * This is where the use is created in the WordPress system if needed.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function process_webhook_user_data(): void {
			if ( ! empty( $this->transaction_data['custom'] ) ) {
				$user = get_user_by( 'id', absint( $this->transaction_data['custom'] ) );
				if ( is_object( $user ) && is_a( $user, 'WP_User' ) ) {
					$this->user = $user;
				}

				if ( is_object( $this->user ) && is_a( $this->user, 'WP_User' ) ) {
					$this->log_info( "Valid 'custom' in IPN data: [" . absint( $this->transaction_data['custom'] ) . ']. Matched to User ID [' . $this->user->ID . ']' );

					// @phpstan-ignore-next-line
					$this->transaction_data['user_id'] = $this->user->ID;
				} else {
					$this->log_error( "Unknown User ID 'custom' in IPN data: " . absint( $this->transaction_data['custom'] ) );
					$this->log_info( "Continue processing to create new user from IPN 'payer_email'." );
				}
			}

			if ( empty( $this->user->ID ) ) {
				$payer_email = ! empty( $this->transaction_data['payer_email'] ) ? $this->transaction_data['payer_email'] : '';
				$payer_id    = ! empty( $this->transaction_data['payer_id'] ) ? $this->transaction_data['payer_id'] : '';

				$this->user = $this->find_or_create_user(
					0,
					(string) $payer_email,
					(string) $payer_id
				);

				// @phpstan-ignore-next-line
				$this->transaction_data['user_id'] = $this->user->ID ?? null;

				if ( ! empty( $this->transaction_data['first_name'] ) || ! empty( $this->transaction_data['last_name'] ) ) {
					$first_name = ! empty( $this->transaction_data['first_name'] ) ? $this->transaction_data['first_name'] : '';
					$last_name  = ! empty( $this->transaction_data['last_name'] ) ? $this->transaction_data['last_name'] : '';

					$this->log_info( 'Updating User: ' . $this->transaction_data['user_id'] . ' first_name: ' . $first_name . ' last_name: ' . $last_name );

					wp_update_user(
						array(
							// @phpstan-ignore-next-line
							'ID'         => $this->user->ID,
							'first_name' => $first_name,
							'last_name'  => $last_name,
						)
					);
				}
			}

			// @phpstan-ignore-next-line
			$this->log_info( 'User returned with user_id: ' . $this->user->ID );
		}

		/**
		 * Complete the IPN Transaction Processing.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function process_webhook_completion(): void {
			$this->log_info( 'Process webhook completion' );

			if ( ! empty( $this->transaction_data['txn_type'] ) && in_array( $this->transaction_data['txn_type'], array( 'web_accept', 'subscr_signup', 'subscr_payment' ), true ) ) {
				$this->grant_access();
			}

			foreach ( $this->products as $product ) {
				try {
					$this->record_transaction(
						$this->map_transaction_meta( $this->transaction_data, $product )->to_array(),
						$product->get_post(),
						// @phpstan-ignore-next-line
						$this->user
					);

					$this->log_info( 'Recorded transaction for product ID: ' . $product->get_id() );
				} catch ( Learndash_DTO_Validation_Exception $e ) {
					$message = 'Error recording transaction: ' . $e->getMessage();
					$this->log_error( $message );
					// We should return 200 to PayPal to avoid multiple retries of the same IPN message that is broken.
					$this->exit( '', true, $message, 200 );
				}
			}
		}

		/**
		 * Grant user access.
		 *
		 * This function will enroll the user in the Course/Group.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function grant_access(): void {
			$this->log_info(
				// @phpstan-ignore-next-line
				'Starting to give Course access: User ID[' . $this->user->ID . '] Products[' . $this->transaction_data['post_id'] . ']'
			);

			if ( empty( $this->user->ID ) || empty( $this->transaction_data['post_id'] ) || empty( $this->products ) ) {
				return;
			}

			$access_updates = $this->add_access_to_products( $this->products, $this->user );

			foreach ( $access_updates as $product_id => $update ) {
				if ( $update ) {
					$this->log_info( 'User enrolling into Product[' . $product_id . '] success.' );
				} else {
					$this->log_info( 'User enrolling into Product[' . $product_id . '] failed.' );
				}
			}
		}

		/**
		 * Revokes the user access.
		 *
		 * This function will remove the user from the Product.
		 *
		 * @since 4.20.1
		 *
		 * @return void
		 */
		private function revoke_access(): void {
			if (
				$this->user === null
				|| empty( $this->user->ID )
				|| empty( $this->products )
			) {
				$this->log_info( 'User ID or Products are empty. Skipping the revoke access process.' );
				return;
			}

			$this->log_info(
				'Starting to revoke the user access: User ID[' . $this->user->ID . '] Products[' . implode(
					',',
					array_map(
						function ( $product ) {
							return $product->get_id();
						},
						$this->products
					)
				) . ']'
			);

			$access_updates = $this->remove_access_to_products( $this->products, $this->user ); // @phpstan-ignore-line -- $this->user can't be null at this point.

			foreach ( $access_updates as $product_id => $update ) {
				if ( $update ) {
					$this->log_info( "User removed from Product[$product_id] successfully." );
				} else {
					$this->log_info( "User removal from Product[$product_id] failed or user was not enrolled." );
				}
			}
		}

		/**
		 * Validate the IPN POST Payment Type.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function ipn_validate_payment_type(): void {
			if ( ! isset( $this->transaction_data['txn_type'] ) ) {
				$message = 'Missing transaction parameter "txn_type" in IPN data';
				$this->log_error( $message );
				$this->exit( '', true, $message, 422 );
			}

			if ( ! empty( $this->transaction_data['txn_type'] ) ) {
				switch ( $this->transaction_data['txn_type'] ) {
					case 'web_accept':
					case 'subscr_signup':
					case 'subscr_payment':
					case 'subscr_cancel':
					case 'subscr_failed':
					case 'subscr_eot':
						$this->log_info( 'Valid IPN txn_type: ' . $this->transaction_data['txn_type'] );
						break;

					default:
						$message = 'Unsupported transaction txn_type: ' . $this->transaction_data['txn_type'];
						$this->log_error( $message );
						// We should return 200 to PayPal to avoid multiple retries of the same IPN message that is not processed.
						$this->exit( '', true, $message, 200 );
						break;
				}
			}
		}

		/**
		 * Validate the IPN POST Payment Status.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function ipn_validate_payment_status(): void {
			if ( ! empty( $this->transaction_data['txn_type'] ) ) {
				switch ( $this->transaction_data['txn_type'] ) {
					case 'web_accept':
					case 'subscr_payment':
						if ( ! isset( $this->transaction_data['payment_status'] ) ) {
							$message = 'Missing "payment_status" in IPN data.';
							$this->log_error( $message );
							$this->exit( '', true, $message, 422 );
						}

						if ( ! empty( $this->transaction_data['payment_status'] ) && self::PAYPAL_IPN_PAYMENT_STATUS_COMPLETED !== strtolower( $this->transaction_data['payment_status'] ) ) {
							$message = "Parameter 'payment_status' is not 'completed' in IPN data.";
							$this->log_error( $message );
							$this->exit( '', true, $message, 422 );
						}

						if ( ! empty( $this->transaction_data['payment_status'] ) ) {
							$this->log_info( "Valid IPN 'payment_status': " . $this->transaction_data['payment_status'] );
						}
						break;

					default:
						break;
				}
			}
		}

		/**
		 * Validate the IPN Customer data.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function ipn_validate_customer_data(): void {
			if ( ! isset( $this->transaction_data['payer_email'] ) ) {
				$message = 'Missing transaction "payer_email" in IPN data.';
				$this->log_error( $message );
				$this->exit( '', true, $message, 422 );
			}

			$this->transaction_data['payer_email'] = mb_strtolower( sanitize_email( $this->transaction_data['payer_email'] ?? '' ) );
			$this->transaction_data['first_name']  = sanitize_text_field( $this->transaction_data['first_name'] ?? '' );
			$this->transaction_data['last_name']   = sanitize_text_field( $this->transaction_data['last_name'] ?? '' );

			if ( ! is_email( $this->transaction_data['payer_email'] ) ) {
				$message = 'Invalid "payer_email" in IPN data. Email: ' . $this->transaction_data['payer_email'];
				$this->log_error( $message );
				$this->exit( '', true, $message, 422 );
			}

			if ( ! empty( $this->transaction_data['payer_email'] ) ) {
				$this->log_info( "Valid IPN 'payer_email' : " . $this->transaction_data['payer_email'] );
			}
		}

		/**
		 * Validate the IPN Receiver data.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		private function ipn_validate_receiver_data(): void {
			$valid_ipn_email = false;

			if ( isset( $this->transaction_data['receiver_email'] ) ) {
				$this->transaction_data['receiver_email'] = sanitize_email( $this->transaction_data['receiver_email'] );
				$this->transaction_data['receiver_email'] = strtolower( $this->transaction_data['receiver_email'] );

				if ( $this->transaction_data['receiver_email'] === $this->settings['paypal_email'] ) {
					$valid_ipn_email = true;
				}

				$this->log_info( 'Receiver Email: ' . $this->transaction_data['receiver_email'] . ' Valid Receiver Email? :' . ( true === $valid_ipn_email ? 'YES' : 'NO' ) );
			}

			if ( isset( $this->transaction_data['business'] ) ) {
				$this->transaction_data['business'] = sanitize_email( $this->transaction_data['business'] );
				$this->transaction_data['business'] = strtolower( $this->transaction_data['business'] );

				if ( $this->transaction_data['business'] === $this->settings['paypal_email'] ) {
					$valid_ipn_email = true;
				}

				$this->log_info( 'Business Email: ' . $this->transaction_data['business'] . ' Valid Business Email? :' . ( true === $valid_ipn_email ? 'YES' : 'NO' ) );
			}

			if ( true !== $valid_ipn_email ) {
				$message = 'IPN with invalid receiver/business email.';
				$this->log_error( $message );
				$this->exit( '', true, $message, 422 );
			}
		}

		/**
		 * IPN Processing exit.
		 *
		 * This is a general cleanup function called at the end of
		 * processing on an abort. This function will finish out the
		 * processing log before exit.
		 *
		 * @since 4.5.0
		 *
		 * @param string $redirect_url Redirect URL on exit.
		 * @param bool   $error        True if it's an error|false otherwise.
		 * @param string $message      Message output.
		 * @param int    $status_code  HTTP status code return.
		 *
		 * @return void
		 */
		private function exit( string $redirect_url = '', bool $error = false, string $message = '', int $status_code = 200 ): void {
			if ( ! empty( $redirect_url ) ) {
				learndash_safe_redirect( $redirect_url );
				wp_die();
			}

			if ( $error ) {
				wp_send_json_error(
					array(
						'message' => $message,
					),
					$status_code
				);
			} else {
				wp_send_json_success(
					array(
						'message' => $message,
					),
					$status_code
				);
			}
		}

		/**
		 * Maps transaction meta.
		 *
		 * @since 4.5.0
		 *
		 * @param array<mixed> $data    Data.
		 * @param Product      $product Product.
		 *
		 * @throws Learndash_DTO_Validation_Exception Transaction data validation exception.
		 *
		 * @return Learndash_Transaction_Meta_DTO
		 */
		protected function map_transaction_meta( $data, Product $product ): Learndash_Transaction_Meta_DTO {
			// @phpstan-ignore-line Parent method doesn't have parameter type for $data.
			// We need to build PayPal IPN transaction DTO here as PayPal IPN doesn't support event metadata.
			$is_subscription_event = ! empty( $data['subscr_id'] );

			$pricing = $product->get_pricing();
			/**
			 * Pricing.
			 *
			 * @var array<string,mixed> $pricing_array Pricing.
			 */
			$pricing_array = $pricing->to_array();

			$pricing_info = ! empty( $this->user_hash[ Transaction::$meta_key_pricing_info ] ) ?
				Learndash_Pricing_DTO::create(
					// @phpstan-ignore-next-line -- Variable array key name.
					$this->user_hash[ Transaction::$meta_key_pricing_info ]
				) :
				Learndash_Pricing_DTO::create(
					$pricing_array
				);

			$meta = array(
				Transaction::$meta_key_gateway_name        => $this::get_name(),
				Transaction::$meta_key_is_test_mode        => isset( $data['test_ipn'] ) && '1' === $data['test_ipn'],
				Transaction::$meta_key_price_type          => ! $is_subscription_event ? LEARNDASH_PRICE_TYPE_PAYNOW : LEARNDASH_PRICE_TYPE_SUBSCRIBE,
				Transaction::$meta_key_pricing_info        => $pricing_info,
				Transaction::$meta_key_has_trial           => $is_subscription_event && ! empty( $data['period1'] ),
				Transaction::$meta_key_has_free_trial      => $is_subscription_event && ! empty( $data['period1'] ) && isset( $data['mc_amount1'] ) && ( '0.00' === strval( $data['mc_amount1'] ) || '0' === strval( $data['mc_amount1'] ) ),
				Transaction::$meta_key_gateway_transaction => Learndash_Transaction_Gateway_Transaction_DTO::create(
					array(
						'id'          => $is_subscription_event ? $data['subscr_id'] : $data['txn_id'],
						'customer_id' => $data['payer_id'] ?? '',
						'event'       => $data,
					)
				),
			);

			return Learndash_Transaction_Meta_DTO::create( $meta );
		}
	}
}
