<?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;

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_email    : string,
	 *      paypal_sandbox  : string,
	 *      paypal_country  : string,
	 *      paypal_returnurl: string,
	 *      paypal_cancelurl: string,
	 *      paypal_notifyurl: string,
	 * } $settings
	 *
	 * @phpstan-type User_Hash_Type array{
	 *     nonce?         : string,
	 *     post_id?       : int,
	 *     product_id?    : int,
	 *     return-success?: string|int,
	 *     subscr_id?     : string,
	 *     txn_type?      : string,
	 *     user_id?       : int,
	 *     pricing_info?  : array<string,mixed>
	 * } | array{}
	 *
	 * @phpstan-type Transaction_Data_Type array{
	 *     business?        : string,
	 *     business_email?  : string,
	 *     custom?          : int,
	 *     first_name?      : string,
	 *     item_number?     : int,
	 *     last_name?       : string,
	 *     mc_gross?        : float,
	 *     notify_version?  : int,
	 *     payer_email?     : string,
	 *     payer_id?        : string,
	 *     payment_status?  : string,
	 *     post_id?         : int,
	 *     post_type?       : string|false,
	 *     post_type_prefix?: string,
	 *     receiver_email?  : 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;

		/**
		 * 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
		 *
		 * @return string
		 */
		public static function get_label(): string {
			return esc_html__( 'PayPal', 'learndash' );
		}

		/**
		 * Adds hooks.
		 *
		 * @since 4.5.0
		 *
		 * @return void
		 */
		public function add_extra_hooks(): void {
			// No hooks.
		}

		/**
		 * 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 = 'Invalid webhook action.';
				$this->log_error( $message );
				$this->exit( '', true, $message, 400 );
			}

			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 ( ! $this->verify_user_purchase_hash() ) {
					$message = 'Hash nonce verification failed.';
					$this->log_error( $message );
					$this->exit( '', true, $message, 422 );
				}

				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->is_ready() ) {
				$this->log_error( 'PayPal gateway is not enabled yet or PayPal email address is empty.' );

				return;
			}

			// 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_data_init();

			$this->process_webhook_action();

			$this->process_webhook_ipn_listener();

			$this->process_webhook_data_validation();

			$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 transaction 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 {
			try {
				$product = Product::create_from_post( $post );
				$pricing = $product->get_pricing( $this->user );
			} catch ( Exception $e ) {
				return '';
			}

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

			/** This filter is documented in includes/payments/gateways/class-learndash-stripe-gateway.php */
			$price               = apply_filters( 'learndash_get_price_by_coupon', $pricing->price, $product->get_id(), $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 );
			$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';

			if ( $product->is_price_type_paynow() ) {
				$shortcode_content = do_shortcode( '[paypal type="paynow" button_label="' . $button_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 . '" 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
		 *
		 * @param int                   $product_id      Product ID.
		 * @param Learndash_Pricing_DTO $product_pricing Product pricing DTO.
		 *
		 * @return string
		 */
		private function generate_user_purchase_hash( int $product_id, Learndash_Pricing_DTO $product_pricing ): string {
			$hash_nonce = wp_create_nonce(
				( isset( $this->user->ID ) && $this->user->ID > 0 ? $this->user->ID : time() ) . '-' . $product_id
			);

			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,
					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 {
			/**
			 * 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.
			 */
			return ! empty( $this->user_hash['nonce'] ) && ! empty( $this->hash_nonce ) && $this->user_hash['nonce'] === $this->hash_nonce;
		}

		/**
		 * 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;
		}

		/**
		 * 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;

					$this->transaction_data['ld_payment_processor'] = self::get_name();

					$this->grant_access();

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

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

					$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:
					// 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 );
						$this->exit( '', true, $message, 400 );
					}
					break;

				default:
					$message = 'Unknown hash action: ' . $this->webhook_action;
					$this->log_error( $message );
					$this->exit( '', true, $message, 400 );

			}
		}

		/**
		 * 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 );
				$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 ( ! empty( $this->settings['paypal_sandbox'] ) ) {
				$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 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 );
				$this->exit( '', true, $message, 422 );
			}

			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 );
				$this->exit( '', true, $message, 422 );
			}
		}

		/**
		 * 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 );
					$this->exit( '', true, $message, 422 );
				}
			}
		}

		/**
		 * 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.' );
				}
			}
		}

		/**
		 * 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 );
						$this->exit( '', true, $message, 422 );
						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_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'],
						'event' => $data,
					)
				),
			);

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