<?php
/**
 * PayPal Payouts Admin Class
 *
 * @package     AffiliateWP
 * @subpackage  Payouts/Methods/PayPalPayouts
 * @since       2.29.0
 */

// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

#[\AllowDynamicProperties]

class AffiliateWP_PayPal_Payouts_Admin {

	/**
	 * API instance
	 */
	private $api;

	/**
	 * Get things started
	 *
	 * @since 2.29.0
	 * @return void
	 */
	public function __construct() {

		// Initialize API based on mode
		$mode = affiliate_wp()->settings->get( 'paypal_payout_mode', 'masspay' );

		switch ( $mode ) {
			case 'masspay':
				$this->api = new \AffiliateWP_PayPal_MassPay();
				break;

			case 'api':
			default:
				$this->api = new \AffiliateWP_PayPal_API();
				break;
		}

		$this->api->credentials = affiliate_wp_paypal()->get_api_credentials();

		// Settings registration.
		$version = get_option( 'affwp_version' );

		// Keeps compatibility with older versions of AffiliateWP.
		if ( version_compare( $version, '2.18.0', '<' ) ) {
			add_filter( 'affwp_settings_tabs', [ $this, 'setting_tab' ] );
			add_filter( 'affwp_settings', [ $this, 'register_settings_legacy' ] );
		} else {
			// Use the new section system.
			add_filter( 'affwp_settings_commissions', [ $this, 'register_settings' ] );
			add_action( 'affiliatewp_after_register_admin_sections', [ $this, 'register_section' ] );
		}

		// Payouts screen functionality.
		if ( affiliate_wp_paypal()->has_2_4() ) {
			add_action( 'affwp_preview_payout_note_paypal', [ $this, 'preview_payout_note' ] );
			add_filter( 'affwp_preview_payout_data_paypal', [ $this, 'preview_payout_data' ] );
			add_filter( 'affwp_preview_payout_invalid_affiliates_paypal', [ $this, 'preview_payout_invalid_affiliates' ], 10, 2 );
			add_action( 'affwp_process_payout_paypal', [ $this, 'process_bulk_paypal_payout' ], 10, 6 );

			/*
			 * PayPal in REST API mode is async: payouts stay 'processing' until verification
			 * confirms them, and are excluded from the CSV export.
			 *
			 * The batch status filter and completion action are registered in the always-loaded
			 * bootstrap (class-paypal-payouts.php init()), not here: the batch runs in non-admin
			 * cron workers where this admin class never loads.
			 */
			add_filter( 'affwp_in_system_payout_methods', [ $this, 'filter_in_system_methods' ] );
		}

		// Referrals admin screen functionality.
		if ( $this->should_display_pay_now_links() ) {
			// "Pay Now via PayPal" (row + bulk) is superseded by the unified "Pay" action;
			// PayPal registers its single-referral handler instead. The single-pay direct
			// link handler is kept for back-compat with any external affwp_action=pay_now URLs.
			add_action( 'affwp_pay_now', [ $this, 'process_pay_now' ] );

			add_filter( 'affwp_single_referral_payout_handlers', [ $this, 'register_single_referral_handler' ] );
		}

		add_action( 'admin_notices', [ $this, 'admin_notices' ] );

		// AJAX handler for testing PayPal connection.
		add_action( 'wp_ajax_affwp_test_paypal_connection', [ $this, 'ajax_test_connection' ] );

		/*
		 * PayPal payout detail data is rendered by the payout drawer's Summary card, not via
		 * affwp_edit_payout_end. display_paypal_payout_details() is kept callable for third-party
		 * code but is no longer auto-registered, which would double-render inside the drawer.
		 */

		// Manual "Check Status Now" action.
		add_action( 'affwp_check_paypal_payout_status', [ $this, 'handle_manual_status_check' ] );

		if ( ! affiliate_wp_paypal()->has_2_4() ) {
			add_action( 'affwp_referrals_page_buttons', [ $this, 'bulk_pay_form' ] );
			add_action( 'affwp_process_bulk_paypal_payout', [ $this, 'process_bulk_paypal_payout_legacy' ] );
		}
	}

	/**
	 * Register the new settings tab
	 *
	 * @since 2.29.0
	 *
	 * @param array $tabs The array of tabs.
	 *
	 * @return array
	 */
	public function setting_tab( array $tabs ) : array {
		$tabs['paypal'] = __( 'PayPal Payouts', 'affiliate-wp' );
		return $tabs;
	}

	/**
	 * Return the list of settings.
	 *
	 * @since 2.29.0
	 * @return array
	 */
	private function get_settings() : array {

		return [
			'paypal_payout_mode'    => [
				'name'    => __( 'Payout API to Use', 'affiliate-wp' ),
				'desc'    => __( 'Select the payout method you wish to use. PayPal MassPay is an older technology not available to all accounts. See <a href="https://affiliatewp.com/docs/paypal-payouts-installation-and-usage/" target="_blank" rel="noopener noreferrer">documentation</a> for assistance.', 'affiliate-wp' ),
				'type'    => 'select',
				'options' => [
					'api'     => __( 'API Application', 'affiliate-wp' ),
					'masspay' => __( 'MassPay', 'affiliate-wp' ),
				],
			],
			'paypal_test_mode'      => [
				'name' => __( 'Test Mode', 'affiliate-wp' ),
				'desc' => __( 'Check this box if you would like to use PayPal Payouts in Test Mode', 'affiliate-wp' ),
				'type' => 'checkbox',
			],
			'paypal_api_header'     => [
				'name' => __( 'PayPal API Application Credentials', 'affiliate-wp' ),
				'desc' => __( 'Enter your PayPal API Application credentials.', 'affiliate-wp' ),
				'type' => 'header',
			],
			'paypal_live_client_id' => [
				'name' => __( 'Client ID', 'affiliate-wp' ),
				'desc' => __( 'Enter your PayPal Application\'s Client ID. Create or retrieve these from <a href="https://developer.paypal.com/home/" target="_blank">PayPal\'s Developer portal</a>.', 'affiliate-wp' ),
				'type' => 'text',
			],
			'paypal_live_secret'    => [
				'name' => __( 'Secret', 'affiliate-wp' ),
				'desc' => __( 'Enter your PayPal Application\'s Secret.', 'affiliate-wp' ),
				'type' => 'text',
			],
			'paypal_test_client_id' => [
				'name' => __( 'Test Client ID', 'affiliate-wp' ),
				'desc' => __( 'Enter your Sandbox PayPal Application\'s Client ID.', 'affiliate-wp' ),
				'type' => 'text',
			],
			'paypal_test_secret'    => [
				'name' => __( 'Test Secret', 'affiliate-wp' ),
				'desc' => __( 'Enter your Sandbox PayPal Application\'s Secret.', 'affiliate-wp' ),
				'type' => 'text',
			],
			'paypal_masspay_header' => [
				'name' => __( 'PayPal MassPay Credentials', 'affiliate-wp' ),
				'desc' => __( 'Enter your Test API Username.', 'affiliate-wp' ),
				'type' => 'header',
			],
			'paypal_test_username'  => [
				'name' => __( 'Test API Username', 'affiliate-wp' ),
				'desc' => __( 'Enter your Test API Username.', 'affiliate-wp' ),
				'type' => 'text',
			],
			'paypal_test_password'  => [
				'name' => __( 'Test API Password', 'affiliate-wp' ),
				'desc' => __( 'Enter your Test API Password.', 'affiliate-wp' ),
				'type' => 'text',
			],
			'paypal_test_signature' => [
				'name' => __( 'Test API Signature', 'affiliate-wp' ),
				'desc' => __( 'Enter your Test API Signature.', 'affiliate-wp' ),
				'type' => 'text',
			],
			'paypal_live_username'  => [
				'name' => __( 'Live API Username', 'affiliate-wp' ),
				'desc' => __( 'Enter your Live API Username.', 'affiliate-wp' ),
				'type' => 'text',
			],
			'paypal_live_password'  => [
				'name' => __( 'Live API Password', 'affiliate-wp' ),
				'desc' => __( 'Enter your Live API Password.', 'affiliate-wp' ),
				'type' => 'text',
			],
			'paypal_live_signature' => [
				'name' => __( 'Live API Signature', 'affiliate-wp' ),
				'desc' => __( 'Enter your Live API Signature.', 'affiliate-wp' ),
				'type' => 'text',
			],
		];
	}

	/**
	 * Register the settings for our PayPal Payouts tab.
	 *
	 * @since 2.29.0
	 *
	 * @param array $settings The array of settings.
	 *
	 * @return array
	 */
	public function register_settings( array $settings ) : array {

		return array_merge_recursive(
			$settings,
			$this->get_settings()
		);
	}

	/**
	 * Register the settings for our PayPal Payouts tab for older AffiliateWP versions.
	 *
	 * @since 2.29.0
	 *
	 * @param array $settings The array of settings.
	 *
	 * @return array
	 */
	public function register_settings_legacy( array $settings ) : array {

		$settings['paypal'] = $this->get_settings();

		return $settings;
	}

	/**
	 * Register the settings section.
	 *
	 * @since 2.29.0
	 */
	public function register_section() {

		if ( ! method_exists( affiliate_wp()->settings, 'register_section' ) ) {
			return; // It is an old AffiliateWP and do not have support for sections.
		}

		affiliate_wp()->settings->register_section(
			'commissions',
			'paypal_payouts',
			__( 'PayPal Payouts Payment Method', 'affiliate-wp' ),
			apply_filters(
				'affiliatewp_register_section_paypal_payouts',
				[
					'paypal_payout_mode',
					'paypal_test_mode',
					'paypal_api_header',
					'paypal_live_client_id',
					'paypal_live_secret',
					'paypal_test_client_id',
					'paypal_test_secret',
					'paypal_masspay_header',
					'paypal_test_username',
					'paypal_test_password',
					'paypal_test_signature',
					'paypal_live_username',
					'paypal_live_password',
					'paypal_live_signature',
				]
			),
			'',
			[
				'required_field' => 'paypal_payouts',
				'value'          => true,
			],
		);
	}



	/**
	 * Add a note to the Payout preview page for a PayPal payout.
	 *
	 * @since 2.29.0
	 *
	 * @return void
	 */
	public function preview_payout_note() {
		?>
		<p><?php esc_html_e( "If an affiliate doesn't have a PayPal account, PayPal emails them a link to sign up. If they don't claim the payout within 30 days, the funds are returned to your account.", 'affiliate-wp' ); ?></p>
		<?php
	}

	/**
	 * Displays the list of valid affiliates that can be paid out via PayPal.
	 *
	 * @since 2.29.0
	 *
	 * @param array $data Payout data.
	 * @return array (Maybe) filtered payout data.
	 */
	public function preview_payout_data( $data ) {

		foreach ( $data as $affiliate_id => $payout_data ) {

			$affiliate = affwp_get_affiliate( $affiliate_id );

			if ( ! $affiliate->user ) {
				unset( $data[ $affiliate_id ] );
			}
		}

		return $data;
	}

	/**
	 * Filter out the list of invalid affiliates on the payout preview page.
	 *
	 * @since 2.29.0
	 *
	 * @param array $invalid_affiliates Invalid affiliates.
	 * @param array $data               Payout data.
	 * @return array Modified array of invalid affiliates.
	 */
	public function preview_payout_invalid_affiliates( $invalid_affiliates, $data ) {

		foreach ( $data as $affiliate_id => $payout_data ) {

			$affiliate = affwp_get_affiliate( $affiliate_id );

			if ( ! $affiliate->user ) {
				$invalid_affiliates[ $affiliate_id ] = 'user_account_deleted';
			}
		}

		return $invalid_affiliates;
	}

	/**
	 * Payout referrals in bulk for a specified timeframe via PayPal.
	 *
	 * @since 2.29.0
	 *
	 * @param string $start         Referrals start date.
	 * @param string $end           Referrals end date data.
	 * @param float  $minimum       Minimum payout.
	 * @param int    $affiliate_id  Affiliate ID.
	 * @param string $payout_method Payout method.
	 * @param bool   $bypass_holding Bypass holding period.
	 *
	 * @return void
	 */
	public function process_bulk_paypal_payout( $start, $end, $minimum, $affiliate_id, $payout_method, $bypass_holding ) {

		if ( ! current_user_can( 'manage_payouts' ) ) {
			wp_die( __( 'You do not have permission to process payouts', 'affwp-paypal-payouts' ) );
		}

		if ( ! affiliate_wp_paypal()->has_api_credentials() ) {
			wp_die( __( 'Please enter your API credentials in Affiliates &rarr; Settings &rarr; PayPal Payouts before attempting to process payments', 'affwp-paypal-payouts' ) );
		}

		$args = [
			'status'       => 'unpaid',
			'number'       => -1,
			'affiliate_id' => $affiliate_id,
			'date'         => [
				'start' => $start,
				'end'   => $end,
			],
		];

		// Final  affiliate / referral data to be paid out.
		$data = [];

		// The affiliates that have earnings to be paid.
		$affiliates = [];

		// Retrieve the referrals from the database.
		$referrals = affiliate_wp()->referrals->get_referrals( $args );

		if ( $referrals ) {

			foreach ( $referrals as $referral ) {

				// If not bypassing the holding period, skip referrals within the holding period.
				if ( ! $bypass_holding && affwp_is_referral_within_holding_period( $referral ) ) {
					continue;
				}

				$affiliate = affwp_get_affiliate( $referral->affiliate_id );

				if ( ! $affiliate->user ) {
					continue;
				}

				if ( in_array( $referral->affiliate_id, $affiliates ) ) {

					// Add the amount to an affiliate that already has a referral in the export.
					$amount = $data[ $referral->affiliate_id ]['amount'] + $referral->amount;

					$data[ $referral->affiliate_id ]['amount']      = $amount;
					$data[ $referral->affiliate_id ]['referrals'][] = $referral->referral_id;

				} else {

					$email = affwp_get_affiliate_payment_email( $referral->affiliate_id );

					$data[ $referral->affiliate_id ] = [
						'email'     => $email,
						'amount'    => $referral->amount,
						'currency'  => ! empty( $referral->currency ) ? $referral->currency : affwp_get_currency(),
						'referrals' => [ $referral->referral_id ],
					];

					$affiliates[] = $referral->affiliate_id;

				}
			}

			$payouts = [];

			$i = 0;

			foreach ( $data as $affiliate_id => $payout ) {

				if ( $minimum > 0 && $payout['amount'] < $minimum ) {

					// Ensure the minimum amount was reached.
					unset( $data[ $affiliate_id ] );

					// Skip to the next affiliate.
					continue;
				}

				$payouts[ $affiliate_id ] = [
					'email'       => $payout['email'],
					'amount'      => $payout['amount'],
					/* translators: 1: Referrals start date, 2: Referrals end date, 3: Home URL */
					/* translators: %s: Site name. Recipient-facing PayPal note shown to the affiliate. */
					'description' => sprintf( __( 'Affiliate commission from %s', 'affiliate-wp' ), get_bloginfo( 'name' ) ),
					'referrals'   => $payout['referrals'],
				];

				++$i;
			}

			$redirect_args = [
				'affwp_notice' => 'paypal_bulk_pay_success',
			];

			$response = $this->api->send_bulk_payment( $payouts );

			if ( is_wp_error( $response ) ) {

				$redirect_args['affwp_notice'] = 'paypal_error';
				$redirect_args['message']      = $response->get_error_message();
				$redirect_args['code']         = $response->get_error_code();

			} elseif ( is_array( $response ) && $this->is_api_mode() ) {

				// REST API mode: create payouts with 'processing' status and schedule async verification.
				$batch_id  = $response['batch_header']['payout_batch_id'] ?? '';
				$self_link = $response['links'][0]['href'] ?? '';

				$redirect_args['affwp_notice'] = 'paypal_bulk_pay_processing';

				/*
				 * This path pays via PayPal, but affwp_add_payout() fires the Store Credit hook,
				 * which keys off the affiliate's resolved method. Force the gate FALSE across these
				 * writes so a Store Credit affiliate isn't double-paid (transfer plus balance bump).
				 */
				$force_store_credit = static function () {
					return false;
				};
				add_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

				foreach ( $payouts as $affiliate_id => $payout ) {
					$payout_id = affwp_add_payout( [
						'affiliate_id'  => $affiliate_id,
						'referrals'     => $payout['referrals'],
						'amount'        => $payout['amount'],
						'payout_method' => 'paypal',
						'status'        => 'processing',
						'description'   => wp_json_encode( [
							'paypal_batch_id' => $batch_id,
							'batch_status'    => 'PENDING',
							'self_link'       => $self_link,
							'mode'            => 'bulk',
							'poll_count'      => 0,
							'last_checked'    => null,
							'created_at'      => gmdate( 'Y-m-d H:i:s' ),
						] ),
					] );

					// Schedule the first status check.
					if ( $payout_id && function_exists( 'as_schedule_single_action' ) ) {
						as_schedule_single_action(
							time() + 120,
							'affwp_paypal_check_payout_status',
							[ 'payout_id' => $payout_id ],
							'affiliatewp'
						);
					}
				}

				remove_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

			} else {

				/*
				 * MassPay or legacy: create payouts with 'paid' status immediately.
				 * Same Store Credit double-pay protection as the REST API branch above.
				 */
				$force_store_credit = static function () {
					return false;
				};
				add_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

				foreach ( $payouts as $affiliate_id => $payout ) {
					if ( function_exists( 'affwp_add_payout' ) ) {
						affwp_add_payout( [
							'affiliate_id'  => $affiliate_id,
							'referrals'     => $payout['referrals'],
							'amount'        => $payout['amount'],
							'payout_method' => 'paypal',
						] );
					} else {
						foreach ( $payout['referrals'] as $referral ) {
							affwp_set_referral_status( $referral, 'paid' );
						}
					}
				}

				remove_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );
			}

			$redirect = affwp_admin_url( 'referrals', $redirect_args );

			// A header is used here instead of wp_redirect() due to the esc_url() bug that removes [] from URLs.
			header( 'Location:' . $redirect );
			exit;

		}
	}

	/**
	 * Excludes PayPal from the batch-generated CSV when running in REST API mode.
	 *
	 * REST API mode pays through PayPal's server-to-server Payouts API; the admin never
	 * needs the CSV for those affiliates. MassPay mode still needs the CSV (admin uploads
	 * it to PayPal manually), so we leave that path untouched.
	 *
	 * @since 2.33.0
	 *
	 * @param string[] $methods In-system payout method identifiers.
	 * @return string[]
	 */
	public function filter_in_system_methods( $methods ) {

		if ( ! is_array( $methods ) ) {
			$methods = [];
		}

		if ( $this->is_api_mode() && affiliate_wp_paypal()->has_api_credentials() ) {
			$methods[] = 'paypal';
		}

		return $methods;
	}

	/**
	 * Sets the initial status of batch-generated PayPal payouts to 'processing' in REST API mode.
	 *
	 * The payout stays in 'processing' (with its referrals 'unpaid') until the PayPal
	 * Payouts API confirms the transaction via `affwp_paypal_check_payout_status`.
	 *
	 * @since 2.33.0
	 *
	 * @param string $status        Default initial status ('paid').
	 * @param string $payout_method Method identifier for this payout.
	 * @return string
	 */
	public function filter_batch_initial_status( $status, $payout_method ) {

		if ( 'paypal' !== $payout_method ) {
			return $status;
		}

		if ( ! $this->is_api_mode() || ! affiliate_wp_paypal()->has_api_credentials() ) {
			return $status;
		}

		return 'processing';
	}

	/**
	 * Submits PayPal-method payouts from a completed batch to the PayPal Payouts API.
	 *
	 * Runs once at the end of a per-affiliate batch. Collects every 'processing' PayPal
	 * payout in the batch, submits them as one PayPal bulk request, stamps each payout
	 * with the returned batch ID, and schedules verification. On API failure, transitions
	 * them to 'failed' with the error stored in the description so the admin can see
	 * what went wrong.
	 *
	 * @since 2.33.0
	 *
	 * @param int $batch_id Payout batch ID.
	 * @return void
	 */
	public function process_generated_batch( $batch_id ) {

		$batch_id = absint( $batch_id );

		if ( empty( $batch_id ) ) {
			return;
		}

		if ( ! $this->is_api_mode() || ! affiliate_wp_paypal()->has_api_credentials() ) {
			return;
		}

		$payouts = affiliate_wp()->affiliates->payouts->get_payouts( [
			'batch_id'      => $batch_id,
			'payout_method' => 'paypal',
			'status'        => 'processing',
			'number'        => -1,
		] );

		if ( empty( $payouts ) ) {
			return;
		}

		// Keyed by affiliate ID so the response maps back to the right payout record.
		$request = [];

		foreach ( $payouts as $payout ) {

			/*
			 * Idempotency guard. This hook can fire more than once for a batch. A payout that
			 * already carries a PayPal batch ID has been submitted; re-sending it errors at PayPal
			 * as a duplicate and would falsely fail an in-flight payment.
			 */
			$existing = json_decode( (string) $payout->description, true );
			if ( is_array( $existing ) && ! empty( $existing['paypal_batch_id'] ) ) {
				continue;
			}

			$email = affwp_get_affiliate_payment_email( $payout->affiliate_id );

			if ( empty( $email ) ) {
				// No payment email on file, so it can't route through PayPal. Mark failed and skip; leaving it 'processing' would never resolve.
				$this->fail_generated_payout(
					$payout->payout_id,
					__( 'Affiliate has no PayPal payment email on file.', 'affiliate-wp' )
				);
				continue;
			}

			$referrals = ! empty( $payout->referrals ) ? explode( ',', $payout->referrals ) : [];

			$request[ $payout->affiliate_id ] = [
				'email'       => $email,
				'amount'      => $payout->amount,
				/* translators: %d: batch id, %s: site URL */
				/* translators: %s: Site name. This is the recipient-facing note PayPal shows the affiliate, so it uses the store name (not an internal batch ID or raw URL). */
				'description' => sprintf( __( 'Affiliate commission from %s', 'affiliate-wp' ), get_bloginfo( 'name' ) ),
				'referrals'   => array_map( 'intval', $referrals ),
				'payout_id'   => $payout->payout_id,
			];
		}

		if ( empty( $request ) ) {
			return;
		}

		$response = $this->api->send_bulk_payment( $request );

		if ( is_wp_error( $response ) ) {
			$error_message = $response->get_error_message();

			foreach ( $request as $entry ) {
				$this->fail_generated_payout( $entry['payout_id'], $error_message );
			}

			return;
		}

		if ( ! is_array( $response ) ) {
			foreach ( $request as $entry ) {
				$this->fail_generated_payout(
					$entry['payout_id'],
					__( 'Unexpected response from PayPal.', 'affiliate-wp' )
				);
			}

			return;
		}

		$paypal_batch_id = $response['batch_header']['payout_batch_id'] ?? '';
		$self_link       = $response['links'][0]['href'] ?? '';

		foreach ( $request as $entry ) {
			$payout_id = $entry['payout_id'];

			affiliate_wp()->affiliates->payouts->update(
				$payout_id,
				[
					'description' => wp_json_encode( [
						'paypal_batch_id' => $paypal_batch_id,
						'batch_status'    => 'PENDING',
						'self_link'       => $self_link,
						'mode'            => 'bulk',
						'poll_count'      => 0,
						'last_checked'    => null,
						'created_at'      => gmdate( 'Y-m-d H:i:s' ),
					] ),
				],
				'',
				'payout'
			);

			if ( function_exists( 'as_schedule_single_action' ) ) {
				as_schedule_single_action(
					time() + 120,
					'affwp_paypal_check_payout_status',
					[ 'payout_id' => $payout_id ],
					'affiliatewp'
				);
			}
		}
	}

	/**
	 * Transitions a batch-generated PayPal payout to 'failed' and records the reason.
	 *
	 * @since 2.33.0
	 *
	 * @param int    $payout_id Payout ID.
	 * @param string $message   Failure reason.
	 * @return void
	 */
	private function fail_generated_payout( $payout_id, $message ) {

		affiliate_wp()->affiliates->payouts->update(
			$payout_id,
			[
				'status'      => 'failed',
				'description' => wp_json_encode( [
					'paypal_batch_id' => '',
					'batch_status'    => 'FAILED',
					'error'           => (string) $message,
					'created_at'      => gmdate( 'Y-m-d H:i:s' ),
				] ),
			],
			'',
			'payout'
		);

		// Referrals on a failed payout go back to 'unpaid' so the admin can retry.
		$payout = affwp_get_payout( $payout_id );

		if ( $payout && ! empty( $payout->referrals ) ) {
			foreach ( array_map( 'absint', explode( ',', $payout->referrals ) ) as $rid ) {
				if ( $rid ) {
					affwp_set_referral_status( $rid, 'unpaid' );
				}
			}
		}

		$this->maybe_recount_batch( $payout_id );
	}

	/**
	 * Determines whether to display Pay Now action links.
	 *
	 * This check is based on the logic that initiating a payment "now" should only
	 * be possible if 'paypal' payout method is enabled.
	 *
	 * @since 2.29.0
	 *
	 * @return bool True if the Pay Now links should be displayed, otherwise false.
	 */
	public function should_display_pay_now_links() {

		// Compat for pre-AffiliateWP 2.4.
		if ( ! affiliate_wp_paypal()->has_2_4() ) {
			return true;
		}

		$enabled_payout_methods = affwp_get_enabled_payout_methods();

		if ( in_array( 'paypal', $enabled_payout_methods ) ) {
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Add new action links to the referral actions column
	 *
	 * @access public
	 * @since 2.29.0
	 * @return array
	 */
	public function action_links( $links, $referral ) {

		if ( ! affiliate_wp_paypal()->has_api_credentials() ) {
			return $links;
		}

		if (
			'unpaid' !== $referral->status
			|| ! current_user_can( 'manage_referrals' )
			|| empty( affwp_get_affiliate_payment_email( $referral->affiliate_id ) )
		) {
			return $links;
		}

		// Only surface the inline pay link when the affiliate's configured method is paypal, so affiliates on another method aren't silently paid via PayPal.
		if ( function_exists( 'affwp_get_affiliate_payout_method' )
			&& 'paypal' !== affwp_get_affiliate_payout_method( $referral->affiliate_id )
		) {
			return $links;
		}

		$links[] = sprintf(
			'<a href="%1$s" class="%2$s">%3$s</a>',
			esc_url(
				add_query_arg(
					[
						'affwp_action' => 'pay_now',
						'referral_id'  => $referral->referral_id,
						'affiliate_id' => $referral->affiliate_id,
					]
				)
			),
			esc_attr( 'affwp-pay-now-via-paypal' ),
			esc_html__( 'Pay Now via PayPal', 'affiliate-wp' )
		);

		return $links;
	}

	/**
	 * Register a new bulk action
	 *
	 * @access public
	 * @since 2.29.0
	 * @return array
	 */
	public function bulk_actions( $actions ) {

		if ( affiliate_wp_paypal()->has_api_credentials() ) {

			$actions['pay_now'] = __( 'Pay Now via PayPal', 'affiliate-wp' );

		}

		return $actions;
	}

	/**
	 * Render the Bulk Pay section
	 *
	 * @access public
	 * @since 2.29.0
	 * @return void
	 */
	public function bulk_pay_form() {

		if ( ! affiliate_wp_paypal()->has_api_credentials() ) {
			return;
		}
		?>
		<script>
		jQuery(document).ready(function($) {
			// Show referral export form
			$('.affwp-referrals-paypal-payout-toggle').click(function() {
				$('.affwp-referrals-paypal-payout-toggle').toggle();
				$('#affwp-referrals-paypal-payout-form').slideToggle();
			});
			$('#affwp-referrals-paypal-payout-form').submit(function() {
				if( ! confirm( "<?php _e( 'Are you sure you want to payout referrals for the specified time frame via Paypal?', 'affiliate-wp' ); ?>" ) ) {
					return false;
				}
			});
		});
		</script>
		<button class="button-primary affwp-referrals-paypal-payout-toggle"><?php _e( 'Bulk Pay via Paypal', 'affiliate-wp' ); ?></button>
		<button class="button-primary affwp-referrals-paypal-payout-toggle" style="display:none"><?php _e( 'Close', 'affiliate-wp' ); ?></button>
		<form id="affwp-referrals-paypal-payout-form" class="affwp-gray-form" style="display:none;" action="<?php echo admin_url( 'admin.php?page=affiliate-wp-referrals' ); ?>" method="post">
			<p>
				<input type="text" class="affwp-datepicker" autocomplete="off" name="from" placeholder="<?php _e( 'From - mm/dd/yyyy', 'affiliate-wp' ); ?>"/>
				<input type="text" class="affwp-datepicker" autocomplete="off" name="to" placeholder="<?php _e( 'To - mm/dd/yyyy', 'affiliate-wp' ); ?>"/>
				<input type="text" class="affwp-text" name="minimum" placeholder="<?php esc_attr_e( 'Minimum amount', 'affiliate-wp' ); ?>"/>
				<input type="hidden" name="affwp_action" value="process_bulk_paypal_payout"/>
				<input type="submit" value="<?php _e( 'Process Payout via Paypal', 'affiliate-wp' ); ?>" class="button-secondary"/>
				<p><?php printf( __( 'This will send payments via Paypal for all unpaid referrals in the specified timeframe.', 'affiliate-wp' ), admin_url( 'admin.php?page=affiliate-wp-tools&tab=export_import' ) ); ?></p>
			</p>
		</form>
		<?php
	}

	/**
	 * Process a single referral payment
	 *
	 * @access public
	 * @since 2.29.0
	 * @return void
	 */
	public function process_pay_now( $data ) {

		$referral_id = absint( $data['referral_id'] );

		if ( empty( $referral_id ) ) {
			return;
		}

		if ( ! current_user_can( 'manage_referrals' ) ) {
			wp_die( __( 'You do not have permission to process payments', 'affiliate-wp' ) );
		}

		if ( ! affiliate_wp_paypal()->has_api_credentials() ) {
			wp_die( __( 'Please enter your API credentials in Affiliates > Settings > PayPal Payouts before attempting to process payments', 'affiliate-wp' ) );
		}

		$result = $this->pay_referral( $referral_id );

		if ( is_wp_error( $result ) ) {

			wp_safe_redirect( admin_url( 'admin.php?page=affiliate-wp-referrals&affwp_notice=paypal_error&message=' . urlencode( $result->get_error_message() ) . '&code=' . urlencode( $result->get_error_code() ) ) );
			exit;

		}

		// Show appropriate notice based on payout mode.
		$notice = $this->is_api_mode() ? 'paypal_processing' : 'paypal_success';

		wp_safe_redirect( admin_url( 'admin.php?page=affiliate-wp-referrals&affwp_notice=' . $notice . '&referral=' . $referral_id ) );
		exit;
	}

	/**
	 * Process a referral payment for a bulk payout
	 *
	 * @access public
	 * @since 2.29.0
	 * @return void
	 */
	public function process_bulk_action_pay_now( $referral_id ) {

		if ( empty( $referral_id ) ) {
			return;
		}

		if ( ! current_user_can( 'manage_referrals' ) ) {
			return;
		}

		if ( ! affiliate_wp_paypal()->has_api_credentials() ) {
			wp_die( __( 'Please enter your API credentials in Affiliates > Settings > PayPal Payouts before attempting to process payments', 'affiliate-wp' ) );
		}

		$transfer = $this->pay_referral( $referral_id );
	}

	/**
	 * Payouts referrals in bulk for a specified timeframe (Legacy method for pre-2.4)
	 *
	 * All referrals are summed and then paid as a single transfer for each affiliate
	 *
	 * @access public
	 * @since 2.29.0
	 * @return void
	 */
	public function process_bulk_paypal_payout_legacy() {

		if ( ! current_user_can( 'manage_referrals' ) ) {
			wp_die( __( 'You do not have permission to process payments', 'affiliate-wp' ) );
		}

		if ( ! affiliate_wp_paypal()->has_api_credentials() ) {
			wp_die( __( 'Please enter your API credentials in Affiliates > Settings > PayPal Payouts before attempting to process payments', 'affiliate-wp' ) );
		}

		$start = ! empty( $_POST['from'] ) ? sanitize_text_field( $_POST['from'] ) : false;
		$end   = ! empty( $_POST['to'] ) ? sanitize_text_field( $_POST['to'] ) : false;

		$args = [
			'status' => 'unpaid',
			'date'   => [
				'start' => $start,
				'end'   => $end,
			],
			'number' => -1,
		];

		// Final  affiliate / referral data to be paid out
		$data = [];

		// The affiliates that have earnings to be paid
		$affiliates = [];

		// Retrieve the referrals from the database
		$referrals = affiliate_wp()->referrals->get_referrals( $args );

		// The minimum payout amount
		$minimum = ! empty( $_POST['minimum'] ) ? sanitize_text_field( affwp_sanitize_amount( $_POST['minimum'] ) ) : 0;

		if ( $referrals ) {

			foreach ( $referrals as $referral ) {

				if ( in_array( $referral->affiliate_id, $affiliates ) ) {

					// Add the amount to an affiliate that already has a referral in the export

					$amount = $data[ $referral->affiliate_id ]['amount'] + $referral->amount;

					$data[ $referral->affiliate_id ]['amount']      = $amount;
					$data[ $referral->affiliate_id ]['referrals'][] = $referral->referral_id;

				} else {

					$user_name = affwp_get_affiliate_username( $referral->affiliate_id );

					// Skip if affiliate user account has been deleted.
					if ( ! $user_name ) {
						continue;
					}

					$email = affwp_get_affiliate_payment_email( $referral->affiliate_id );

					$data[ $referral->affiliate_id ] = [
						'email'     => $email,
						'amount'    => $referral->amount,
						'currency'  => ! empty( $referral->currency ) ? $referral->currency : affwp_get_currency(),
						'referrals' => [ $referral->referral_id ],
					];

					$affiliates[] = $referral->affiliate_id;

				}
			}

			$payouts = [];

			$i = 0;
			foreach ( $data as $affiliate_id => $payout ) {

				if ( $minimum > 0 && $payout['amount'] < $minimum ) {

					// Ensure the minimum amount was reached

					unset( $data[ $affiliate_id ] );

					// Skip to the next affiliate
					continue;

				}

				$payouts[ $affiliate_id ] = [
					'email'       => $payout['email'],
					'amount'      => $payout['amount'],
					/* translators: %s: Site name. Recipient-facing PayPal note shown to the affiliate. */
					'description' => sprintf( __( 'Affiliate commission from %s', 'affiliate-wp' ), get_bloginfo( 'name' ) ),
					'referrals'   => $payout['referrals'],
				];
				++$i;
			}

			$redirect_args = [
				'affwp_notice' => 'paypal_bulk_pay_success',
			];

			if ( empty( $payouts ) ) {
				$redirect_args['affwp_notice'] = 'paypal_bulk_pay_empty_error';

				$redirect = affwp_admin_url( 'referrals', $redirect_args );

				wp_redirect( $redirect );
				exit;
			}

			$response = $this->api->send_bulk_payment( $payouts );

			if ( is_wp_error( $response ) ) {

				$redirect_args['affwp_notice'] = 'paypal_error';
				$redirect_args['message']      = $response->get_error_message();
				$redirect_args['code']         = $response->get_error_code();

			} elseif ( is_array( $response ) && $this->is_api_mode() ) {

				// REST API mode: create payouts with 'processing' status.
				$batch_id  = $response['batch_header']['payout_batch_id'] ?? '';
				$self_link = $response['links'][0]['href'] ?? '';

				$redirect_args['affwp_notice'] = 'paypal_bulk_pay_processing';

				/*
				 * This path pays via PayPal, but affwp_add_payout() fires the Store Credit hook,
				 * which keys off the affiliate's resolved method. Force the gate FALSE across these
				 * writes so a Store Credit affiliate isn't double-paid (transfer plus balance bump).
				 */
				$force_store_credit = static function () {
					return false;
				};
				add_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

				foreach ( $payouts as $affiliate_id => $payout ) {
					$payout_id = affwp_add_payout( [
						'affiliate_id'  => $affiliate_id,
						'referrals'     => $payout['referrals'],
						'amount'        => $payout['amount'],
						'payout_method' => 'paypal',
						'status'        => 'processing',
						'description'   => wp_json_encode( [
							'paypal_batch_id' => $batch_id,
							'batch_status'    => 'PENDING',
							'self_link'       => $self_link,
							'mode'            => 'bulk',
							'poll_count'      => 0,
							'last_checked'    => null,
							'created_at'      => gmdate( 'Y-m-d H:i:s' ),
						] ),
					] );

					if ( $payout_id && function_exists( 'as_schedule_single_action' ) ) {
						as_schedule_single_action(
							time() + 120,
							'affwp_paypal_check_payout_status',
							[ 'payout_id' => $payout_id ],
							'affiliatewp'
						);
					}
				}

				remove_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

			} else {

				/*
				 * MassPay or legacy: create payouts with 'paid' status immediately.
				 * Same Store Credit double-pay protection as the REST API branch above.
				 */
				$force_store_credit = static function () {
					return false;
				};
				add_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

				foreach ( $payouts as $affiliate_id => $payout ) {
					if ( function_exists( 'affwp_add_payout' ) ) {
						affwp_add_payout( [
							'affiliate_id'  => $affiliate_id,
							'referrals'     => $payout['referrals'],
							'amount'        => $payout['amount'],
							'payout_method' => 'paypal',
						] );
					} else {
						foreach ( $payout['referrals'] as $referral ) {
							affwp_set_referral_status( $referral, 'paid' );
						}
					}
				}

				remove_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );
			}

			$redirect = affwp_admin_url( 'referrals', $redirect_args );

			// A header is used here instead of wp_redirect() due to the esc_url() bug that removes [] from URLs
			header( 'Location:' . $redirect );
			exit;

		}
	}

	/**
	 * Pay a referral.
	 *
	 * For the REST API mode, creates a payout with 'processing' status and
	 * schedules async verification via Action Scheduler. For MassPay mode,
	 * payouts are created with 'paid' status immediately (synchronous).
	 *
	 * @access private
	 * @since 2.29.0
	 * @param int $referral_id The referral ID to pay.
	 * @return true|WP_Error True on success, WP_Error on failure.
	 */
	/**
	 * Registers the PayPal single-referral pay handler with the unified dispatcher.
	 *
	 * @since 2.33.0
	 *
	 * @param array $handlers Handler map keyed by payout-method slug.
	 * @return array
	 */
	public function register_single_referral_handler( $handlers ) {
		$handlers['paypal'] = array( $this, 'pay_single_referral' );
		return $handlers;
	}

	/**
	 * Public entry point for {@see affwp_pay_referral()} to pay one referral via PayPal.
	 *
	 * @since 2.33.0
	 *
	 * @param int $referral_id Referral ID.
	 * @return true|\WP_Error
	 */
	public function pay_single_referral( $referral_id ) {
		return $this->pay_referral( $referral_id );
	}

	private function pay_referral( $referral_id = 0 ) {

		if ( empty( $referral_id ) ) {
			return false;
		}

		$referral = affwp_get_referral( $referral_id );

		if ( ! affiliate_wp_paypal()->has_api_credentials() ) {
			return new WP_Error( 'missing_api_keys', __( 'Please enter your API credentials in Affiliates > Settings > PayPal Payouts before attempting to process payments', 'affiliate-wp' ) );
		}

		if ( empty( $referral ) ) {
			return new WP_Error( 'invalid_referral', __( 'The specified referral does not exist', 'affiliate-wp' ) );
		}

		if ( empty( $referral->affiliate_id ) ) {
			return new WP_Error( 'no_affiliate', __( 'There is no affiliate connected to this referral', 'affiliate-wp' ) );
		}

		if ( 'unpaid' != $referral->status ) {
			return new WP_Error( 'referral_not_unpaid', __( 'A payment cannot be processed for this referral since it is not marked as Unpaid', 'affiliate-wp' ) );
		}

		$user_name = affwp_get_affiliate_username( $referral->affiliate_id );

		if ( ! $user_name ) {
			return new WP_Error( 'user_account_deleted', __( 'This affiliate user account has been deleted', 'affiliate-wp' ) );
		}

		$email = affwp_get_affiliate_payment_email( $referral->affiliate_id );

		if ( empty( $email ) ) {
			return new WP_Error( 'no_email', __( 'This affiliate account does not have a Paypal email attached', 'affiliate-wp' ) );
		}

		$description = sprintf( __( 'Payment for referral #%1$d, %2$s', 'affiliate-wp' ), $referral_id, $referral->description );

		$response = $this->api->send_payment( [
			'email'        => $email,
			'amount'       => $referral->amount,
			'description'  => $description,
			'affiliate_id' => $referral->affiliate_id,
			'referral_id'  => $referral_id,
		] );

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

		/*
		 * This path pays via PayPal, but affwp_add_payout() fires the Store Credit hook, which
		 * keys off the affiliate's resolved method. Force the gate FALSE across these writes so a
		 * Store Credit affiliate isn't double-paid (transfer plus balance bump).
		 */
		$force_store_credit = static function () {
			return false;
		};
		add_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

		/*
		 * REST API mode: the response is an array with batch data.
		 * MassPay mode: the response is true (synchronous success).
		 */
		if ( is_array( $response ) && $this->is_api_mode() ) {

			$batch_id  = $response['batch_header']['payout_batch_id'] ?? '';
			$self_link = $response['links'][0]['href'] ?? '';

			$payout_id = affwp_add_payout( [
				'affiliate_id'  => $referral->affiliate_id,
				'referrals'     => $referral->ID,
				'amount'        => $referral->amount,
				'payout_method' => 'paypal',
				'status'        => 'processing',
				'description'   => wp_json_encode( [
					'paypal_batch_id' => $batch_id,
					'batch_status'    => 'PENDING',
					'self_link'       => $self_link,
					'mode'            => 'single',
					'poll_count'      => 0,
					'last_checked'    => null,
					'created_at'      => gmdate( 'Y-m-d H:i:s' ),
				] ),
			] );

			// Schedule the first status check.
			if ( $payout_id && function_exists( 'as_schedule_single_action' ) ) {
				as_schedule_single_action(
					time() + 120,
					'affwp_paypal_check_payout_status',
					[ 'payout_id' => $payout_id ],
					'affiliatewp'
				);
			}

		} else {
			// MassPay or legacy: create payout with 'paid' status immediately.
			affwp_add_payout( [
				'affiliate_id'  => $referral->affiliate_id,
				'referrals'     => $referral->ID,
				'amount'        => $referral->amount,
				'payout_method' => 'paypal',
			] );
		}

		remove_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

		return true;
	}

	/**
	 * Check if the current payout mode is REST API (not MassPay).
	 *
	 * @access private
	 * @since 2.33.0
	 * @return bool
	 */
	private function is_api_mode() {
		$mode = affiliate_wp()->settings->get( 'paypal_payout_mode', 'masspay' );

		return 'api' === $mode;
	}

	/**
	 * Display PayPal-specific payout details on the payout view page.
	 *
	 * Hooked into 'affwp_edit_payout_end' to add PayPal batch data,
	 * transaction statuses, and a "Check Status Now" button.
	 *
	 * @access public
	 * @since 2.33.0
	 * @param \AffWP\Affiliate\Payout $payout The payout object.
	 * @return void
	 */
	public function display_paypal_payout_details( $payout ) {

		// Only show for PayPal payouts.
		if ( ! in_array( strtolower( $payout->payout_method ), [ 'paypal', 'paypal payouts' ], true ) ) {
			return;
		}

		$paypal_data = json_decode( $payout->description, true );

		// Only show PayPal details if description contains batch data.
		if ( ! is_array( $paypal_data ) || empty( $paypal_data['paypal_batch_id'] ) ) {
			return;
		}

		$batch_id     = $paypal_data['paypal_batch_id'];
		$batch_status = $paypal_data['batch_status'] ?? 'UNKNOWN';
		$last_checked = $paypal_data['last_checked'] ?? null;
		$poll_count   = $paypal_data['poll_count'] ?? 0;
		$is_sandbox   = affiliate_wp_paypal()->is_test_mode();
		$domain       = $is_sandbox ? 'sandbox.paypal.com' : 'paypal.com';

		?>
		<tr class="form-row">
			<th scope="row">
				<?php esc_html_e( 'PayPal Batch ID', 'affiliate-wp' ); ?>
			</th>
			<td>
				<code><?php echo esc_html( $batch_id ); ?></code>
				<?php if ( $is_sandbox ) : ?>
					&mdash; <a href="https://developer.paypal.com/dashboard/api-calls/sandbox" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'View in Developer Dashboard', 'affiliate-wp' ); ?></a>
				<?php else : ?>
					&mdash; <a href="https://www.paypal.com/activity" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'View PayPal Activity', 'affiliate-wp' ); ?></a>
				<?php endif; ?>
			</td>
		</tr>

		<tr class="form-row">
			<th scope="row">
				<?php esc_html_e( 'PayPal Batch Status', 'affiliate-wp' ); ?>
			</th>
			<td>
				<?php $this->render_paypal_status_badge( $batch_status ); ?>
			</td>
		</tr>

		<?php if ( $last_checked ) : ?>
		<tr class="form-row">
			<th scope="row">
				<?php esc_html_e( 'Last Verified', 'affiliate-wp' ); ?>
			</th>
			<td>
				<?php
				echo esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $last_checked ) + ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ) ) );
				printf( ' (%s)', esc_html( sprintf(
					/* translators: %d: number of checks */
					_n( '%d check', '%d checks', $poll_count, 'affiliate-wp' ),
					$poll_count
				) ) );
				?>
			</td>
		</tr>
		<?php endif; ?>

		<?php if ( 'processing' === $payout->status ) : ?>
		<tr class="form-row">
			<th scope="row"></th>
			<td>
				<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=affiliate-wp-payouts&affwp_action=check_paypal_payout_status&payout_id=' . $payout->ID ), 'affwp_check_paypal_status_' . $payout->ID ) ); ?>" class="button button-secondary">
					<?php esc_html_e( 'Check Status Now', 'affiliate-wp' ); ?>
				</a>
			</td>
		</tr>
		<?php endif; ?>

		<?php
		// Show per-referral PayPal transaction details.
		$referral_ids = array_map( 'absint', explode( ',', $payout->referrals ) );
		$has_item_data = false;

		foreach ( $referral_ids as $rid ) {
			if ( affwp_get_referral_meta( $rid, '_paypal_transaction_status', true ) ) {
				$has_item_data = true;
				break;
			}
		}

		if ( $has_item_data ) :
		?>
		</table>

		<h2><?php esc_html_e( 'PayPal Transaction Details', 'affiliate-wp' ); ?></h2>
		<table class="wp-list-table widefat fixed striped">
			<thead>
				<tr>
					<th><?php esc_html_e( 'Referral', 'affiliate-wp' ); ?></th>
					<th><?php esc_html_e( 'Recipient', 'affiliate-wp' ); ?></th>
					<th><?php esc_html_e( 'Status', 'affiliate-wp' ); ?></th>
					<th><?php esc_html_e( 'Transaction ID', 'affiliate-wp' ); ?></th>
					<th><?php esc_html_e( 'Fee', 'affiliate-wp' ); ?></th>
					<th><?php esc_html_e( 'Details', 'affiliate-wp' ); ?></th>
				</tr>
			</thead>
			<tbody>
			<?php foreach ( $referral_ids as $rid ) :
				$txn_status    = affwp_get_referral_meta( $rid, '_paypal_transaction_status', true );
				$txn_id        = affwp_get_referral_meta( $rid, '_paypal_transaction_id', true );
				$txn_fee       = affwp_get_referral_meta( $rid, '_paypal_payout_fee', true );
				$receiver      = affwp_get_referral_meta( $rid, '_paypal_receiver_email', true );
				$error_json    = affwp_get_referral_meta( $rid, '_paypal_error', true );
				$error         = $error_json ? json_decode( $error_json, true ) : null;

				if ( ! $txn_status ) {
					continue;
				}
			?>
				<tr>
					<td>#<?php echo esc_html( $rid ); ?></td>
					<td><?php echo esc_html( $receiver ?: '—' ); ?></td>
					<td><?php $this->render_paypal_status_badge( $txn_status ); ?></td>
					<td><code><?php echo esc_html( $txn_id ?: '—' ); ?></code></td>
					<td><?php echo $txn_fee ? esc_html( affwp_currency_filter( affwp_format_amount( $txn_fee ) ) ) : '—'; ?></td>
					<td>
						<?php
						if ( 'UNCLAIMED' === $txn_status ) {
							echo '<span style="color: #b45309;">';
							esc_html_e( 'Recipient does not have a PayPal account. Funds will be returned after 30 days if unclaimed.', 'affiliate-wp' );
							echo '</span>';
						} elseif ( $error && ! empty( $error['message'] ) ) {
							echo '<span style="color: #dc2626;">';
							echo esc_html( $error['message'] );
							echo '</span>';
						} else {
							echo '—';
						}
						?>
					</td>
				</tr>
			<?php endforeach; ?>
			</tbody>
		</table>

		<table class="form-table" style="display:none;">
		<?php
		endif;
	}

	/**
	 * Render a PayPal status badge using AffiliateWP's badge system.
	 *
	 * @access private
	 * @since 2.33.0
	 * @param string $status The PayPal status string.
	 * @return void
	 */
	private function render_paypal_status_badge( $status ) {

		// Map PayPal statuses to AffiliateWP badge variants.
		$variants = [
			'SUCCESS'    => 'success',
			'PENDING'    => 'warning',
			'PROCESSING' => 'info',
			'DENIED'     => 'danger',
			'CANCELED'   => 'neutral',
			'FAILED'     => 'danger',
			'UNCLAIMED'  => 'warning',
			'RETURNED'   => 'neutral',
			'BLOCKED'    => 'danger',
			'ONHOLD'     => 'warning',
			'REFUNDED'   => 'neutral',
			'REVERSED'   => 'danger',
		];

		$variant = $variants[ $status ] ?? 'neutral';

		affwp_badge( [
			'text'    => $status,
			'variant' => $variant,
			'size'    => 'sm',
		] );
	}

	/**
	 * Handle manual "Check Status Now" action.
	 *
	 * @access public
	 * @since 2.33.0
	 * @param array $data Request data.
	 * @return void
	 */
	public function handle_manual_status_check( $data ) {

		$payout_id = absint( $data['payout_id'] ?? 0 );

		if ( empty( $payout_id ) ) {
			return;
		}

		if ( ! current_user_can( 'manage_payouts' ) ) {
			wp_die( __( 'You do not have permission to perform this action.', 'affiliate-wp' ) );
		}

		check_admin_referer( 'affwp_check_paypal_status_' . $payout_id );

		$this->check_payout_status( $payout_id );

		wp_safe_redirect( admin_url( 'admin.php?page=affiliate-wp-payouts&action=view_payout&payout_id=' . $payout_id . '&affwp_notice=paypal_status_checked' ) );
		exit;
	}

	/**
	 * Check the status of a PayPal payout batch via the API.
	 *
	 * Called by Action Scheduler to verify whether a payout batch has been
	 * processed by PayPal. Updates the payout and referral statuses based
	 * on the actual PayPal batch/item results.
	 *
	 * @access public
	 * @since 2.33.0
	 * @param int $payout_id The AffiliateWP payout ID to check.
	 * @return void
	 */
	public function check_payout_status( $payout_id ) {

		$payout = affwp_get_payout( $payout_id );

		if ( ! $payout || 'processing' !== $payout->status ) {
			return;
		}

		$paypal_data = json_decode( $payout->description, true );

		if ( empty( $paypal_data['paypal_batch_id'] ) ) {
			return;
		}

		// Ensure the API is initialized.
		if ( ! $this->api instanceof \AffiliateWP_PayPal_API ) {
			return;
		}

		$batch_id = $paypal_data['paypal_batch_id'];
		$response = $this->api->get_batch_status( $batch_id );

		if ( is_wp_error( $response ) ) {
			affiliate_wp()->utils->log( 'check_payout_status() failed for payout #' . $payout_id . ': ' . $response->get_error_message() );
			$this->reschedule_check( $payout_id, $paypal_data );
			return;
		}

		$batch_status = $response['batch_header']['batch_status'] ?? '';

		/*
		 * Bulk PayPal payouts share ONE batch across every affiliate paid in the
		 * run, so the batch response carries an item per affiliate. Scope the
		 * items to THIS payout's referrals before evaluating status or writing
		 * meta — otherwise a sibling affiliate's item (another row's SUCCESS,
		 * UNCLAIMED, etc.) leaks into this payout's status decision and overwrites
		 * its per-referral transaction details.
		 */
		$mode         = $paypal_data['mode'] ?? 'single';
		$referral_ids = array_map( 'absint', explode( ',', $payout->referrals ) );

		$items = array_values(
			array_filter(
				$response['items'] ?? [],
				function ( $item ) use ( $mode, $referral_ids ) {
					$sender_item_id = $item['payout_item']['sender_item_id'] ?? '';
					return ! empty( $this->get_referral_ids_for_item( $mode, $sender_item_id, $referral_ids ) );
				}
			)
		);

		// Update stored PayPal data.
		$paypal_data['batch_status'] = $batch_status;
		$paypal_data['poll_count']   = ( $paypal_data['poll_count'] ?? 0 ) + 1;
		$paypal_data['last_checked'] = gmdate( 'Y-m-d H:i:s' );

		/*
		 * No items for this payout yet (PayPal hasn't surfaced them, or none
		 * matched after scoping). Never fall through to the SUCCESS branch: with
		 * zero items `$all_succeeded` stays true and would wrongly mark the payout
		 * paid. Keep polling instead. A DENIED batch is still allowed through so a
		 * wholesale rejection fails the payout.
		 */
		if ( empty( $items ) && 'DENIED' !== $batch_status ) {
			$this->reschedule_check( $payout_id, $paypal_data );
			affiliate_wp()->affiliates->payouts->update( $payout_id, [ 'description' => wp_json_encode( $paypal_data ) ], '', 'payout' );
			return;
		}

		// Store per-item data in referral meta.
		foreach ( $items as $item ) {
			$sender_item_id     = $item['payout_item']['sender_item_id'] ?? '';
			$transaction_status = $item['transaction_status'] ?? '';
			$transaction_id     = $item['transaction_id'] ?? '';
			$payout_item_id     = $item['payout_item_id'] ?? '';
			$payout_item_fee    = $item['payout_item_fee']['value'] ?? '';
			$receiver_email     = $item['payout_item']['receiver'] ?? '';
			$errors             = $item['errors'] ?? null;

			// Map sender_item_id back to referral IDs.
			$item_referral_ids = $this->get_referral_ids_for_item( $mode, $sender_item_id, $referral_ids );

			foreach ( $item_referral_ids as $referral_id ) {
				affwp_update_referral_meta( $referral_id, '_paypal_transaction_status', $transaction_status );
				affwp_update_referral_meta( $referral_id, '_paypal_transaction_id', $transaction_id );
				affwp_update_referral_meta( $referral_id, '_paypal_payout_item_id', $payout_item_id );
				affwp_update_referral_meta( $referral_id, '_paypal_payout_fee', $payout_item_fee );
				affwp_update_referral_meta( $referral_id, '_paypal_receiver_email', $receiver_email );

				if ( $errors ) {
					affwp_update_referral_meta( $referral_id, '_paypal_error', wp_json_encode( $errors ) );
				} else {
					affwp_delete_referral_meta( $referral_id, '_paypal_error' );
				}
			}
		}

		// Determine final status based on batch and item statuses.
		$final_statuses = [ 'FAILED', 'RETURNED', 'BLOCKED', 'REFUNDED', 'REVERSED' ];

		$reached_terminal = false;

		/*
		 * Reconciling a PayPal payout: force the Store Credit gate FALSE across every referral
		 * status flip below. On paid flips this stops a Store Credit affiliate being credited on
		 * top of the PayPal payment; on failure/timeout reversals it stops a decrement for a credit
		 * that never existed. Covers the give-up flip in reschedule_check(), which runs in this window.
		 */
		$force_store_credit = static function () {
			return false;
		};
		add_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

		if ( 'DENIED' === $batch_status ) {

			/*
			 * Entire batch denied — mark all as failed and classify so the
			 * drawer dispatch + auto-retry sweep + auto-email pipeline
			 * can act on it without re-parsing the description.
			 *
			 * Pull the actual per-item error (e.g. SELF_PAY_NOT_ALLOWED) rather than
			 * classifying on the bare 'DENIED' batch status — the item error is the real,
			 * actionable reason. Persisting its message on the payout lets the drawer
			 * surface "PayPal: <reason>" instead of generic class boilerplate.
			 */
			$denied_error_name    = '';
			$denied_error_message = '';
			foreach ( $items as $item ) {
				if ( ! empty( $item['errors']['name'] ) ) {
					$denied_error_name    = (string) $item['errors']['name'];
					$denied_error_message = (string) ( $item['errors']['message'] ?? '' );
					break;
				}
			}

			$failure_class = \AffWP\Payouts\Failure_Classifier::classify(
				'paypal',
				$denied_error_name ? $denied_error_name : 'DENIED',
				$denied_error_message ? $denied_error_message : __( 'Entire batch denied by PayPal', 'affiliate-wp' ),
				$payout
			);

			// Persist the specific reason so the drawer (and Details "Error" row) show it.
			$paypal_data['error'] = $denied_error_message ? $denied_error_message : __( 'Entire batch denied by PayPal', 'affiliate-wp' );

			affiliate_wp()->affiliates->payouts->update(
				$payout_id,
				[
					'status'        => 'failed',
					'failure_class' => $failure_class,
				],
				'',
				'payout'
			);

			foreach ( $referral_ids as $rid ) {
				affwp_set_referral_status( $rid, 'unpaid' );
			}

			/*
			 * Auto-email dispatch. Gating (setting toggle, cooldown,
			 * affiliate_action_required class only) lives in
			 * affwp_maybe_send_failure_email().
			 */
			affwp_maybe_send_failure_email( (int) $payout_id );

			$reached_terminal = true;

		} elseif ( in_array( $batch_status, [ 'SUCCESS', 'CANCELED' ], true ) ) {

			// Batch is complete — check individual item statuses.
			$all_succeeded = true;
			$any_failed    = false;
			$any_pending   = false;

			foreach ( $items as $item ) {
				$status = $item['transaction_status'] ?? '';

				if ( 'SUCCESS' !== $status ) {
					$all_succeeded = false;
				}
				if ( in_array( $status, $final_statuses, true ) ) {
					$any_failed = true;
				}
				if ( in_array( $status, [ 'PENDING', 'UNCLAIMED', 'ONHOLD' ], true ) ) {
					$any_pending = true;
				}
			}

			if ( $all_succeeded ) {
				// All items succeeded — mark payout as paid.
				affiliate_wp()->affiliates->payouts->update( $payout_id, [ 'status' => 'paid' ], '', 'payout' );

				foreach ( $referral_ids as $rid ) {
					affwp_set_referral_status( $rid, 'paid' );
				}

				$reached_terminal = true;

			} elseif ( $any_pending ) {
				// Some items still pending/unclaimed — keep polling.
				$this->reschedule_check( $payout_id, $paypal_data );

				/*
				 * Trigger the auto-email pipeline for processing payouts
				 * that have UNCLAIMED items. PayPal holds those funds for
				 * 30 days waiting for the recipient to verify their
				 * PayPal email — if they don't, the funds get returned.
				 * Operationally the same as a failed payout requiring
				 * affiliate action, so the same outreach applies.
				 * `affwp_maybe_send_failure_email()` does its own gating
				 * (setting toggle, cooldown, has-unclaimed-items check),
				 * so it's safe to call on every poll cycle; the cooldown
				 * ensures the affiliate only gets one email per window.
				 */
				$has_unclaimed = false;
				foreach ( $items as $item ) {
					if ( 'UNCLAIMED' === ( $item['transaction_status'] ?? '' ) ) {
						$has_unclaimed = true;
						break;
					}
				}
				if ( $has_unclaimed ) {
					affwp_maybe_send_failure_email( (int) $payout_id );
				}

			} elseif ( $any_failed ) {
				/*
				 * Some or all items failed. Mark payout as failed and
				 * classify based on the WORST item status — the dispatch
				 * decision should reflect the harshest failure mode in
				 * the batch (DENIED outranks UNCLAIMED outranks FAILED).
				 * Individual referrals: succeeded ones stay paid; failed
				 * ones revert to unpaid.
				 */
				$worst_item_status = '';
				foreach ( $items as $item ) {
					$status = $item['transaction_status'] ?? '';
					if ( in_array( $status, $final_statuses, true ) && ! $worst_item_status ) {
						$worst_item_status = $status;
					}
					/*
					 * DENIED is the strongest signal — affiliate must act.
					 * Override even an earlier FAILED/RETURNED/etc. match.
					 */
					if ( 'DENIED' === $status ) {
						$worst_item_status = 'DENIED';
						break;
					}
				}

				$failure_class = \AffWP\Payouts\Failure_Classifier::classify(
					'paypal',
					$worst_item_status,
					sprintf(
						/* translators: %s: PayPal item status (DENIED, FAILED, etc.) */
						__( 'One or more items terminal at status: %s', 'affiliate-wp' ),
						$worst_item_status
					),
					$payout
				);

				affiliate_wp()->affiliates->payouts->update(
					$payout_id,
					[
						'status'        => 'failed',
						'failure_class' => $failure_class,
					],
					'',
					'payout'
				);

				foreach ( $referral_ids as $rid ) {
					$item_status = affwp_get_referral_meta( $rid, '_paypal_transaction_status', true );

					if ( 'SUCCESS' === $item_status ) {
						affwp_set_referral_status( $rid, 'paid' );
					} else {
						affwp_set_referral_status( $rid, 'unpaid' );
					}
				}

				/*
				 * Auto-email dispatch (same as the DENIED-batch path above).
				 */
				affwp_maybe_send_failure_email( (int) $payout_id );

				$reached_terminal = true;
			}

		} else {
			// Still PENDING or PROCESSING — reschedule.
			$this->reschedule_check( $payout_id, $paypal_data );
		}

		remove_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

		// Save updated PayPal data back to description.
		affiliate_wp()->affiliates->payouts->update( $payout_id, [
			'description' => wp_json_encode( $paypal_data ),
		], '', 'payout' );

		// On terminal transitions, roll the batch's counters forward so its status resolves from 'processing' to completed/partial/failed.
		if ( $reached_terminal ) {
			$this->maybe_recount_batch( $payout_id );
		}
	}

	/**
	 * Recounts the payout batch a given payout belongs to (if any).
	 *
	 * Called after terminal status transitions so a mixed-method batch can progress
	 * from 'processing' to its resolved state as each async payout lands.
	 *
	 * @since 2.33.0
	 *
	 * @param int $payout_id Payout ID.
	 * @return void
	 */
	private function maybe_recount_batch( $payout_id ) {

		$payout = affwp_get_payout( $payout_id );

		if ( ! $payout || empty( $payout->batch_id ) ) {
			return;
		}

		if ( ! isset( affiliate_wp()->affiliates->payout_batches ) ) {
			return;
		}

		affiliate_wp()->affiliates->payout_batches->recount( absint( $payout->batch_id ) );
	}

	/**
	 * Reschedule a payout status check with escalating backoff.
	 *
	 * Schedule: 2min, 5min, 15min, 1hr, 6hr, then every 6hr.
	 * Stops after 30 days (120 checks at 6hr intervals).
	 *
	 * @access private
	 * @since 2.33.0
	 * @param int   $payout_id   The AffiliateWP payout ID.
	 * @param array $paypal_data The decoded PayPal data from the payout description.
	 * @return void
	 */
	private function reschedule_check( $payout_id, $paypal_data ) {

		$poll_count = $paypal_data['poll_count'] ?? 0;

		/*
		 * Poll for ~37 days — deliberately PAST PayPal's 30-day unclaimed-return
		 * window. PayPal returns an unclaimed payout's funds to the sender at
		 * ~30 days and flips the item's transaction_status to RETURNED. We must
		 * still be polling when that happens so RETURNED is observed and run
		 * through the normal terminal-status path, which classifies it as
		 * affiliate_action_required. That classification is what surfaces the
		 * Next Steps card AND lets the guarded retry sweep re-pay — and it's
		 * double-pay-safe, because by the time PayPal reports RETURNED the funds
		 * are already back in the sender's account.
		 *
		 * The previous 120-check window stopped at ~28.9 days — ~1 day BEFORE
		 * the return — so the payout was marked failed with NO failure_class:
		 * never classified, never carded, never retried. This higher threshold
		 * is the genuine last-resort valve for the rare case PayPal never returns
		 * the funds; that path intentionally leaves failure_class unset so
		 * nothing is auto-retried while the money may still be in limbo.
		 */
		if ( $poll_count > 150 ) {
			affiliate_wp()->utils->log( 'check_payout_status() giving up on payout #' . $payout_id . ' after ' . $poll_count . ' checks.' );

			affiliate_wp()->affiliates->payouts->update( $payout_id, [ 'status' => 'failed' ], '', 'payout' );

			$payout      = affwp_get_payout( $payout_id );
			$referral_ids = array_map( 'absint', explode( ',', $payout->referrals ) );

			foreach ( $referral_ids as $rid ) {
				affwp_set_referral_status( $rid, 'unpaid' );
			}

			return;
		}

		// Escalating delays: 2m, 5m, 15m, 1h, 6h, then every 6h.
		$delays = [ 120, 300, 900, 3600, 21600 ];
		$delay  = $delays[ min( $poll_count, count( $delays ) - 1 ) ];

		if ( function_exists( 'as_schedule_single_action' ) ) {
			as_schedule_single_action(
				time() + $delay,
				'affwp_paypal_check_payout_status',
				[ 'payout_id' => $payout_id ],
				'affiliatewp'
			);
		}
	}

	/**
	 * Map a PayPal sender_item_id back to referral IDs.
	 *
	 * For single payouts, sender_item_id is the referral ID.
	 * For bulk payouts, sender_item_id is the affiliate ID.
	 *
	 * @access private
	 * @since 2.33.0
	 * @param string $mode            The payout mode ('single' or 'bulk').
	 * @param string $sender_item_id  The PayPal sender_item_id value.
	 * @param array  $referral_ids    The referral IDs associated with this payout.
	 * @return array Array of matching referral IDs.
	 */
	private function get_referral_ids_for_item( $mode, $sender_item_id, $referral_ids ) {

		/*
		 * Return ONLY the referrals an item genuinely maps to, never a fallback
		 * to the full set. Bulk payouts share one batch, so this is called with
		 * sibling affiliates' items too; a fallback would misattribute another
		 * affiliate's transaction status/error onto this payout's referral.
		 */
		if ( 'single' === $mode ) {
			// sender_item_id is the referral ID.
			$rid = absint( $sender_item_id );
			return in_array( $rid, $referral_ids, true ) ? [ $rid ] : [];
		}

		// Bulk mode: sender_item_id is the affiliate ID; return the referral IDs for that affiliate within this payout.
		$affiliate_id = absint( $sender_item_id );
		$matching     = [];

		foreach ( $referral_ids as $rid ) {
			$referral = affwp_get_referral( $rid );
			if ( $referral && absint( $referral->affiliate_id ) === $affiliate_id ) {
				$matching[] = $rid;
			}
		}

		return $matching;
	}

	/**
	 * AJAX handler for testing PayPal API connection.
	 *
	 * Tests credentials from the form (not saved settings) and verifies
	 * Payouts permission is enabled. This allows users to test credentials
	 * before saving them.
	 *
	 * @since 2.33.0
	 * @return void
	 */
	public function ajax_test_connection() {
		// Verify nonce.
		if ( ! check_ajax_referer( 'affwp_test_paypal_connection', 'nonce', false ) ) {
			wp_send_json_error( [
				'status'  => 'error',
				'message' => __( 'Security check failed. Please refresh the page and try again.', 'affiliate-wp' ),
			] );
		}

		// Check capabilities.
		if ( ! current_user_can( 'manage_affiliate_options' ) ) {
			wp_send_json_error( [
				'status'  => 'error',
				'message' => __( 'You do not have permission to perform this action.', 'affiliate-wp' ),
			] );
		}

		// Get credentials from POST (form values) instead of saved settings.
		$client_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
		$secret    = isset( $_POST['secret'] ) ? sanitize_text_field( wp_unslash( $_POST['secret'] ) ) : '';
		$test_mode = isset( $_POST['test_mode'] ) && '1' === $_POST['test_mode'];

		// Validate credentials are provided.
		if ( empty( $client_id ) || empty( $secret ) ) {
			wp_send_json_error( [
				'status'  => 'error',
				'message' => __( 'Please enter both Client ID and Secret before testing.', 'affiliate-wp' ),
			] );
		}

		// Create API instance with provided credentials.
		$api = new \AffiliateWP_PayPal_API();
		$api->set_credentials( $client_id, $secret );
		$api->set_sandbox_mode( $test_mode );

		$result = $api->test_connection();

		$allowed_html = [
			'a' => [
				'href'   => [],
				'target' => [],
				'rel'    => [],
				'class'  => [],
			],
		];

		$response = [
			'status'  => $result['status'],
			'message' => wp_kses( $result['message'], $allowed_html ),
		];

		if ( 'error' === $result['status'] ) {
			wp_send_json_error( $response );
		}

		wp_send_json_success( $response );
	}

	/**
	 * Admin notices for success and error messages
	 *
	 * @access public
	 * @since 2.29.0
	 * @return void
	 */
	public function admin_notices() {

		if ( empty( $_REQUEST['affwp_notice'] ) ) {
			return;
		}

		$referral_id = ! empty( $_REQUEST['referral'] ) ? absint( $_REQUEST['referral'] ) : 0;
		$transfer_id = ! empty( $_REQUEST['transfer'] ) ? sanitize_text_field( $_REQUEST['transfer'] ) : '';
		$message     = ! empty( $_REQUEST['message'] ) ? urldecode( $_REQUEST['message'] ) : '';
		$code        = ! empty( $_REQUEST['code'] ) ? urldecode( $_REQUEST['code'] ) . ' ' : '';

		switch ( $_REQUEST['affwp_notice'] ) {

			case 'paypal_success':
				echo '<div class="updated"><p>' . sprintf( __( 'Referral #%d paid out via PayPal successfully', 'affiliate-wp' ), $referral_id ) . '</p></div>';
				break;

			case 'paypal_processing':
				echo '<div class="updated"><p>' . sprintf( __( 'Referral #%d has been submitted to PayPal and is being verified.', 'affiliate-wp' ), $referral_id ) . '</p></div>';
				break;

			case 'paypal_bulk_pay_success':
				echo '<div class="updated"><p>' . __( 'Referrals paid out via PayPal successfully', 'affiliate-wp' ) . '</p></div>';
				break;

			case 'paypal_bulk_pay_processing':
				echo '<div class="updated"><p>' . __( 'Payouts have been submitted to PayPal and are being verified.', 'affiliate-wp' ) . '</p></div>';
				break;

			case 'paypal_error':
				echo '<div class="error"><p><strong>' . esc_html__( 'Error:', 'affiliate-wp' ) . '</strong>&nbsp;' . esc_html( $code ) . esc_html( $message ) . '</p></div>';
				break;

			case 'paypal_bulk_pay_empty_error':
				echo '<div class="error"><p>' . __( 'Error: No referrals are available to be paid out via PayPal', 'affiliate-wp' ) . '</p></div>';
				break;

			case 'paypal_status_checked':
				echo '<div class="updated"><p>' . __( 'PayPal payout status has been refreshed.', 'affiliate-wp' ) . '</p></div>';
				break;

		}
	}

}
