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

/**
 * Processes the preview payout request.
 *
 * @since 2.4
 * @since 2.27.0 Added the `$bypass_holding` argument.
 *
 * @param array $data {
 *     Array of arguments for generating a payout preview. Default empty array.
 *
 *     @type string $user_name      Affiliate username.
 *     @type string $from           Referrals start date.
 *     @type string $to             Referral end date.
 *     @type bool   $bypass_holding Include commissions in the holding period.
 *     @type string $minimum        Minimum payout amount.
 *     @type string $payout_method  Payout method.
 * }
 * @return void|false
 */
function affwp_process_preview_payout( $data ) {

	if ( ! is_admin() ) {
		return false;
	}

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_die( __( 'You do not have permission to process payouts', 'affiliate-wp' ), __( 'Error', 'affiliate-wp' ), [ 'response' => 403 ] );
	}

	$nonce = empty( $data['affwp_preview_payout_nonce'] ) ? '' : $data['affwp_preview_payout_nonce'];

	if ( ! wp_verify_nonce( $nonce, 'affwp_preview_payout_nonce' ) ) {
		wp_die( __( 'Security check failed', 'affiliate-wp' ), [ 'response' => 403 ] );
	}

	$query_args = [
		'action'         => 'preview_payout',
		'user_name'      => sanitize_text_field( $data['user_name'] ),
		'from'           => sanitize_text_field( $data['from'] ),
		'to'             => sanitize_text_field( $data['to'] ),
		/*
		 * Read from POST, not $data ($_REQUEST), so an unchecked box (which posts nothing)
		 * reads as off instead of picking up a stale ?bypass_holding=1 from a prior preview.
		 */
		'bypass_holding' => ! empty( $_POST['bypass_holding'] ) ? 1 : 0,
		'minimum'        => sanitize_text_field( $data['minimum'] ),
	];

	// Multi-select filters, preserved in the redirect URL so the preview stays bookmarkable and shareable.
	if ( ! empty( $data['include_affiliates'] ) && is_array( $data['include_affiliates'] ) ) {
		$query_args['include_affiliates'] = array_values( array_filter( array_map( 'absint', $data['include_affiliates'] ) ) );
	}

	if ( ! empty( $data['include_groups'] ) && is_array( $data['include_groups'] ) ) {
		$query_args['include_groups'] = array_values( array_filter( array_map( 'absint', $data['include_groups'] ) ) );
	}

	if ( ! empty( $data['payout_methods'] ) && is_array( $data['payout_methods'] ) ) {
		$query_args['payout_methods'] = array_values( array_filter( array_map( 'sanitize_key', $data['payout_methods'] ) ) );
	}

	/*
	 * Forward a batch-level method filter when provided (backwards compat for add-ons that
	 * still trigger their own Pay Affiliates flow with a single method). 2.33.0+ the admin
	 * form does not send this — method is resolved per-affiliate at submission time.
	 */
	if ( ! empty( $data['payout_method'] ) ) {
		$query_args['payout_method'] = sanitize_text_field( $data['payout_method'] );
	}

	$url = affwp_admin_url( 'payouts', $query_args );

	wp_redirect( $url );
	exit;
}
add_action( 'affwp_preview_payout', 'affwp_process_preview_payout' );

/**
 * Processes a new payout request.
 *
 * @since 2.4
 * @since 2.27.0 Included bypass_holding parameter.
 *
 * @param array $data {
 *     Array of arguments for creating a new payout. Default empty array.
 *
 *     @type string $user_name     Affiliate username.
 *     @type string $from          Referrals start date.
 *     @type string $to            Referral end date.
 *     @type string $minimum       Minimum payout amount.
 *     @type string $payout_method Payout method.
 * }
 *
 * @return false|void False if not admin page request, payout_method, affwp_new_payout_nonce data isn't
 *                    passed or nonce verification failed, void otherwise.
 */
function affwp_process_new_payout( $data ) {

	if ( ! is_admin() ) {
		return false;
	}

	if ( empty( $data['payout_method'] ) ) {
		return false;
	}

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

	$nonce = empty( $data['affwp_new_payout_nonce'] ) ? '' : $data['affwp_new_payout_nonce'];

	if ( ! wp_verify_nonce( $nonce, 'affwp_new_payout_nonce' ) ) {
		return false;
	}

	$payout_methods = affwp_get_payout_methods();

	if ( ! array_key_exists( $data['payout_method'], $payout_methods ) ) {
		wp_die( __( 'Invalid payout method', 'affiliate-wp' ), [ 'response' => 403 ] );
	}

	if ( ! empty( $data['user_name'] ) && ( $affiliate = affwp_get_affiliate( $data['user_name'] ) ) ) {
		$affiliate_id = $affiliate->ID;
	} else {
		$affiliate_id = false;
	}

	$start          = ! empty( $data['from'] ) ? sanitize_text_field( $data['from'] ) : false;
	$end            = ! empty( $data['to'] ) ? sanitize_text_field( $data['to'] ) : false;
	$minimum        = ! empty( $data['minimum'] ) ? sanitize_text_field( affwp_sanitize_amount( $data['minimum'] ) ) : 0;
	$payout_method  = ! empty( $data['payout_method'] ) ? strtolower( sanitize_text_field( $data['payout_method'] ) ) : 'manual';
	$bypass_holding = ! empty( $data['bypass_holding'] ) ? absint( $data['bypass_holding'] ) : 0;

	/**
	 * Fires after a new payout action is performed.
	 *
	 * The dynamic portion of the hook name, `$payout_method` refers to the payout method.
	 *
	 * @since 2.4
	 *
	 * @param string   $start          Referrals start date.
	 * @param string   $end            Referral end date.
	 * @param string   $minimum        Minimum payout amount.
	 * @param int|bool $affiliate_id   Affiliate ID.
	 * @param string   $payout_method  Payout method.
	 * @param bool     $bypass_holding Whether to bypass the holding period or not.
	 */
	do_action(
		"affwp_process_payout_{$payout_method}",
		$start,
		$end,
		$minimum,
		$affiliate_id,
		$payout_method,
		(bool) $bypass_holding
	);
}
add_action( 'affwp_new_payout', 'affwp_process_new_payout' );

/**
 * Processes the "Retry Payment" row action from the classic payouts list.
 *
 * Fires on the `affwp_retry_payment` hook via `affwp_do_actions()` when the URL
 * carries `?affwp_action=retry_payment&payout_id=X&_wpnonce=...`. Single-payout
 * case — bulk retries go through `affwp_process_bulk_retry_payment()` below so
 * N checked items land in one shared batch.
 *
 * @since 2.33.0
 *
 * @param array $data $_REQUEST payload as passed by `affwp_do_actions()`.
 * @return void
 */
function affwp_process_retry_payment( $data ) {

	if ( ! is_admin() ) {
		return;
	}

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_die( esc_html__( 'You do not have permission to retry payouts.', 'affiliate-wp' ), esc_html__( 'Error', 'affiliate-wp' ), [ 'response' => 403 ] );
	}

	if ( empty( $data['_wpnonce'] ) || ! wp_verify_nonce( $data['_wpnonce'], 'payout-nonce' ) ) {
		wp_die( esc_html__( 'Security check failed.', 'affiliate-wp' ), esc_html__( 'Error', 'affiliate-wp' ), [ 'response' => 403 ] );
	}

	$payout_id = isset( $data['payout_id'] ) ? absint( $data['payout_id'] ) : 0;
	$result    = affwp_retry_payout( $payout_id );

	$notice = is_wp_error( $result ) ? 'payout_retry_failed' : 'payout_retried';
	$url    = affwp_admin_url( 'payouts', [ 'affwp_notice' => $notice ] );

	wp_safe_redirect( $url );
	exit;
}
add_action( 'affwp_retry_payment', 'affwp_process_retry_payment' );

/**
 * Processes the "Retry Payment" bulk action from the classic payouts list.
 *
 * Bulk is a separate code path from the row action because all N checked
 * items should land in a single new batch — the audit story is "admin retried
 * these 5 failures together", not 5 independent one-off retries. Intercepts
 * the bulk form submit on `admin_init` (before `process_bulk_action()` runs
 * inside the page render, so the redirect headers can fire cleanly).
 *
 * Listens for the standard WP_List_Table bulk nonce (`bulk-payouts`) plus
 * `action` or `action2` (top/bottom dropdowns both submit, depending which
 * Apply the admin clicked).
 *
 * @since 2.33.0
 *
 * @return void
 */
function affwp_process_bulk_retry_payment() {

	if ( ! is_admin() ) {
		return;
	}

	if ( empty( $_REQUEST['page'] ) || 'affiliate-wp-payouts' !== $_REQUEST['page'] ) {
		return;
	}

	$action1   = isset( $_REQUEST['action'] ) ? sanitize_key( $_REQUEST['action'] ) : '';
	$action2   = isset( $_REQUEST['action2'] ) ? sanitize_key( $_REQUEST['action2'] ) : '';
	$requested = ( 'retry_payment' === $action1 || 'retry_payment' === $action2 );

	if ( ! $requested ) {
		return;
	}

	if ( empty( $_REQUEST['_wpnonce'] ) || ! wp_verify_nonce( $_REQUEST['_wpnonce'], 'bulk-payouts' ) ) {
		return;
	}

	$ids = isset( $_REQUEST['payout_id'] ) ? (array) $_REQUEST['payout_id'] : [];
	$ids = array_filter( array_map( 'absint', $ids ) );

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

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_die( esc_html__( 'You do not have permission to retry payouts.', 'affiliate-wp' ), esc_html__( 'Error', 'affiliate-wp' ), [ 'response' => 403 ] );
	}

	/*
	 * One shared batch for the whole bulk operation. `retry_of_batch_id` stays
	 * at 0 because the N source payouts may have come from different batches —
	 * there's no single parent to point back to.
	 */
	$batch_id = affwp_add_payout_batch( [
		'created_by'    => get_current_user_id(),
		'payout_method' => 'mixed',
		'status'        => 'processing',
	] );

	if ( ! $batch_id ) {
		wp_safe_redirect( affwp_admin_url( 'payouts', [ 'affwp_notice' => 'payout_retry_failed' ] ) );
		exit;
	}

	$succeeded = 0;
	$failed    = 0;

	foreach ( $ids as $id ) {
		$result = affwp_retry_payout( $id, $batch_id );
		if ( is_wp_error( $result ) ) {
			$failed++;
		} else {
			$succeeded++;
		}
	}

	/*
	 * Fire the batch-complete lifecycle once, after all N payouts are attached.
	 * The method processors (PayPal/Stripe) handle the mixed-method batch via
	 * per-affiliate routing on their end.
	 */
	affiliate_wp()->affiliates->payout_batches->recount( (int) $batch_id );
	do_action( 'affwp_batch_generate_payouts_completed', (int) $batch_id );

	$notice_key = $succeeded > 0 ? 'payout_retried_bulk' : 'payout_retry_failed';

	wp_safe_redirect( affwp_admin_url( 'payouts', [
		'affwp_notice' => $notice_key,
		'retried'      => $succeeded,
		'failed'       => $failed,
	] ) );
	exit;
}
add_action( 'admin_init', 'affwp_process_bulk_retry_payment' );

/**
 * Retries every failed child of a batch in one new retry-batch.
 *
 * One-click equivalent of: open the batch's failed payouts, check all, bulk
 * retry. Skips already-successful children by construction (we only query
 * rows where `status = 'failed'` inside this batch). Succeeds in a single
 * shared retry-batch that carries `retry_of_batch_id` back to the source.
 *
 * Semantics note: the source batch is **never touched**. Its failed children
 * stay as an audit record forever; the new batch is an additional, linked
 * attempt. Walking `retry_of_batch_id` pointers reconstructs the full chain.
 *
 * URL: `?affwp_action=retry_batch_failed&batch_id=N&_wpnonce=payout-nonce`
 *
 * @since 2.33.0
 *
 * @param array $data $_REQUEST payload.
 * @return void
 */
function affwp_process_retry_batch_failed( $data ) {

	if ( ! is_admin() ) {
		return;
	}

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_die( esc_html__( 'You do not have permission to retry payouts.', 'affiliate-wp' ), esc_html__( 'Error', 'affiliate-wp' ), [ 'response' => 403 ] );
	}

	if ( empty( $data['_wpnonce'] ) || ! wp_verify_nonce( $data['_wpnonce'], 'payout-nonce' ) ) {
		wp_die( esc_html__( 'Security check failed.', 'affiliate-wp' ), esc_html__( 'Error', 'affiliate-wp' ), [ 'response' => 403 ] );
	}

	$batch_id = isset( $data['batch_id'] ) ? absint( $data['batch_id'] ) : 0;
	$result   = affwp_retry_batch_failed_payouts( $batch_id );

	switch ( $result['status'] ) {
		case 'invalid_batch':
			wp_safe_redirect( affwp_admin_url( 'payouts', [ 'tab' => 'batches' ] ) );
			exit;

		case 'nothing_to_retry':
			wp_safe_redirect( affwp_admin_url( 'payouts', [
				'tab'          => 'batches',
				'batch_id'     => $batch_id,
				'affwp_notice' => 'payout_retry_nothing',
			] ) );
			exit;

		case 'create_failed':
			wp_safe_redirect( affwp_admin_url( 'payouts', [
				'tab'          => 'batches',
				'batch_id'     => $batch_id,
				'affwp_notice' => 'payout_retry_failed',
			] ) );
			exit;
	}

	/*
	 * Land on the new retry batch's detail so the admin sees the in-flight
	 * state. The source batch stays untouched in the list.
	 */
	wp_safe_redirect( affwp_admin_url( 'payouts', [
		'tab'          => 'batches',
		'batch_id'     => (int) $result['new_batch_id'],
		'affwp_notice' => 'payout_retried_bulk',
		'retried'      => (int) $result['succeeded'],
		'failed'       => (int) $result['failed'],
	] ) );
	exit;
}
add_action( 'affwp_retry_batch_failed', 'affwp_process_retry_batch_failed' );

/**
 * Creates a retry batch for the failed payouts inside a source batch.
 *
 * Pure data work: validate, fan out failed children into a new batch,
 * recount, fire the completion hook. No permission or CSRF checks — the
 * caller (HTTP handler, bulk processor, CLI command) owns that.
 *
 * The source batch is never touched. `retry_of_batch_id` on the new batch
 * is the only link back, so the audit chain is preserved.
 *
 * @since 2.33.0
 *
 * @param int $batch_id Source batch ID.
 * @return array{
 *     new_batch_id: int|null,
 *     succeeded:    int,
 *     failed:       int,
 *     status:       string
 * } `status` is one of: ok, invalid_batch, nothing_to_retry, create_failed.
 */
function affwp_retry_batch_failed_payouts( $batch_id ) {

	$batch_id = absint( $batch_id );
	$batch    = $batch_id ? affwp_get_payout_batch( $batch_id ) : false;

	if ( ! $batch ) {
		return [
			'new_batch_id' => null,
			'succeeded'    => 0,
			'failed'       => 0,
			'status'       => 'invalid_batch',
		];
	}

	/*
	 * `number = -1` returns the full set; a batch with hundreds of failed
	 * items is unusual but the query plan here is indexed (KEY batch_id on
	 * the payouts table) so it's safe.
	 */
	$failed_payouts = affiliate_wp()->affiliates->payouts->get_payouts( [
		'batch_id' => (int) $batch->batch_id,
		'status'   => 'failed',
		'number'   => -1,
		'fields'   => 'ids',
	] );

	if ( empty( $failed_payouts ) ) {
		return [
			'new_batch_id' => null,
			'succeeded'    => 0,
			'failed'       => 0,
			'status'       => 'nothing_to_retry',
		];
	}

	$new_batch_id = affwp_add_payout_batch( [
		'created_by'        => get_current_user_id(),
		'payout_method'     => 'mixed',
		'status'            => 'processing',
		'retry_of_batch_id' => (int) $batch->batch_id,
	] );

	if ( ! $new_batch_id ) {
		return [
			'new_batch_id' => null,
			'succeeded'    => 0,
			'failed'       => 0,
			'status'       => 'create_failed',
		];
	}

	$succeeded = 0;
	$failed    = 0;

	foreach ( $failed_payouts as $pid ) {
		$result = affwp_retry_payout( (int) $pid, (int) $new_batch_id );
		if ( is_wp_error( $result ) ) {
			$failed++;
		} else {
			$succeeded++;
		}
	}

	affiliate_wp()->affiliates->payout_batches->recount( (int) $new_batch_id );
	do_action( 'affwp_batch_generate_payouts_completed', (int) $new_batch_id );

	return [
		'new_batch_id' => (int) $new_batch_id,
		'succeeded'    => $succeeded,
		'failed'       => $failed,
		'status'       => 'ok',
	];
}

/**
 * Manually triggers the auto-retry sweep for failed `affiliate_action_required` payouts.
 *
 * Cap-gated + nonce-protected. The daily Action Scheduler hook runs this
 * function automatically once per 24h; this admin-post handler exists as
 * a QA + emergency-rerun affordance so admins don't have to wait for
 * the next cron tick. Reports counts via the redirect notice.
 *
 * URL: `?affwp_action=run_retry_sweep_now&_wpnonce=payout-nonce`
 *
 * @since 2.33.0
 *
 * @param array $data $_REQUEST payload.
 * @return void
 */
function affwp_process_run_retry_sweep_now( $data ) {

	if ( ! is_admin() ) {
		return;
	}

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_die( esc_html__( 'You do not have permission to run the retry sweep.', 'affiliate-wp' ), esc_html__( 'Error', 'affiliate-wp' ), array( 'response' => 403 ) );
	}

	if ( empty( $data['_wpnonce'] ) || ! wp_verify_nonce( $data['_wpnonce'], 'payout-nonce' ) ) {
		wp_die( esc_html__( 'Security check failed.', 'affiliate-wp' ), esc_html__( 'Error', 'affiliate-wp' ), array( 'response' => 403 ) );
	}

	$report = affwp_run_action_required_retry_sweep();

	wp_safe_redirect(
		affwp_admin_url(
			'payouts',
			array(
				'affwp_notice' => 'retry_sweep_completed',
				'candidates'   => (int) $report['candidates'],
				'retried'      => (int) $report['retried'],
				'skipped'      => (int) $report['skipped'],
			)
		)
	);
	exit;
}
add_action( 'affwp_run_retry_sweep_now', 'affwp_process_run_retry_sweep_now' );

/**
 * Adds a note to the payout preview page for a manual payout.
 *
 * @since 2.4
 *
 * @return void
 */
function affwp_manual_payout_preview_payout_note() {
	?>
	<h2><?php esc_html_e( 'Note', 'affiliate-wp' ); ?></h2>
	<p><?php esc_html_e( 'A CSV file will be generated containing the payout details for each affiliate.', 'affiliate-wp' ); ?></p>
	<p>
		<?php
		/* translators: Import/Export Tools screen URL */
		printf( __( 'This will mark all unpaid referrals in this timeframe as paid. To export referrals with a status other than <em>unpaid</em>, go to the <a href="%s">Tools &rarr; Export</a> page.', 'affiliate-wp' ), esc_url( affwp_admin_url( 'tools', [ 'tab' => 'export_import' ] ) ) );
		?>
	</p>
	<?php
}
add_action( 'affwp_preview_payout_note_manual', 'affwp_manual_payout_preview_payout_note' );

/**
 * Payout methods that get a row in the manual-payout CSV.
 *
 * Shares the `affwp_csv_payout_methods` filter with the batch processor so the
 * drawer download regenerates exactly the set the processor would have written:
 * only methods the admin pays by hand. Integrated methods (PayPal, Stripe) are
 * excluded so a re-download can't be uploaded to a provider and double-pay.
 *
 * @since 2.33.0
 *
 * @return string[] Method identifiers eligible for the CSV. Default ['manual'].
 */
function affwp_get_payout_batch_csv_methods() {
	/** This filter is documented in includes/payouts/class-batch-payout-processor.php */
	return array_values( array_filter( array_map( 'strval', (array) apply_filters( 'affwp_csv_payout_methods', array( 'manual' ) ) ) ) );
}

/**
 * Returns the paid, CSV-eligible payouts in a batch.
 *
 * The CSV is regenerated on demand from these rows (the source of truth) rather
 * than read back from the transient file the processor wrote — so the download
 * survives revisits, server cleanup, and the next day's batch overwriting the
 * shared dated filename.
 *
 * @since 2.33.0
 *
 * @param int $batch_id Payout batch ID.
 * @return \AffWP\Affiliate\Payout[] Payout objects, oldest first.
 */
function affwp_get_payout_batch_csv_payouts( $batch_id ) {

	$methods = affwp_get_payout_batch_csv_methods();

	if ( empty( $methods ) ) {
		return array();
	}

	// get_payouts() filters one method at a time, so fetch the batch's paid payouts and narrow by the CSV method set in PHP.
	$payouts = (array) affiliate_wp()->affiliates->payouts->get_payouts( array(
		'batch_id' => absint( $batch_id ),
		'status'   => 'paid',
		'number'   => -1,
		'orderby'  => 'payout_id',
		'order'    => 'ASC',
	) );

	return array_values( array_filter(
		$payouts,
		static function ( $payout ) use ( $methods ) {
			return in_array( strtolower( (string) $payout->payout_method ), $methods, true );
		}
	) );
}

/**
 * Builds the nonce-protected download URL for a batch's manual-payout CSV.
 *
 * Pure URL builder — does not check whether the batch actually has CSV rows.
 * Callers gate on `affwp_get_payout_batch_csv_payouts()` (or its count) so the
 * eligibility query runs once per render rather than once here and again there.
 *
 * @since 2.33.0
 *
 * @param int $batch_id Payout batch ID.
 * @return string Download URL.
 */
function affwp_payout_batch_csv_download_url( $batch_id ) {

	$batch_id = absint( $batch_id );

	return affwp_admin_url(
		'payouts',
		array(
			'affwp_action' => 'download_payout_batch_csv',
			'batch_id'     => $batch_id,
			'_wpnonce'     => wp_create_nonce( 'affwp-download-payout-batch-csv-' . $batch_id ),
		)
	);
}

/**
 * Builds the manual-payout CSV string for a batch.
 *
 * Pure string builder — no headers, no exit — so the output is unit-testable
 * and reusable. Regenerated from the batch's paid manual payouts; the column
 * set and row format mirror Batch_Payout_Processor so a re-download is
 * byte-equivalent to the file the processor originally wrote.
 *
 * @since 2.33.0
 *
 * @param int $batch_id Payout batch ID.
 * @return string CSV contents (header + one row per manual payout), or '' when
 *                the batch has no manual rows to export.
 */
function affwp_build_payout_batch_csv( $batch_id ) {

	$payouts = affwp_get_payout_batch_csv_payouts( $batch_id );

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

	/** This filter is documented in includes/payouts/class-batch-payout-processor.php */
	$cols = (array) apply_filters(
		'affwp_batch_generate_payouts_csv_cols',
		array(
			'email'    => __( 'Email', 'affiliate-wp' ),
			'amount'   => __( 'Amount', 'affiliate-wp' ),
			'currency' => __( 'Currency', 'affiliate-wp' ),
		)
	);

	$to_line = static function ( array $values ) use ( $cols ) {
		$line = '';
		$i    = 1;
		foreach ( $values as $col_id => $value ) {
			if ( ! array_key_exists( $col_id, $cols ) ) {
				continue;
			}
			$line .= '"' . addslashes( preg_replace( '/"/', "'", (string) $value ) ) . '"';
			$line .= $i === count( $cols ) ? '' : ',';
			$i++;
		}
		return $line . "\r\n";
	};

	$output = $to_line( $cols );

	foreach ( $payouts as $payout ) {

		$currency     = affwp_get_currency();
		$referral_ids = array_map( 'absint', (array) $payout->referrals );
		if ( ! empty( $referral_ids ) ) {
			$first_referral = affwp_get_referral( $referral_ids[0] );
			if ( $first_referral && ! empty( $first_referral->currency ) ) {
				$currency = $first_referral->currency;
			}
		}

		/** This filter is documented in includes/payouts/class-batch-payout-processor.php */
		$row = apply_filters(
			'affwp_batch_generate_payouts_get_data',
			array(
				'email'    => affwp_get_affiliate_payment_email( $payout->affiliate_id ),
				'amount'   => affwp_format_amount( $payout->amount ),
				'currency' => $currency,
			),
			(int) $payout->affiliate_id,
			array( (int) $payout->affiliate_id => array( 'amount' => (float) $payout->amount, 'currency' => $currency, 'referrals' => $referral_ids ) )
		);

		$output .= $to_line( $row );
	}

	return $output;
}

/**
 * Streams a batch's manual-payout CSV to the browser, regenerated on demand.
 *
 * Replaces the legacy fire-once auto-download: the file is rebuilt from the
 * batch's paid manual payouts every time, so admins can re-download a batch any
 * time from the drawer or the Batches list.
 *
 * @since 2.33.0
 *
 * @param array $data $_REQUEST payload.
 * @return void
 */
function affwp_download_payout_batch_csv( $data ) {

	$batch_id = isset( $data['batch_id'] ) ? absint( $data['batch_id'] ) : 0;

	if ( ! wp_verify_nonce( $data['_wpnonce'] ?? '', 'affwp-download-payout-batch-csv-' . $batch_id ) ) {
		wp_die(
			esc_html__( 'Security check failed.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 403 )
		);
	}

	/** This filter is documented in includes/payouts/class-batch-payout-processor.php */
	if ( ! current_user_can( apply_filters( 'affwp_export_capability', 'export_affiliate_data' ) ) ) {
		wp_die(
			esc_html__( 'You do not have permission to export data.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 403 )
		);
	}

	$batch = $batch_id ? affwp_get_payout_batch( $batch_id ) : false;

	if ( ! $batch ) {
		wp_die(
			esc_html__( 'Invalid batch ID.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 404 )
		);
	}

	$output = affwp_build_payout_batch_csv( $batch_id );

	if ( '' === $output ) {
		wp_die(
			esc_html__( 'This batch has no manual payouts to export.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 404 )
		);
	}

	$filename = sprintf( 'affiliate-wp-payout-batch-%d-%s.csv', $batch_id, gmdate( 'm-d-Y' ) );

	nocache_headers();
	header( 'Content-Type: text/csv; charset=utf-8' );
	header( 'Content-Disposition: attachment; filename=' . $filename );
	header( 'Expires: 0' );

	echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw CSV stream.
	exit;
}
add_action( 'affwp_download_payout_batch_csv', 'affwp_download_payout_batch_csv' );
