<?php
/**
 * Admin: Ajax Action Handlers
 *
 * @package     AffiliateWP
 * @subpackage  Admin
 * @copyright   Copyright (c) 2021, Sandhills Development, LLC
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       1.0.0
 */

/**
 * Handles ajax for retrieving a list of users via live search.
 *
 * @since 1.0.0
 */
function affwp_search_users() {
	if ( empty( $_REQUEST['term'] ) ) {
		wp_die( -1 );
	}

	if ( ! current_user_can( 'manage_affiliates' ) ) {
		wp_die( -1 );
	}

	$search_query = htmlentities2( trim( sanitize_text_field( $_REQUEST['term'] ) ) );

	/**
	 * Fires immediately prior to an AffiliateWP user search query.
	 *
	 * @since 2.0
	 *
	 * @param string $search_query The user search query.
	 */
	do_action( 'affwp_pre_search_users', $search_query );

	$args = array(
		'search_columns' => array( 'user_login', 'display_name', 'user_email' )
	);

	$found_users = false;

	if ( mb_strlen( $search_query ) > 2 ) {

		// Add search string to args.
		$args['search'] = '*' . mb_strtolower( $search_query ) . '*';

		// Get users matching search.
		$found_users = get_users( $args );
	}

	$status = isset( $_REQUEST['status'] ) ? mb_strtolower( htmlentities2( trim( $_REQUEST['status'] ) ) ) : 'bypass';

	$user_list = array();

	if ( $found_users ) {

		foreach ( $found_users as $user ) {

			$label     = empty( $user->user_email ) ? $user->user_login : "{$user->user_login} ({$user->user_email})";
			$affiliate = affwp_get_affiliate_by( 'user_id', $user->ID );

			if ( 'bypass' !== $status ) {

				switch ( $status ) {

					case 'none':

						if ( is_wp_error( $affiliate ) ) {

							$user_list[] = array(
								'label'   => $label,
								'value'   => $user->user_login,
								'user_id' => $user->ID
							);

						}

						break;
					case 'any':

						if ( ! is_wp_error( $affiliate ) ) {

							$user_list[] = array(
								'label'   => $label,
								'value'   => $user->user_login,
								'user_id' => $user->ID
							);

						}

						break;
					default:

						if ( ! is_wp_error( $affiliate ) && $status == $affiliate->status ) {

							$user_list[] = array(
								'label'   => $label,
								'value'   => $user->user_login,
								'user_id' => $user->ID
							);

						}

				}

			} else {

				$user_list[] = array(
					'label'   => $label,
					'value'   => $user->user_login,
					'user_id' => $user->ID
				);

			}
		}
	}

	wp_die( json_encode( $user_list ) );
}
add_action( 'wp_ajax_affwp_search_users', 'affwp_search_users' );

/**
 * Handles ajax for processing a single batch request.
 *
 * @since 2.0
 */
function affwp_process_batch_request() {
	// Batch ID.
	if ( ! isset( $_REQUEST['batch_id'] ) ) {
		wp_send_json_error( array(
			'error' => __( 'A batch process ID must be present to continue.', 'affiliate-wp' )
		) );
	} else {
		$batch_id = sanitize_key( $_REQUEST['batch_id'] );
	}

	// Nonce.
	if ( ! isset( $_REQUEST['nonce'] )
	     || ( isset( $_REQUEST['nonce'] ) && false === wp_verify_nonce( $_REQUEST['nonce'], "{$batch_id}_step_nonce") )
	) {
		wp_send_json_error( array(
			'error' => __( 'You do not have permission to initiate this request. Contact an administrator for more information.', 'affiliate-wp' )
		) );
	}

	// Attempt to retrieve the batch attributes from memory.
	if ( $batch_id && false === $batch = affiliate_wp()->utils->batch->get( $batch_id ) ) {
		wp_send_json_error( array(
			/* translators: Batch process ID */
			'error' => sprintf( __( '%s is an invalid batch process ID.', 'affiliate-wp' ), esc_html( $_REQUEST['batch_id'] ) )
		) );
	}

	$class      = isset( $batch['class'] ) ? sanitize_text_field( $batch['class'] ) : '';
	$class_file = isset( $batch['file'] ) ? $batch['file'] : '';

	if ( empty( $class_file ) ) {
		wp_send_json_error( array(
			/* translators: Formatted batch process ID */
			'error' => sprintf( __( 'An invalid file path is registered for the %1$s batch process handler.', 'affiliate-wp' ), "<code>{$batch_id}</code>" )
		) );
	} else {
		require_once $class_file;
	}

	if ( empty( $class ) || ! class_exists( $class ) ) {
		wp_send_json_error( array(
			/* translators: 1: Formatted batch processor class name, 2: Formatted batch process ID */
			'error' => sprintf( __( '%1$s is an invalid handler for the %2$s batch process. Please try again.', 'affiliate-wp' ),
				"<code>{$class}</code>",
				"<code>{$batch_id}</code>"
			)
		) );
	}

	$step = sanitize_text_field( $_REQUEST['step'] );

	/**
	 * Instantiate the batch class.
	 *
	 * @var \AffWP\Utils\Batch_Process\Export|\AffWP\Utils\Batch_Process\Base $process
	 */
	if ( isset( $_REQUEST['data']['upload']['file'] ) ) {

		// If this is an import, instantiate with the file and step.
		$file    = sanitize_text_field( $_REQUEST['data']['upload']['file'] );
		$process = new $class( $file, $step );

	} else {

		// Otherwise just the step.
		$process = new $class( $step );

	}

	// Garbage collect any old temporary data.
	if ( $step < 2 ) {
		$process->finish( $batch_id );
	}

	$using_prefetch = ( $process instanceof \AffWP\Utils\Batch_Process\With_PreFetch );

	// Handle pre-fetching data.
	if ( $using_prefetch ) {

		// Initialize any data needed to process a step.
		$data = isset( $_REQUEST['form'] ) ? $_REQUEST['form'] : array();

		$process->init( $data );
		$process->pre_fetch();
	}

	$step          = $process->process_step();
	$response_data = array( 'step' => $step );

	// Map fields if this is an import.
	if ( isset( $process->field_mapping ) && ( $process instanceof \AffWP\Utils\Importer\CSV ) ) {
		$response_data['columns'] = $process->get_columns();
		$response_data['mapping'] = $process->field_mapping;
	}

	if ( is_wp_error( $step ) ) {
		// Log the error with response data.
		affiliate_wp()->utils->log( "Batch Process {$batch_id} failed to complete.", $response_data );
		wp_send_json_error( $step );
	} else {
		// Finish and set the status flag if done.
		if ( 'done' === $step ) {
			$response_data['done']    = true;
			$response_data['message'] = $process->get_message( 'done' );

			if ( method_exists( $process, 'get_redirect_url' ) ) {
				$response_data['url'] = $process->get_redirect_url();
			}

			// If this is an export class and not an empty export, send the download URL.
			if ( method_exists( $process, 'can_export' ) ) {

				if ( ! $process->is_empty ) {
					$response_data['url'] = affwp_admin_url( 'tools', array(
						'step'         => $step,
						'nonce'        => wp_create_nonce( 'affwp-batch-export' ),
						'batch_id'     => $batch_id,
						'affwp_action' => 'download_batch_export',
					) );
				}
			}

			// Log the success message
			affiliate_wp()->utils->log( "Batch Process {$batch_id} completed.", $response_data );

			// Once all calculations have finished, run cleanup.
			$process->finish( $batch_id );
		} else {
			$response_data['done']       = false;
			$response_data['percentage'] = $process->get_percentage_complete();
		}

		wp_send_json_success( $response_data );
	}

}
add_action( 'wp_ajax_process_batch_request', 'affwp_process_batch_request' );

/**
 * Handles ajax for processing the upload step in single batch import request.
 *
 * @since 2.1
 */
function affwp_process_batch_import() {
	if ( ! function_exists( 'wp_handle_upload' ) ) {
		require_once( ABSPATH . 'wp-admin/includes/file.php' );
	}

	require_once AFFILIATEWP_PLUGIN_DIR . 'includes/admin/tools/import/class-batch-import-csv.php';

	if( ! wp_verify_nonce( $_REQUEST['affwp_import_nonce'], 'affwp_import_nonce' ) ) {
		wp_send_json_error( array( 'error' => __( 'Nonce verification failed', 'affiliate-wp' ) ) );
	}

	if( empty( $_FILES['affwp-import-file'] ) ) {
		wp_send_json_error( array( 'error' => __( 'Missing import file. Please provide an import file.', 'affiliate-wp' ), 'request' => $_REQUEST ) );
	}

	$accepted_mime_types = array(
		'text/csv',
		'text/comma-separated-values',
		'text/plain',
		'text/anytext',
		'text/*',
		'text/plain',
		'text/anytext',
		'text/*',
		'application/csv',
		'application/excel',
		'application/vnd.ms-excel',
		'application/vnd.msexcel',
	);

	if( empty( $_FILES['affwp-import-file']['type'] ) || ! in_array( strtolower( $_FILES['affwp-import-file']['type'] ), $accepted_mime_types ) ) {
		wp_send_json_error( array( 'error' => __( 'The file you uploaded does not appear to be a CSV file.', 'affiliate-wp' ), 'request' => $_REQUEST ) );
	}

	if( ! file_exists( $_FILES['affwp-import-file']['tmp_name'] ) ) {
		wp_send_json_error( array( 'error' => __( 'Something went wrong during the upload process, please try again.', 'affiliate-wp' ), 'request' => $_REQUEST ) );
	}

	// Let WordPress import the file. We will remove it after import is complete
	$import_file = wp_handle_upload( $_FILES['affwp-import-file'], array( 'test_form' => false ) );

	if ( $import_file && empty( $import_file['error'] ) ) {

		// Batch ID.
		if ( ! isset( $_REQUEST['batch_id'] ) ) {
			wp_send_json_error( array(
				'error' => __( 'A batch process ID must be present to continue.', 'affiliate-wp' )
			) );
		} else {
			$batch_id = sanitize_key( $_REQUEST['batch_id'] );
		}

		// Attempt to retrieve the batch attributes from memory.
		if ( $batch_id && false === $batch = affiliate_wp()->utils->batch->get( $batch_id ) ) {
			wp_send_json_error( array(
				/* translators: Batch process ID */
				'error' => sprintf( __( '%s is an invalid batch process ID.', 'affiliate-wp' ), esc_html( $_REQUEST['batch_id'] ) )
			) );
		}

		$class      = isset( $batch['class'] ) ? sanitize_text_field( $batch['class'] ) : '';
		$class_file = isset( $batch['file'] ) ? $batch['file'] : '';

		if ( empty( $class_file ) ) {
			wp_send_json_error( array(
				/* translators: Formatted batch process ID */
				'error' => sprintf( __( 'An invalid file path is registered for the %1$s batch process handler.', 'affiliate-wp' ), "<code>{$batch_id}</code>" )
			) );
		} else {
			require_once $class_file;
		}

		if ( ! class_exists( $class ) ) {
			wp_send_json_error( array(
				/* translators: 1: Formatted batch processor class name, 2: Formatted batch process ID */
				'error' => sprintf( __( '%1$s is an invalid handler for the %2$s batch process. Please try again.', 'affiliate-wp' ),
					"<code>{$class}</code>",
					"<code>{$batch_id}</code>"
				)
			) );
		}


		$import = new $class( $import_file['file'] );


		if( ! $import->can_import() ) {
			wp_send_json_error( array( 'error' => __( 'You do not have permission to import data', 'affiliate-wp' ) ) );
		}

		wp_send_json_success( array(
			'batch_id'  => $batch_id,
			'upload'    => $import_file,
			'first_row' => $import->get_first_row(),
			'columns'   => $import->get_columns(),
			'nonce'     => wp_create_nonce( "{$batch_id}_step_nonce" )
		) );

	} else {

		/**
		 * Error generated by _wp_handle_upload()
		 * @see _wp_handle_upload() in wp-admin/includes/file.php
		 */

		wp_send_json_error( array( 'error' => $import_file['error'] ) );
	}

	exit;
}
add_action( 'wp_ajax_process_batch_import', 'affwp_process_batch_import' );


/**
 * Handles ajax for determining if a user log in name is valid
 *
 * @since 2.1.4
 * @since 2.3   Added support for user_email parameter
 */
function affwp_check_user_login() {

	if ( ! current_user_can( 'manage_affiliates' ) ) {
		wp_die( -1 );
	}

	if ( ! empty( $_REQUEST['user'] ) ) {
		$user = sanitize_text_field( $_REQUEST['user'] );
	} elseif ( ! empty( $_REQUEST['user_email'] ) ) {
		$user = get_user_by( 'email', sanitize_text_field( $_REQUEST['user_email'] ) );
		if ( false !== $user ) {
			$user = $user->user_login;
		}
	} else {
		wp_die( -1 );
	}

	/**
	 * Fires immediately prior to an AffiliateWP user check.
	 *
	 * @since 2.1.4
	 *
	 * @param string $user The user login.
	 */
	do_action( 'affwp_pre_check_user', $user );

	$affiliate = affwp_get_affiliate( $user );

	if ( $affiliate ) {
		$response = array(
				'affiliate' => true,
				'user'      => $user,
				'url'       => esc_url( affwp_admin_url( 'affiliates', array(
						'affiliate_id' => $affiliate->ID,
						'action'       => 'edit_affiliate',
				) ) ),
		);
	} else {
		$response = array( 'affiliate' => false, 'user' => $user );

	}

	wp_send_json_success( $response );

}
add_action( 'wp_ajax_affwp_check_user_login', 'affwp_check_user_login' );

/**
 * Activate plugin.
 *
 * @since 2.9.5
 */
function affwp_activate_plugin() {

	// Run a security check.
	check_ajax_referer( 'affiliate-wp-admin', 'nonce' );

	if ( ! isset( $_POST['plugin'] ) || ! is_string( $_POST['plugin'] ) ) {
		wp_send_json_error( esc_html__( 'Could not activate the plugin. Plugin slug is empty.', 'affiliate-wp' ) );
	}

	if ( ! current_user_can( 'activate_plugins' ) ) {

		wp_send_json_error( esc_html__( 'Plugin activation is disabled for you on this site.', 'affiliate-wp' ) );
		exit;
	}

	$activate = activate_plugins( sanitize_text_field( wp_unslash( $_POST['plugin'] ) ) );

	if ( ! is_wp_error( $activate ) ) {
		wp_send_json_success( esc_html__( 'Plugin activated.', 'affiliate-wp' ) );
	}

	wp_send_json_error( esc_html__( 'Could not activate the plugin. Please activate it on the Plugins page.', 'affiliate-wp' ) );
}
add_action( 'wp_ajax_affwp_activate_plugin', 'affwp_activate_plugin' );

/**
 * Install addon.
 *
 * @since 2.9.5
 */
function affwp_install_plugin() {

	// Run a security check.
	check_ajax_referer( 'affiliate-wp-admin', 'nonce' );

	$generic_error = esc_html__( 'There was an error while performing your request.', 'affiliate-wp' );

	// Check if new installations are allowed.
	if ( ! current_user_can( 'install_plugins' ) ) {
		wp_send_json_error( $generic_error );
		exit;
	}

	$error = esc_html__( 'Could not install the plugin. Please download and install it manually.', 'affiliate-wp' );

	if ( empty( $_POST['plugin'] ) ) {
		wp_send_json_error( $error );
	}

	// Set the current screen to avoid undefined notices.
	set_current_screen( 'affiliates_page_affiliate-wp-settings' );

	// Prepare variables.
	$url = esc_url_raw( admin_url( 'plugins.php' ) );

	ob_start();

	$creds = request_filesystem_credentials( $url, '', false, false, null );

	// Hide the filesystem credentials form.
	ob_end_clean();

	// Check for file system permissions.
	if ( false === $creds || ! WP_Filesystem( $creds ) ) {
		wp_send_json_error( $error );
	}

	/*
	 * We do not need any extra credentials if we have gotten this far, so let's install the plugin.
	 */
	require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';

	require_once AFFILIATEWP_PLUGIN_DIR . 'includes/admin/class-plugin-silent-upgrader-skin.php';

	// Do not allow WordPress to search/download translations, as this will break JS output.
	remove_action( 'upgrader_process_complete', array( 'Language_Pack_Upgrader', 'async_upgrade' ), 20 );

	// Create the plugin upgrader with our custom skin.
	$installer = new Plugin_Upgrader( new Affiliate_WP_Silent_Upgrader_Skin() );

	// Error check.
	if ( empty( $_POST['plugin'] ) || ! method_exists( $installer, 'install' ) ) {
		wp_send_json_error( $error );
	}

	$installer->install( sanitize_text_field( wp_unslash( $_POST['plugin'] ) ) );

	// Flush the cache and return the newly installed plugin basename.
	wp_cache_flush();

	$plugin_basename = $installer->plugin_info();

	if ( empty( $plugin_basename ) ) {
		wp_send_json_error( $error );
	}

	// Check for permissions.
	if ( ! current_user_can( 'activate_plugins' ) ) {

		wp_send_json_success(
			array(
				'msg'          => esc_html__( 'Plugin installed.', 'affiliate-wp' ),
				'is_activated' => false,
				'basename'     => $plugin_basename,
			)
		);
	}

	// Activate the plugin silently.
	$activated = activate_plugin( $plugin_basename );

	if ( ! is_wp_error( $activated ) ) {

		wp_send_json_success(
			array(
				'is_activated' => true,
				'msg'          => esc_html__( 'Plugin installed & activated.', 'affiliate-wp' ),
				'basename'     => $plugin_basename,
			)
		);
	}

	// Fallback error just in case.
	wp_send_json_error(
		array(
			'msg'          => $generic_error,
			'is_activated' => false,
			'basename'     => $plugin_basename,
		)
	);
}
add_action( 'wp_ajax_affwp_install_plugin', 'affwp_install_plugin' );


/**
 * AJAX search for affiliates, used by the shared affiliate picker component.
 *
 * Returns up to 10 active affiliates matching the search query,
 * excluding any IDs passed in the `exclude` parameter. When `include_group=1`
 * is passed, each result also includes the affiliate's current group as
 * metadata. When `include_groups=1` is passed, affiliate-groups are also
 * returned as first-class selectable results with a `type: 'group'` field
 * (affiliates carry `type: 'affiliate'`).
 *
 * @since 2.33.0
 */
function affwp_ajax_search_affiliates() {

	check_ajax_referer( 'affwp_affiliate_picker', '_wpnonce' );

	if ( ! current_user_can( 'manage_affiliates' ) ) {
		wp_send_json_error( __( 'You do not have permission.', 'affiliate-wp' ) );
	}

	$search  = isset( $_GET['search'] ) ? sanitize_text_field( wp_unslash( $_GET['search'] ) ) : '';
	$exclude = isset( $_GET['exclude'] ) ? array_map( 'absint', (array) $_GET['exclude'] ) : [];

	$args = [
		'number' => 10,
		'status' => 'active',
	];

	if ( ! empty( $search ) ) {
		$args['search'] = $search;
	}

	if ( ! empty( $exclude ) ) {
		$args['exclude'] = $exclude;
	}

	$affiliates     = affiliate_wp()->affiliates->get_affiliates( $args );
	$include_group  = isset( $_GET['include_group'] ) && '1' === $_GET['include_group'];
	$include_groups = isset( $_GET['include_groups'] ) && '1' === $_GET['include_groups'];
	$results        = [];

	// Prepend matching affiliate-groups so they appear above affiliates.
	if ( $include_groups ) {
		$groups = affiliate_wp()->groups->get_groups(
			[
				'number' => 50,
				'type'   => 'affiliate-group',
				'fields' => 'objects',
			]
		);

		if ( is_array( $groups ) ) {
			foreach ( $groups as $group ) {
				$title = $group->get_title();

				if ( '' !== $search && false === stripos( $title, $search ) ) {
					continue;
				}

				$group_id        = absint( $group->group_id );
				$affiliate_ids   = affiliate_wp()->connections->get_connected(
					'affiliate',
					'group',
					$group_id
				);
				$affiliate_count = is_array( $affiliate_ids ) ? count( $affiliate_ids ) : 0;

				$results[] = [
					'id'              => $group_id,
					'name'            => $title,
					'type'            => 'group',
					'affiliate_count' => $affiliate_count,
				];
			}
		}
	}

	foreach ( $affiliates as $aff ) {
		$aff_id    = absint( $aff->affiliate_id );
		$aff_name  = affwp_get_affiliate_name( $aff_id );
		$user_info = get_userdata( $aff->user_id );
		$username  = $user_info ? $user_info->user_login : '';

		if ( $aff_name ) {
			$display = sprintf( '%s (#%d)', $aff_name, $aff_id );
		} elseif ( $username ) {
			$display = sprintf( '%s (#%d)', $username, $aff_id );
		} else {
			$display = sprintf( 'Affiliate #%d', $aff_id );
		}

		$result = [
			'id'   => $aff_id,
			'name' => $display,
			'type' => 'affiliate',
		];

		// Optionally include the affiliate's current group as metadata.
		if ( $include_group ) {
			$group_ids = affiliate_wp()->connections->get_connected(
				'group',
				'affiliate',
				$aff_id
			);

			if ( ! empty( $group_ids ) && is_array( $group_ids ) ) {
				$group = affiliate_wp()->groups->get_group( absint( $group_ids[0] ) );
				if ( $group ) {
					$result['group_id']   = absint( $group->group_id );
					$result['group_name'] = $group->get_title();
				}
			}
		}

		$results[] = $result;
	}

	wp_send_json_success( $results );
}
add_action( 'wp_ajax_affwp_search_affiliates', 'affwp_ajax_search_affiliates' );

/**
 * Returns the eligible-affiliate count and total for a payout preview, given the
 * Pay Affiliates form's current filter state. Powers the live eligibility chip
 * on the Step 1 form so admins can see counts update as they tune filters.
 *
 * Returns JSON: { count, total, formatted_total }. Reuses the same filter inputs
 * accepted by preview.php so behaviour stays consistent.
 *
 * @since 2.33.0
 */
function affwp_ajax_payout_eligibility() {

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_send_json_error( [ 'message' => __( 'Permission denied.', 'affiliate-wp' ) ], 403 );
	}

	check_ajax_referer( 'affwp_payout_eligibility', 'nonce' );

	$start          = ! empty( $_POST['from'] ) ? sanitize_text_field( wp_unslash( $_POST['from'] ) ) : false;
	$end            = ! empty( $_POST['to'] ) ? sanitize_text_field( wp_unslash( $_POST['to'] ) ) : false;
	$minimum        = ! empty( $_POST['minimum'] ) ? sanitize_text_field( affwp_sanitize_amount( wp_unslash( $_POST['minimum'] ) ) ) : 0;
	$bypass_holding = ! empty( $_POST['bypass_holding'] );

	$include_affiliates = isset( $_POST['include_affiliates'] ) && is_array( $_POST['include_affiliates'] )
		? array_values( array_filter( array_map( 'absint', wp_unslash( $_POST['include_affiliates'] ) ) ) )
		: array();
	$include_groups     = isset( $_POST['include_groups'] ) && is_array( $_POST['include_groups'] )
		? array_values( array_filter( array_map( 'absint', wp_unslash( $_POST['include_groups'] ) ) ) )
		: array();
	$method_filter      = isset( $_POST['payout_methods'] ) && is_array( $_POST['payout_methods'] )
		? array_values( array_filter( array_map( 'sanitize_key', wp_unslash( $_POST['payout_methods'] ) ) ) )
		: array();

	/*
	 * Override mode (singular payout_method, no plural array): pay everyone via this one
	 * method. The rail's API handles unconfigured affiliates (PayPal sends invites, etc.).
	 */
	$override_method = '';
	if ( empty( $method_filter ) && ! empty( $_POST['payout_method'] ) ) {
		$candidate = strtolower( sanitize_key( wp_unslash( $_POST['payout_method'] ) ) );
		if ( array_key_exists( $candidate, affwp_get_payout_methods() ) ) {
			$override_method = $candidate;
		}
	}

	// Expand groups → affiliate IDs.
	$group_affiliate_ids = array();
	foreach ( $include_groups as $group_id ) {
		if ( 'affiliate-group' !== affiliate_wp()->groups->get_group_type( $group_id ) ) {
			continue;
		}
		foreach ( (array) affiliate_wp()->connections->get_connected( 'affiliate', 'group', $group_id ) as $aid ) {
			if ( affwp_get_affiliate( $aid ) ) {
				$group_affiliate_ids[] = (int) $aid;
			}
		}
	}

	$affiliate_id_filter = array_values( array_unique( array_merge( $include_affiliates, $group_affiliate_ids ) ) );
	$affiliate_id_arg    = ! empty( $affiliate_id_filter ) ? $affiliate_id_filter : false;

	$base_args = array(
		'affiliate_id'        => $affiliate_id_arg,
		'date'                => array(
			'start' => $start,
			'end'   => $end,
		),
		'minimum'             => (float) $minimum,
		'bypass_holding'      => $bypass_holding,
		'override_method'     => $override_method,
		'method_filter'       => $method_filter,
		'apply_addon_filters' => true,
		'referral_shape'      => 'none',
	);

	$result = affwp_compute_eligible_payouts( $base_args );

	/*
	 * Inclusion breakdown for the bar's top row. Run two counter-factual passes against
	 * `affwp_compute_eligible_payouts` to attribute each excluded affiliate to one
	 * exclusion reason:
	 *
	 *   - $pool          — pool size (no minimum, no method filter, no override): the
	 *                      affiliates with unpaid referrals in scope, ignoring the
	 *                      eligibility filters the admin set.
	 *   - $with_minimum  — applies the minimum but leaves method routing wide open.
	 *
	 * Waterfall: pool → below_minimum drops → no-method-or-filtered drops → eligible.
	 *   below_minimum     = pool       - with_minimum
	 *   no_method_count   = with_min   - eligible
	 *
	 * Both extra runs share the same `affiliate_id` filter so the in-scope set stays
	 * stable across passes. Skip when the include filter would return zero pool — the
	 * stuck-referrals block below handles that case separately.
	 */
	$pool_count            = (int) $result['count'];
	$below_minimum_count   = 0;
	$no_method_count       = 0;

	if ( (int) $result['count'] > 0 || ! empty( $affiliate_id_filter ) || ( $minimum > 0 ) || ! empty( $method_filter ) || $override_method ) {
		$with_minimum_args   = array_merge( $base_args, array( 'method_filter' => array(), 'override_method' => '' ) );
		$with_minimum_result = affwp_compute_eligible_payouts( $with_minimum_args );

		$pool_args   = array_merge( $with_minimum_args, array( 'minimum' => 0 ) );
		$pool_result = affwp_compute_eligible_payouts( $pool_args );

		$pool_count          = (int) $pool_result['count'];
		$below_minimum_count = max( 0, (int) $pool_result['count'] - (int) $with_minimum_result['count'] );
		$no_method_count     = max( 0, (int) $with_minimum_result['count'] - (int) $result['count'] );
	}

	$stuck_referrals_count = 0;
	$stuck_payouts_count   = 0;
	$stuck_payouts_url     = '';

	// Count stuck referrals across the whole scope, even when other affiliates are payable, so included affiliates with stuck money aren't silently underpaid.
	global $wpdb;
	$referrals_table = affiliate_wp()->referrals->table_name;
	$payouts_table   = affiliate_wp()->affiliates->payouts->table_name;

	$where  = array( 'r.status = %s', 'r.payout_id != 0', 'p.status IN (%s, %s)' );
	$params = array( 'unpaid', 'processing', 'failed' );

	if ( ! empty( $affiliate_id_filter ) ) {
		$placeholders = implode( ',', array_fill( 0, count( $affiliate_id_filter ), '%d' ) );
		$where[]      = "r.affiliate_id IN ({$placeholders})";
		foreach ( $affiliate_id_filter as $aid ) {
			$params[] = (int) $aid;
		}
	}

	if ( ! empty( $start ) ) {
		$where[]  = 'r.date >= %s';
		$params[] = gmdate( 'Y-m-d 00:00:00', strtotime( $start ) );
	}

	if ( ! empty( $end ) ) {
		$where[]  = 'r.date <= %s';
		$params[] = gmdate( 'Y-m-d 23:59:59', strtotime( $end ) );
	}

	$sql = sprintf(
		'SELECT COUNT(*) AS stuck_referrals, COUNT(DISTINCT r.payout_id) AS stuck_payouts FROM %s r INNER JOIN %s p ON r.payout_id = p.payout_id WHERE %s',
		$referrals_table,
		$payouts_table,
		implode( ' AND ', $where )
	);

	$row = $wpdb->get_row(
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names hard-coded; param placeholders bound below.
		$wpdb->prepare( $sql, $params )
	);

	if ( $row ) {
		$stuck_referrals_count = (int) $row->stuck_referrals;
		$stuck_payouts_count   = (int) $row->stuck_payouts;
	}

	if ( $stuck_referrals_count > 0 ) {
		$stuck_payouts_url_args = array( 'status' => 'failed' );
		if ( count( $affiliate_id_filter ) === 1 ) {
			$stuck_payouts_url_args['affiliate_id'] = (int) $affiliate_id_filter[0];
		}
		$stuck_payouts_url = affwp_admin_url( 'payouts', $stuck_payouts_url_args );
	}

	wp_send_json_success(
		array(
			'count'                 => $result['count'],
			'total'                 => $result['total'],
			// Decode entities so Alpine's x-text renders the currency symbol, not the &#36; literal.
			'formatted_total'       => html_entity_decode( affwp_currency_filter( affwp_format_amount( $result['total'] ) ), ENT_QUOTES, 'UTF-8' ),
			'pool_count'            => $pool_count,
			'below_minimum_count'   => $below_minimum_count,
			'no_method_count'       => $no_method_count,
			'has_method_filter'     => ! empty( $method_filter ),
			'has_override_method'   => ! empty( $override_method ),
			'stuck_referrals_count' => $stuck_referrals_count,
			'stuck_payouts_count'   => $stuck_payouts_count,
			'stuck_payouts_url'     => $stuck_payouts_url,
		)
	);
}
add_action( 'wp_ajax_affwp_payout_eligibility', 'affwp_ajax_payout_eligibility' );

/**
 * Paginated row loader for the Preview Payout screen.
 *
 * Powers the "Show next 100" buttons under each per-method section and the
 * "Cannot be paid this run" section. The screen renders only the first 5 rows
 * per section on the initial page load; subsequent batches are loaded lazily
 * to keep the DOM small (a 4k-eligible run otherwise queued tens of thousands
 * of nodes through Alpine init and froze the browser for several seconds).
 *
 * Re-runs {@see affwp_compute_eligible_payouts()} with the same
 * filter args the screen used, then slices on `offset`/`limit`. No server-side
 * state, refresh-safe; the eligibility query runs in ~0.12s on a 90-day window
 * over 10k affiliates so 4–5 consecutive clicks are imperceptible.
 *
 * Request body (POST):
 *   - section            string  'method' | 'invalid'
 *   - method_key         string  Method slug, required when section='method'.
 *   - offset             int     Where to resume in the section's row list.
 *   - limit              int     Rows per batch (1–500; default 100).
 *   - include_affiliates int[]   Mirror of preview.php's filter inputs.
 *   - include_groups     int[]
 *   - payout_methods     string[]
 *   - payout_method      string  Legacy "override" mode (single method).
 *   - from               string  Date start.
 *   - to                 string  Date end.
 *   - minimum            string  Per-affiliate minimum (currency-formatted).
 *   - bypass_holding     bool
 *   - nonce              string  affwp_preview_payout_load_more
 *
 * Response (JSON):
 *   - html       string  Concatenated row markup (server-rendered, ready to append).
 *   - remaining  int     How many rows still un-loaded after this batch.
 *
 * @since 2.33.0
 */
function affwp_ajax_preview_payout_load_more() {

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_send_json_error( [ 'message' => __( 'Permission denied.', 'affiliate-wp' ) ], 403 );
	}

	check_ajax_referer( 'affwp_preview_payout_load_more', 'nonce' );

	$section = isset( $_POST['section'] ) ? sanitize_key( wp_unslash( $_POST['section'] ) ) : '';
	if ( 'method' !== $section && 'invalid' !== $section ) {
		wp_send_json_error( [ 'message' => __( 'Invalid section.', 'affiliate-wp' ) ], 400 );
	}

	$method_key = isset( $_POST['method_key'] ) ? sanitize_key( wp_unslash( $_POST['method_key'] ) ) : '';
	$offset     = isset( $_POST['offset'] ) ? max( 0, absint( wp_unslash( $_POST['offset'] ) ) ) : 0;
	$limit      = isset( $_POST['limit'] ) ? absint( wp_unslash( $_POST['limit'] ) ) : 100;
	$limit      = max( 1, min( 500, $limit ) );

	// Mirror preview.php's filter parsing so the rerun produces the same payouts/invalid arrays as the initial render.
	$start          = ! empty( $_POST['from'] ) ? sanitize_text_field( wp_unslash( $_POST['from'] ) ) : false;
	$end            = ! empty( $_POST['to'] ) ? sanitize_text_field( wp_unslash( $_POST['to'] ) ) : false;
	$minimum        = ! empty( $_POST['minimum'] ) ? sanitize_text_field( affwp_sanitize_amount( wp_unslash( $_POST['minimum'] ) ) ) : 0;
	$bypass_holding = ! empty( $_POST['bypass_holding'] );

	$include_affiliates = isset( $_POST['include_affiliates'] ) && is_array( $_POST['include_affiliates'] )
		? array_values( array_filter( array_map( 'absint', wp_unslash( $_POST['include_affiliates'] ) ) ) )
		: array();
	$include_groups     = isset( $_POST['include_groups'] ) && is_array( $_POST['include_groups'] )
		? array_values( array_filter( array_map( 'absint', wp_unslash( $_POST['include_groups'] ) ) ) )
		: array();
	$method_filter      = isset( $_POST['payout_methods'] ) && is_array( $_POST['payout_methods'] )
		? array_values( array_filter( array_map( 'sanitize_key', wp_unslash( $_POST['payout_methods'] ) ) ) )
		: array();

	// Override mode: singular payout_method without the plural array means "pay everyone via this one method"; plural is filter-only.
	$payout_method         = '';
	$per_affiliate_routing = true;
	if ( empty( $method_filter ) && ! empty( $_POST['payout_method'] ) ) {
		$candidate = strtolower( sanitize_key( wp_unslash( $_POST['payout_method'] ) ) );
		if ( array_key_exists( $candidate, affwp_get_payout_methods() ) ) {
			$payout_method         = $candidate;
			$per_affiliate_routing = false;
		}
	}

	$group_affiliate_ids = array();
	foreach ( $include_groups as $group_id ) {
		if ( 'affiliate-group' !== affiliate_wp()->groups->get_group_type( $group_id ) ) {
			continue;
		}
		foreach ( (array) affiliate_wp()->connections->get_connected( 'affiliate', 'group', $group_id ) as $aid ) {
			if ( affwp_get_affiliate( $aid ) ) {
				$group_affiliate_ids[] = (int) $aid;
			}
		}
	}

	$affiliate_id_filter = array_values( array_unique( array_merge( $include_affiliates, $group_affiliate_ids ) ) );
	$affiliate_id_arg    = ! empty( $affiliate_id_filter ) ? $affiliate_id_filter : false;

	$eligible = affwp_compute_eligible_payouts(
		array(
			'affiliate_id'        => $affiliate_id_arg,
			'date'                => array(
				'start' => $start,
				'end'   => $end,
			),
			'minimum'             => (float) $minimum,
			'bypass_holding'      => (bool) $bypass_holding,
			'override_method'     => $per_affiliate_routing ? '' : $payout_method,
			'method_filter'       => $method_filter,
			'apply_addon_filters' => true,
			'track_invalid'       => true,
			/*
			 * 'id' is enough for the count() consumer in affwp_render_preview_payout_row();
			 * 'full' would over-fetch per-referral detail the row template never uses.
			 */
			'referral_shape'      => 'id',
		)
	);

	$html      = '';
	$remaining = 0;

	if ( 'method' === $section ) {

		// Re-bucket payouts by method as preview.php does so the requested method's slice keeps the same ordering.
		$payouts_by_method = array();
		foreach ( $eligible['payouts'] as $aid => $payout ) {
			$m_key = $per_affiliate_routing
				? affwp_get_affiliate_usable_payout_method( $aid )
				: $payout_method;

			if ( ! isset( $payouts_by_method[ $m_key ] ) ) {
				$payouts_by_method[ $m_key ] = array();
			}

			// Stamp preflight so the row badge ('ready' / 'invite' / 'blocked') matches the initial server render.
			$payout['status']                     = affwp_preflight_payout_status( $aid, $m_key );
			$payouts_by_method[ $m_key ][ $aid ]  = $payout;
		}

		$section_rows = isset( $payouts_by_method[ $method_key ] ) ? $payouts_by_method[ $method_key ] : array();
		$total_in_section = count( $section_rows );

		$slice  = array_slice( $section_rows, $offset, $limit, true );
		$lookup = affwp_preload_preview_payout_affiliates( array_keys( $slice ) );

		foreach ( $slice as $affiliate_id => $payout ) {
			$html .= affwp_render_preview_payout_row( $affiliate_id, $payout, $lookup );
		}

		$remaining = max( 0, $total_in_section - ( $offset + count( $slice ) ) );

	} else {

		$invalid_rows     = $eligible['invalid_affiliates'];
		$total_in_section = count( $invalid_rows );

		$slice  = array_slice( $invalid_rows, $offset, $limit, true );
		$lookup = affwp_preload_preview_payout_affiliates( array_keys( $slice ) );

		foreach ( $slice as $affiliate_id => $status ) {
			$html .= affwp_render_preview_invalid_row( $affiliate_id, $status, $lookup );
		}

		$remaining = max( 0, $total_in_section - ( $offset + count( $slice ) ) );
	}

	wp_send_json_success(
		array(
			'html'      => $html,
			'remaining' => (int) $remaining,
		)
	);
}
add_action( 'wp_ajax_affwp_preview_payout_load_more', 'affwp_ajax_preview_payout_load_more' );

/**
 * Handles the in-place referral status toggles fired from the referrals list
 * table action rail (Mark as Paid / Mark as Unpaid).
 *
 * Mirrors the bulk handler in AffWP_Referrals_Table::process_bulk_action() so
 * single-row AJAX and bulk produce identical records (a `paid` transition also
 * creates a manual payout via affwp_add_payout). Returns the re-rendered status
 * and actions cells so the server stays the source of truth for which actions
 * are valid at the new status; the JS swaps both cells in place.
 *
 * @since 2.33.0
 */
function affwp_ajax_set_referral_status() {

	check_ajax_referer( 'affwp_referral_row_action', 'nonce' );

	if ( ! current_user_can( 'manage_referrals' ) ) {
		wp_send_json_error( array( 'message' => __( 'You do not have permission to manage referrals.', 'affiliate-wp' ) ) );
	}

	$referral_id = isset( $_POST['referral_id'] ) ? absint( $_POST['referral_id'] ) : 0;
	$status      = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : '';

	if ( ! in_array( $status, array( 'paid', 'unpaid' ), true ) ) {
		wp_send_json_error( array( 'message' => __( 'Invalid status.', 'affiliate-wp' ) ) );
	}

	if ( 'paid' === $status && ! current_user_can( 'manage_payouts' ) ) {
		wp_send_json_error( array( 'message' => __( 'You do not have permission to manage payouts.', 'affiliate-wp' ) ) );
	}

	$referral = affwp_get_referral( $referral_id );

	if ( ! is_a( $referral, '\AffWP\Referral' ) ) {
		wp_send_json_error( array( 'message' => __( 'Referral not found.', 'affiliate-wp' ) ) );
	}

	if ( 'paid' === $status ) {
		if ( 'paid' !== $referral->status ) {
			affwp_set_referral_status( $referral, 'paid' );
			// Generate any payouts we need for it (mirrors the bulk handler).
			affwp_add_payout(
				array(
					'affiliate_id'  => $referral->affiliate_id,
					'referrals'     => $referral_id,
					'payout_method' => 'manual',
				)
			);
		}
	} elseif ( 'unpaid' !== $referral->status ) {
		affwp_set_referral_status( $referral_id, 'unpaid' );
	}

	$referral = affwp_get_referral( $referral_id );

	/*
	 * Regenerate the actions cell against the list URL, not admin-ajax.php.
	 * The row-action links use add_query_arg() against REQUEST_URI, so without
	 * this the swapped-in links would point at admin-ajax.php. Restore after.
	 */
	$saved_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
	if ( ! empty( $_POST['list_url'] ) ) {
		$parts = wp_parse_url( esc_url_raw( wp_unslash( $_POST['list_url'] ) ) );
		if ( ! empty( $parts['path'] ) ) {
			$_SERVER['REQUEST_URI'] = $parts['path'] . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
		}
	}

	if ( ! class_exists( 'AffWP_Referrals_Table' ) ) {
		require_once AFFILIATEWP_PLUGIN_DIR . 'includes/admin/referrals/class-list-table.php';
	}

	$table        = new AffWP_Referrals_Table( array( 'screen' => 'affiliate-wp-referrals' ) );
	$actions_html = $table->column_actions( $referral );

	$_SERVER['REQUEST_URI'] = $saved_request_uri;

	wp_send_json_success(
		array(
			'status'       => $referral->status,
			'status_html'  => affwp_get_referral_status_badge( $referral->status ),
			'actions_html' => $actions_html,
		)
	);
}
add_action( 'wp_ajax_affwp_set_referral_status', 'affwp_ajax_set_referral_status' );

/**
 * Handles the unified "Pay" action fired from the referrals list table rail and
 * the referral drawer.
 *
 * Routes through {@see affwp_pay_referral()}, which dispatches to the affiliate's
 * effective payout method (PayPal/Stripe/Store Credit/Manual) and always records
 * a payout. Returns the re-rendered status and actions cells so the JS can swap
 * them in place, mirroring {@see affwp_ajax_set_referral_status()}.
 *
 * @since 2.33.0
 */
function affwp_ajax_pay_referral() {

	check_ajax_referer( 'affwp_referral_row_action', 'nonce' );

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_send_json_error( array( 'message' => __( 'You do not have permission to manage payouts.', 'affiliate-wp' ) ) );
	}

	$referral_id = isset( $_POST['referral_id'] ) ? absint( $_POST['referral_id'] ) : 0;

	$result = affwp_pay_referral( $referral_id );

	if ( is_wp_error( $result ) ) {
		wp_send_json_error( array( 'message' => $result->get_error_message() ) );
	}

	$referral = affwp_get_referral( $referral_id );

	/*
	 * Regenerate the actions cell against the list URL, not admin-ajax.php
	 * (same reasoning as affwp_ajax_set_referral_status()).
	 */
	$saved_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
	if ( ! empty( $_POST['list_url'] ) ) {
		$parts = wp_parse_url( esc_url_raw( wp_unslash( $_POST['list_url'] ) ) );
		if ( ! empty( $parts['path'] ) ) {
			$_SERVER['REQUEST_URI'] = $parts['path'] . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
		}
	}

	if ( ! class_exists( 'AffWP_Referrals_Table' ) ) {
		require_once AFFILIATEWP_PLUGIN_DIR . 'includes/admin/referrals/class-list-table.php';
	}

	$table        = new AffWP_Referrals_Table( array( 'screen' => 'affiliate-wp-referrals' ) );
	$actions_html = $table->column_actions( $referral );

	$_SERVER['REQUEST_URI'] = $saved_request_uri;

	// Result toast: the in-place status swap is easy to miss (it's fast and the modal closing
	// pulls the eye away), so confirm what happened. Paid = done; processing = async (Stripe/
	// PayPal) initiated. html_entity_decode keeps the amount plain for the x-text toast.
	$affiliate_name = affwp_get_affiliate_name( $referral->affiliate_id );
	if ( '' === $affiliate_name ) {
		$affiliate_name = affwp_get_affiliate_username( $referral->affiliate_id );
	}

	if ( 'paid' === $referral->status ) {
		$amount_str = html_entity_decode( wp_strip_all_tags( affwp_currency_filter( affwp_format_amount( $referral->amount ) ) ), ENT_QUOTES );
		$toast      = array(
			/* translators: 1: formatted amount, 2: affiliate name */
			'message' => sprintf( __( 'Paid %1$s to %2$s.', 'affiliate-wp' ), $amount_str, $affiliate_name ),
			'type'    => 'success',
		);
	} else {
		$method_label = affwp_get_payout_method_display_label( affwp_get_affiliate_usable_payout_method( $referral->affiliate_id ) );
		$toast        = array(
			/* translators: 1: payout method, 2: affiliate name */
			'message' => sprintf( __( '%1$s payment to %2$s is processing.', 'affiliate-wp' ), $method_label, $affiliate_name ),
			'type'    => 'success',
		);
	}

	wp_send_json_success(
		array(
			'status'       => $referral->status,
			'status_html'  => affwp_get_referral_status_badge( $referral->status ),
			'actions_html' => $actions_html,
			'toast'        => $toast,
		)
	);
}
add_action( 'wp_ajax_affwp_pay_referral', 'affwp_ajax_pay_referral' );

/**
 * Returns the method-resolved details to populate the "Pay" confirm modal for a
 * single referral (affiliate, amount, method, destination, warning).
 *
 * @since 2.33.0
 */
function affwp_ajax_pay_referral_preview() {

	check_ajax_referer( 'affwp_referral_row_action', 'nonce' );

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_send_json_error( array( 'message' => __( 'You do not have permission to manage payouts.', 'affiliate-wp' ) ) );
	}

	$referral_id = isset( $_POST['referral_id'] ) ? absint( $_POST['referral_id'] ) : 0;
	$referral    = affwp_get_referral( $referral_id );

	if ( ! is_a( $referral, '\AffWP\Referral' ) ) {
		wp_send_json_error( array( 'message' => __( 'Referral not found.', 'affiliate-wp' ) ) );
	}

	$method         = affwp_get_affiliate_payout_method( $referral->affiliate_id );
	// Decode entities so Alpine's x-text renders the currency symbol, not the &#36; literal.
	$amount         = html_entity_decode( affwp_currency_filter( affwp_format_amount( $referral->amount ), $referral->currency ), ENT_QUOTES, 'UTF-8' );
	$affiliate_name = affwp_get_affiliate_name( $referral->affiliate_id );

	if ( empty( $affiliate_name ) ) {
		$affiliate_name = affwp_get_affiliate_username( $referral->affiliate_id );
	}

	$method_labels = array(
		'paypal'       => __( 'PayPal', 'affiliate-wp' ),
		'stripe'       => __( 'Stripe', 'affiliate-wp' ),
		'store_credit' => __( 'Store Credit', 'affiliate-wp' ),
		'manual'       => __( 'Manual', 'affiliate-wp' ),
	);
	$method_label = isset( $method_labels[ $method ] ) ? $method_labels[ $method ] : ucfirst( $method );

	/*
	 * Already attached to a payout (a prior run still processing, or one that failed): the compute
	 * engine excludes these from new payouts, so single-pay must agree. Re-paying would fail
	 * downstream. Surface a blocked state pointing at the payout, where retry/resolve lives.
	 */
	if ( ! empty( $referral->payout_id ) ) {
		wp_send_json_success(
			array(
				'referral_id'       => $referral->ID,
				'affiliate'         => $affiliate_name,
				'amount'            => $amount,
				'method_label'      => $method_label,
				'destination_label' => '',
				'destination_value' => '',
				'warning'           => '',
				'account_warning'   => __( 'This referral is already attached to a payout. Resolve that payout to pay it again.', 'affiliate-wp' ),
				'blocked'           => true,
				'action_label'      => __( 'View payout', 'affiliate-wp' ),
				'action_url'        => esc_url_raw( add_query_arg( array( 'action' => 'view_payout', 'payout_id' => absint( $referral->payout_id ) ), affwp_admin_url( 'payouts' ) ) ),
				'title'             => __( "Can't pay this referral yet", 'affiliate-wp' ),
				/* translators: %s: formatted payout amount */
				'confirm_label'     => sprintf( __( 'Pay %s', 'affiliate-wp' ), $amount ),
			)
		);
	}

	$destination_label  = '';
	$destination_value  = '';
	$account_warning    = '';
	$blocked            = false;
	$can_add_test_funds = false;
	$test_funds_nonce   = '';
	$test_funds_amount  = 0;

	switch ( $method ) {
		case 'paypal':
			$destination_label = __( 'PayPal email', 'affiliate-wp' );
			$destination_value = affwp_get_affiliate_payment_email( $referral->affiliate_id );
			$warning           = __( 'Sends immediately to the affiliate via PayPal and cannot be undone.', 'affiliate-wp' );

			// No payment email means there's no destination at all — block. (An email being
			// present doesn't guarantee a valid PayPal account, so that's the only hard block.)
			if ( empty( $destination_value ) ) {
				$blocked           = true;
				$destination_value = __( 'No PayPal email', 'affiliate-wp' );
				$account_warning   = __( 'This affiliate has no PayPal payment email, so the payment cannot be sent.', 'affiliate-wp' );
			}
			break;
		case 'stripe':
			$destination_label = __( 'Stripe account', 'affiliate-wp' );
			$account_id        = function_exists( 'affwp_stripe_payouts_get_affiliate_account_id' )
				? affwp_stripe_payouts_get_affiliate_account_id( $referral->affiliate_id )
				: '';
			$destination_value = $account_id ? $account_id : __( 'Not connected', 'affiliate-wp' );
			$warning           = __( 'Sends immediately to the affiliate via Stripe and cannot be undone.', 'affiliate-wp' );

			// Surface Stripe account readiness (cached 30s) so a transfer that would fail
			// (account not connected / payouts disabled / requirements due) is flagged up front.
			if ( function_exists( 'affwp_stripe_payouts_check_affiliate_payment_capability' ) ) {
				try {
					$capability = affwp_stripe_payouts_check_affiliate_payment_capability( $referral->affiliate_id );
				} catch ( \Throwable $e ) {
					$capability = null;
				}

				if ( is_wp_error( $capability ) ) {
					// Not connected / no account — the transfer can't be sent. Block.
					$blocked         = true;
					$account_warning = $capability->get_error_message();
				} elseif ( is_array( $capability ) ) {
					if ( empty( $capability['can_receive_transfers'] ) || empty( $capability['payouts_enabled'] ) ) {
						// Account can't currently receive payouts (requirements due / disabled). Block.
						$blocked         = true;
						$account_warning = ! empty( $capability['error_message'] )
							? $capability['error_message']
							: __( "This affiliate's Stripe account isn't ready to receive payouts, so the transfer can't be sent.", 'affiliate-wp' );
					} elseif ( ! empty( $capability['warning_message'] ) ) {
						// Soft caution (e.g. upcoming requirements) — allow, but surface it.
						$account_warning = $capability['warning_message'];
					}
				}
			}
			break;
		case 'store_credit':
			$warning = __( "Adds this amount to the affiliate's store credit balance.", 'affiliate-wp' );
			break;
		case 'manual':
		default:
			$warning = __( 'Records this referral as paid. No money moves through your store.', 'affiliate-wp' );
			break;
	}

	// In test mode, surface a low Stripe platform balance up front (as an amber notice with an
	// inline "Add test funds" action) instead of letting the payout fail later with a raw Stripe error.
	if ( 'stripe' === $method
		&& ! $blocked
		&& affiliate_wp()->settings->get( 'stripe_test_mode', false )
		&& function_exists( 'affwp_stripe_payouts_log_balance_state' )
	) {
		$balance_state = affwp_stripe_payouts_log_balance_state( $referral->amount, strtolower( affwp_get_currency() ) );
		if ( is_array( $balance_state ) && isset( $balance_state['sufficient_funds'] ) && ! $balance_state['sufficient_funds'] ) {
			/* translators: %s: formatted payout amount */
			$account_warning    = sprintf( __( 'Your platform Stripe test balance is too low to send this %s payout. Add test funds to continue.', 'affiliate-wp' ), $amount );
			$can_add_test_funds = true;
			$test_funds_nonce   = wp_create_nonce( 'affwp_stripe_create_test_charge' );
			$test_funds_amount  = max( 50, (int) ceil( (float) $referral->amount ) );
		}
	}

	wp_send_json_success(
		array(
			'referral_id'       => $referral->ID,
			'affiliate'         => $affiliate_name,
			'amount'            => $amount,
			'method_label'      => $method_label,
			'destination_label' => $destination_label,
			'destination_value' => $destination_value,
			'warning'           => $warning,
			'account_warning'    => $account_warning,
			'blocked'            => $blocked,
			'can_add_test_funds' => $can_add_test_funds,
			'test_funds_nonce'   => $test_funds_nonce,
			'test_funds_amount'  => $test_funds_amount,
			'action_label'      => $blocked ? __( 'View affiliate', 'affiliate-wp' ) : '',
			'action_url'        => $blocked ? esc_url_raw( add_query_arg( array( 'action' => 'edit_affiliate', 'affiliate_id' => $referral->affiliate_id ), affwp_admin_url( 'affiliates' ) ) ) : '',
			'title'             => $blocked ? __( "Can't pay this referral yet", 'affiliate-wp' ) : __( 'Pay referral', 'affiliate-wp' ),
			/* translators: %s: formatted payout amount */
			'confirm_label'     => sprintf( __( 'Pay %s', 'affiliate-wp' ), $amount ),
		)
	);
}
add_action( 'wp_ajax_affwp_pay_referral_preview', 'affwp_ajax_pay_referral_preview' );

/**
 * Builds the bulk-pay DRAWER preview: the same grouped-payout body the Pay Affiliates
 * screen renders ({@see affwp_render_payout_preview_body()}), scoped to a hand-picked
 * selection of referral IDs, plus the footer summary (total · affiliate count) and the
 * payable referral subset to submit.
 *
 * @since 2.33.0
 */
function affwp_ajax_bulk_pay_drawer_preview() {

	check_ajax_referer( 'affwp_referral_row_action', 'nonce' );

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_send_json_error( array( 'message' => __( 'You do not have permission to manage payouts.', 'affiliate-wp' ) ) );
	}

	$ids = isset( $_POST['referral_ids'] ) && is_array( $_POST['referral_ids'] )
		? array_values( array_filter( array_map( 'absint', wp_unslash( $_POST['referral_ids'] ) ) ) )
		: array();

	if ( empty( $ids ) ) {
		wp_send_json_error( array( 'message' => __( 'No referrals selected.', 'affiliate-wp' ) ) );
	}

	// Same engine as the preview screen, scoped to the selection. bypass_holding mirrors the
	// explicit-pay semantics (affwp_pay_referral() ignores the holding period), so the preview
	// matches what the submit will actually pay.
	$data = affwp_build_payout_preview_data(
		array(
			'referral_id'           => $ids,
			'bypass_holding'        => true,
			'apply_addon_filters'   => true,
			'referral_shape'        => 'id',
			'per_affiliate_routing' => true,
			'payout_method'         => '',
		)
	);

	$warnings     = affwp_get_payout_preview_method_warnings( $data['payouts_by_method'] );
	$has_blocking = false;
	foreach ( $warnings as $w ) {
		if ( isset( $w['severity'] ) && 'blocking' === $w['severity'] ) {
			$has_blocking = true;
			break;
		}
	}

	$body_html = affwp_render_payout_preview_body(
		$data,
		array(
			'context'              => 'drawer',
			'deep_link'            => false,
			'row_cap'              => 100,
			'invalid_row_cap'      => 100,
			'load_more'            => false,
			'method_warnings'      => $warnings,
			// The drawer renders its own referral-level "won't be paid" section below (it must
			// explain hand-picked referrals compute drops silently, e.g. already in a payout).
			'show_invalid_section' => false,
			'show_empty_fallback'  => false,
		)
	);

	// Payable referral IDs = ready + invite rows (exclude blocked, e.g. Stripe not connected).
	$payable_ids = array();
	foreach ( $data['payouts_by_method'] as $group ) {
		foreach ( $group['payouts'] as $payout ) {
			if ( isset( $payout['status'] ) && 'blocked' === $payout['status'] ) {
				continue;
			}
			foreach ( (array) $payout['referrals'] as $rid ) {
				$payable_ids[] = (int) $rid;
			}
		}
	}
	$payable_ids = array_values( array_unique( $payable_ids ) );
	$count       = count( $payable_ids );

	// Referral-level "won't be paid" reconciliation: every selected referral not in the payable
	// set, with its reason + fix link. Covers what compute drops silently (already in a payout)
	// AND blocked rows (not connected), so the drawer explains every hand-picked exclusion.
	$skipped_items = affwp_build_bulk_pay_skipped_items( $ids, $payable_ids );

	$total_label = sprintf(
		/* translators: 1: total payout amount, 2: number of affiliates */
		_n( '%1$s &middot; %2$d affiliate', '%1$s &middot; %2$d affiliates', $data['going_out_count'], 'affiliate-wp' ),
		affwp_currency_filter( affwp_format_amount( $data['going_out_total'] ) ),
		(int) $data['going_out_count']
	);

	$title = $data['going_out_count'] > 0
		/* translators: %d: number of affiliates being paid */
		? sprintf( _n( 'Pay %d affiliate', 'Pay %d affiliates', $data['going_out_count'], 'affiliate-wp' ), (int) $data['going_out_count'] )
		: __( "Can't pay this selection yet", 'affiliate-wp' );

	/* translators: %d: number of referrals to pay */
	$confirm_label = sprintf( _n( 'Pay %d referral', 'Pay %d referrals', $count, 'affiliate-wp' ), (int) $count );

	wp_send_json_success(
		array(
			'body_html'     => $body_html,
			'title'         => $title,
			'total_label'   => $total_label,
			'confirm_label' => $confirm_label,
			'payable_ids'   => $payable_ids,
			'count'         => $count,
			'has_blocking'  => $has_blocking,
			'skipped_items' => $skipped_items,
		)
	);
}
add_action( 'wp_ajax_affwp_bulk_pay_drawer_preview', 'affwp_ajax_bulk_pay_drawer_preview' );

/**
 * Builds the referral-level "won't be paid" list for a hand-picked bulk selection: each
 * selected referral that isn't in the payable set, grouped by affiliate + reason, with a
 * short reason and the action that resolves it (View payout for one already attached to a
 * payout, View affiliate to connect Stripe / add a PayPal email). Shared shape with the
 * preview screen's invalid rows, but referral-accurate (an affiliate can be partly payable).
 *
 * @since 2.33.0
 *
 * @param int[] $selected_ids All referral IDs the admin selected.
 * @param int[] $payable_ids  The subset that will actually be paid.
 * @return array[] Grouped skip rows: { affiliate, reason, count, action_label, action_url }.
 */
function affwp_build_bulk_pay_skipped_items( array $selected_ids, array $payable_ids ) {

	$short_reasons = array(
		'not_connected'           => __( 'not connected to Stripe', 'affiliate-wp' ),
		'stripe_not_ready'        => __( "Stripe account can't receive payouts yet", 'affiliate-wp' ),
		'no_paypal_email'         => __( 'no PayPal payment email', 'affiliate-wp' ),
		'payouts_service_retired' => __( 'Payouts Service is retiring — switch payout method', 'affiliate-wp' ),
		'already_paid'            => __( 'already paid', 'affiliate-wp' ),
		'previous_payout_failed'  => __( 'already attached to a payout', 'affiliate-wp' ),
		'minimum_payout'          => __( 'below the minimum payout amount', 'affiliate-wp' ),
		'generic'                 => __( "can't be paid right now", 'affiliate-wp' ),
	);

	$payable_lookup = array_flip( array_map( 'intval', $payable_ids ) );
	$grouped        = array();

	foreach ( $selected_ids as $id ) {
		$id = (int) $id;
		if ( isset( $payable_lookup[ $id ] ) ) {
			continue;
		}
		$referral = affwp_get_referral( $id );
		if ( ! is_a( $referral, '\AffWP\Referral' ) ) {
			continue;
		}
		$aid  = (int) $referral->affiliate_id;
		$code = affwp_get_referral_payability( $id )['code'];
		if ( '' === $code ) {
			// Single-check found no hard block but compute still dropped it (e.g. Stripe's preview
			// readiness is stricter than the capability check). Infer from the resolved method.
			$code = ( 'stripe' === affwp_get_affiliate_usable_payout_method( $aid ) ) ? 'stripe_not_ready' : 'generic';
		}

		$key = $aid . '|' . $code;
		if ( ! isset( $grouped[ $key ] ) ) {
			$name = affwp_get_affiliate_name( $aid );
			if ( '' === $name ) {
				$name = affwp_get_affiliate_username( $aid );
			}

			$action_label = '';
			$action_url   = '';
			// "View payout" opens the payout drawer IN-CONTEXT (a delegated click handler on the
			// Referrals screen intercepts these links). Flag it so the bulk-pay drawer can step
			// aside on click instead of stacking behind it. "View affiliate" is a real navigation.
			$opens_drawer = false;
			$payout_id    = 0;
			if ( 'already_paid' === $code ) {
				$action_label = ''; // Nothing to resolve.
			} elseif ( 'previous_payout_failed' === $code && ! empty( $referral->payout_id ) ) {
				$action_label = __( 'View payout', 'affiliate-wp' );
				$action_url   = add_query_arg( array( 'action' => 'view_payout', 'payout_id' => absint( $referral->payout_id ) ), affwp_admin_url( 'payouts' ) );
				$opens_drawer = true;
				$payout_id    = absint( $referral->payout_id );
			} elseif ( $aid ) {
				$action_label = __( 'View affiliate', 'affiliate-wp' );
				$action_url   = add_query_arg( array( 'action' => 'edit_affiliate', 'affiliate_id' => $aid ), affwp_admin_url( 'affiliates' ) );
			}

			$grouped[ $key ] = array(
				/* translators: %d: affiliate ID (fallback when the name is unavailable) */
				'affiliate'    => $name ? $name : sprintf( __( 'Affiliate #%d', 'affiliate-wp' ), $aid ),
				'reason'       => isset( $short_reasons[ $code ] ) ? $short_reasons[ $code ] : $short_reasons['generic'],
				'count'        => 0,
				'action_label' => $action_label,
				'action_url'   => $action_url ? esc_url_raw( $action_url ) : '',
				'opens_drawer' => $opens_drawer,
				'payout_id'    => $payout_id,
			);
		}
		$grouped[ $key ]['count']++;
	}

	return array_values( $grouped );
}

/**
 * Creates a real async payout batch scoped to a hand-picked set of referral IDs and
 * returns its batch_id. This is what the bulk-pay drawer submits to: it produces the
 * same kind of batch as the Pay Affiliates screen (grouped per-affiliate payouts, a
 * batch row, status polling, retry) instead of looping per-referral payouts. The
 * drawer then opens the batch drawer on the returned batch_id.
 *
 * @since 2.33.0
 */
function affwp_ajax_bulk_pay_create_batch() {

	check_ajax_referer( 'affwp_referral_row_action', 'nonce' );

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_send_json_error( array( 'message' => __( 'You do not have permission to manage payouts.', 'affiliate-wp' ) ) );
	}

	$ids = isset( $_POST['referral_ids'] ) && is_array( $_POST['referral_ids'] )
		? array_values( array_filter( array_map( 'absint', wp_unslash( $_POST['referral_ids'] ) ) ) )
		: array();

	if ( empty( $ids ) ) {
		wp_send_json_error( array( 'message' => __( 'No referrals selected.', 'affiliate-wp' ) ) );
	}

	if ( ! function_exists( 'as_enqueue_async_action' ) ) {
		wp_send_json_error( array( 'message' => __( 'Background processing is unavailable, so the payout batch could not be created.', 'affiliate-wp' ) ) );
	}

	require_once AFFILIATEWP_PLUGIN_DIR . 'includes/payouts/class-batch-payout-processor.php';

	// referral_ids scope + bypass_holding mirrors the explicit-pay semantics; the batch's own
	// compute drops anything ineligible (not connected, etc.), matching the drawer preview.
	$result = \AffWP\Payouts\Batch_Payout_Processor::enqueue_async_batch(
		array(
			'referral_ids'   => $ids,
			'bypass_holding' => 1,
		)
	);

	if ( is_wp_error( $result ) ) {
		wp_send_json_error( array( 'message' => $result->get_error_message() ) );
	}

	wp_send_json_success( array( 'batch_id' => (int) $result ) );
}
add_action( 'wp_ajax_affwp_bulk_pay_create_batch', 'affwp_ajax_bulk_pay_create_batch' );

/**
 * Handles the in-place affiliate status changes fired from the affiliates list
 * table action rail (Accept / Reject on pending affiliates).
 *
 * Mirrors AffWP_Affiliates_Table::process_bulk_action(): activating an
 * affiliate also provisions a dynamic coupon when that feature is enabled and
 * none exists, so single-row AJAX and bulk stay identical. Returns the
 * re-rendered status + actions cells for the JS to swap in place.
 *
 * @since 2.33.0
 */
function affwp_ajax_set_affiliate_status() {

	check_ajax_referer( 'affwp_affiliate_row_action', 'nonce' );

	if ( ! current_user_can( 'manage_affiliates' ) ) {
		wp_send_json_error( array( 'message' => __( 'You do not have permission to manage affiliates.', 'affiliate-wp' ) ) );
	}

	$affiliate_id = isset( $_POST['affiliate_id'] ) ? absint( $_POST['affiliate_id'] ) : 0;
	$status       = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : '';

	if ( ! in_array( $status, array( 'active', 'rejected' ), true ) ) {
		wp_send_json_error( array( 'message' => __( 'Invalid status.', 'affiliate-wp' ) ) );
	}

	$affiliate = affwp_get_affiliate( $affiliate_id );

	if ( ! is_a( $affiliate, '\AffWP\Affiliate' ) ) {
		wp_send_json_error( array( 'message' => __( 'Affiliate not found.', 'affiliate-wp' ) ) );
	}

	// Mirror the bulk handler: provision a dynamic coupon on activation when enabled and the affiliate has none.
	if ( 'active' === $status && affiliate_wp()->settings->get( 'dynamic_coupons' ) ) {
		$coupons = affwp_get_dynamic_affiliate_coupons( $affiliate_id, false );
		if ( empty( $coupons ) ) {
			affiliate_wp()->affiliates->coupons->add( array( 'affiliate_id' => $affiliate_id ) );
		}
	}

	affwp_set_affiliate_status( $affiliate_id, $status );

	$affiliate = affwp_get_affiliate( $affiliate_id );

	// Re-render the cells against the list URL, not admin-ajax.php (see the referral handler for the REQUEST_URI rationale).
	$saved_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
	if ( ! empty( $_POST['list_url'] ) ) {
		$parts = wp_parse_url( esc_url_raw( wp_unslash( $_POST['list_url'] ) ) );
		if ( ! empty( $parts['path'] ) ) {
			$_SERVER['REQUEST_URI'] = $parts['path'] . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
		}
	}

	if ( ! class_exists( 'AffWP_Affiliates_Table' ) ) {
		require_once AFFILIATEWP_PLUGIN_DIR . 'includes/admin/affiliates/class-list-table.php';
	}

	$table        = new AffWP_Affiliates_Table( array( 'screen' => 'affiliate-wp-affiliates' ) );
	$actions_html = $table->column_actions( $affiliate );

	$_SERVER['REQUEST_URI'] = $saved_request_uri;

	wp_send_json_success(
		array(
			'status'       => $affiliate->status,
			'status_html'  => affwp_get_affiliate_status_badge( $affiliate->status ),
			'actions_html' => $actions_html,
		)
	);
}
add_action( 'wp_ajax_affwp_set_affiliate_status', 'affwp_ajax_set_affiliate_status' );
