<?php
/**
 * Payout Functions
 *
 * @package     AffiliateWP
 * @subpackage  Core
 * @copyright   Copyright (c) 2017, Sandhills Development, LLC
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       1.9
 *
 * phpcs:disable PEAR.Functions.FunctionCallSignature.EmptyLine
 * phpcs:disable PEAR.Functions.FunctionCallSignature.FirstArgumentPosition
 */

// Shared payout-preview rendering (used by the Pay Affiliates screen and the Referrals bulk-pay drawer).
require_once AFFILIATEWP_PLUGIN_DIR . 'includes/admin/payouts/preview-render.php';

/**
 * Retrieves a payout object.
 *
 * @since 1.9
 *
 * @param int|\AffWP\Affiliate\Payout $payout Payout ID or object.
 * @return \AffWP\Affiliate\Payout|false Payout object if found, otherwise false.
 */
function affwp_get_payout( $payout = 0 ) {

	/**
	 * Filters the payout ID or object before it is retrieved.
	 *
	 * Passing a non-null value in the hook callback will effectively preempt retrieving
	 * the payout from the database, returning the passed value instead.
	 *
	 * @since 2.2.2
	 *
	 * @param null                        $payout_before Value to short circuit retrieval of the payout.
	 * @param int|\AffWP\Affiliate\Payout $payout        Payout ID or object passed to affwp_get_payout().
	 */
	$payout_before = apply_filters( 'affwp_get_payout_before', null, $payout );

	if ( null !== $payout_before ) {
		return $payout_before;
	}

	if ( is_object( $payout ) && isset( $payout->payout_id ) ) {
		$payout_id = $payout->payout_id;
	} elseif ( is_numeric( $payout ) ) {
		$payout_id = absint( $payout );
	} else {
		return false;
	}

	return affiliate_wp()->affiliates->payouts->get_object( $payout_id );
}

/**
 * Adds a payout record.
 *
 * @since 1.9
 * @since 2.27.4 Updated so we don't create payouts for referrals that already
 *               have one (https://github.com/awesomemotive/affiliate-wp/pull/5289/files).
 *
 * @param array $args {
 *     Optional. Arguments for adding a new payout record. Default empty array.
 *
 *     @type int          $affiliate_id  Affiliate ID.
 *     @type int|array    $referrals     Referral ID or array of IDs.
 *     @type string       $amount        Payout amount.
 *     @type string       $payout_method Payout method.
 *     @type string       $status        Payout status. Default 'paid'.
 *     @type string|array $date          Payout date.
 * }
 *
 * @return int|false The ID for the newly-added payout, otherwise false.
 */
function affwp_add_payout( $args = [] ) {

	if (

		// If we don't have any referrals to create them for.
		empty( $args['referrals'] )

			// We don't have an affiliate to associate the payouts with.
			|| empty( $args['affiliate_id'] )

				// The affiliate doesn't exist in the system (no valid status).
				|| false === affwp_get_affiliate_status( $args['affiliate_id'] )
		) {

		// Don't create payouts in these instances.
		return false;
	}

	/*
	 * Block re-creation only for payouts in terminal-success (`paid`) or
	 * in-flight (`processing`) states. For `failed` and other non-success
	 * states the historical attachment shouldn't stop a fresh attempt —
	 * silently no-op'ing in those cases left the referral pointing at the
	 * stale payout (e.g. a failed PayPal batch), so the list table rendered
	 * the wrong payout method after a subsequent Mark-as-Paid.
	 *
	 * Statuses observed in 2.33.0: paid, processing, failed, unpaid.
	 */
	$has_blocking_payout = function ( $referral_id ) {
		$existing_ids = affiliate_wp()->affiliates->payouts->get_payout_ids_by_referrals(
			[ $referral_id ],
			'' // any status
		);

		foreach ( $existing_ids as $existing_id ) {
			$existing = affwp_get_payout( $existing_id );
			if ( ! $existing ) {
				continue;
			}
			if ( in_array( $existing->status, array( 'paid', 'processing' ), true ) ) {
				return true;
			}
		}

		return false;
	};

	// When someone sets this to a single referral.
	if ( is_numeric( $args['referrals'] ) ) {
		if ( $has_blocking_payout( $args['referrals'] ) ) {
			return false; // Already paid or in-flight; skip.
		}
	}

	// When someone passes an array of referrals.
	if ( is_array( $args['referrals'] ) ) {

		$args['referrals'] = array_filter(
			$args['referrals'],

			// Filter out any referrals that already have a paid/processing payout.
			function ( $referral_id ) use ( $has_blocking_payout ) {

				if ( false === affwp_get_referral_status( $referral_id ) ) {
					return false; // Not a referral, remove from the list anyways.
				}

				// Keep the referral if it has no paid/processing payout attached.
				return ! $has_blocking_payout( $referral_id );
			}
		);
	}

	if ( empty( $args['referrals'] ) ) {
		return false; // No need to create payouts for (0) referrals (may have been filtered out above).
	}

	$payout = affiliate_wp()->affiliates->payouts->add( $args );

	if ( $payout ) {
		return $payout;
	}

	return false;
}

/**
 * Retrieves the registered single-referral payout handlers.
 *
 * Each handler pays one referral via a specific payout method and is responsible
 * for moving the money (where a rail exists) *and* recording the payout, so the
 * invariant `status = paid  =>  a payout record exists` always holds. The map is
 * keyed by the resolved payout-method slug returned by
 * {@see affwp_get_affiliate_payout_method()} (e.g. `paypal`, `stripe`,
 * `store_credit`, `manual`).
 *
 * @since 2.33.0
 *
 * @return array Map of method slug => callable( int $referral_id ) : true|\WP_Error.
 */
function affwp_get_single_referral_payout_handlers() {

	/**
	 * Filters the single-referral payout handlers.
	 *
	 * Methods register a callback that pays one referral. The callback receives the
	 * referral ID and must return true on success or a \WP_Error on failure, and must
	 * create the payout record itself (see invariant above).
	 *
	 * @since 2.33.0
	 *
	 * @param array $handlers Map of payout-method slug => callable.
	 */
	return (array) apply_filters( 'affwp_single_referral_payout_handlers', array() );
}

/**
 * Whether a payout method can pay a single referral directly.
 *
 * Used by the Referrals list table to decide between the primary "Pay" action and
 * the "Record as paid" reconciliation fallback.
 *
 * @since 2.33.0
 *
 * @param string $method Payout-method slug.
 * @return bool True if a single-referral handler is registered for the method.
 */
function affwp_payout_method_supports_single_referral( $method ) {
	$handlers = affwp_get_single_referral_payout_handlers();
	return ! empty( $handlers[ $method ] ) && is_callable( $handlers[ $method ] );
}

/**
 * Determines whether a single referral can be paid right now, and if not, why.
 *
 * Shared by the single and bulk "Pay" confirm modals so they give the same verdict
 * and wording. Covers the hard blocks the payment would hit immediately: not unpaid,
 * Stripe not connected / payouts disabled, PayPal with no payment email. (A PayPal
 * email being present is not validated — it can still fail at PayPal — so that's not
 * treated as a block.) Method-level skips like a prior failed payout are decided by
 * {@see affwp_compute_eligible_payouts()}, not here.
 *
 * @since 2.33.0
 *
 * @param int $referral_id Referral ID.
 * @return array { @type bool $payable, @type string $reason }
 */
function affwp_get_referral_payability( $referral_id ) {

	$referral = affwp_get_referral( $referral_id );

	if ( ! is_a( $referral, '\AffWP\Referral' ) ) {
		return array( 'payable' => false, 'code' => 'invalid', 'reason' => __( 'Referral not found.', 'affiliate-wp' ) );
	}

	if ( 'unpaid' !== $referral->status ) {
		return array( 'payable' => false, 'code' => 'already_paid', 'reason' => __( 'Already paid.', 'affiliate-wp' ) );
	}

	/*
	 * Already attached to a payout (a prior run that's still processing, or one that failed and
	 * needs resolving). The compute engine excludes these from new payouts at the query level, so
	 * the single-pay path must too — otherwise it offers a referral that can't be re-paid and the
	 * attempt fails downstream. Resolution lives on the payout itself (retry/delete), not here.
	 */
	if ( ! empty( $referral->payout_id ) ) {
		return array(
			'payable' => false,
			'code'    => 'previous_payout_failed',
			'reason'  => __( 'This referral is already attached to a payout. Resolve that payout to pay it again.', 'affiliate-wp' ),
		);
	}

	$method = affwp_get_affiliate_usable_payout_method( $referral->affiliate_id );

	switch ( $method ) {
		case 'stripe':
			if ( function_exists( 'affwp_stripe_payouts_check_affiliate_payment_capability' ) ) {
				try {
					$cap = affwp_stripe_payouts_check_affiliate_payment_capability( $referral->affiliate_id );
				} catch ( \Throwable $e ) {
					$cap = null;
				}

				if ( is_wp_error( $cap ) ) {
					return array( 'payable' => false, 'code' => 'not_connected', 'reason' => $cap->get_error_message() );
				}

				if ( is_array( $cap ) && ( empty( $cap['can_receive_transfers'] ) || empty( $cap['payouts_enabled'] ) ) ) {
					$reason = ! empty( $cap['error_message'] )
						? $cap['error_message']
						: __( "Stripe account isn't ready to receive payouts.", 'affiliate-wp' );
					return array( 'payable' => false, 'code' => 'stripe_not_ready', 'reason' => $reason );
				}
			}
			break;

		case 'paypal':
			if ( empty( affwp_get_affiliate_payment_email( $referral->affiliate_id ) ) ) {
				return array( 'payable' => false, 'code' => 'no_paypal_email', 'reason' => __( 'No PayPal payment email.', 'affiliate-wp' ) );
			}
			break;

		case 'payouts-service':
			// The Payouts Service is being retired; the new per-referral Pay flow doesn't
			// route through it. Surface a clear next step instead of a silent failure.
			return array(
				'payable' => false,
				'code'    => 'payouts_service_retired',
				'reason'  => __( 'The Payouts Service is being retired. Switch this affiliate to another payout method.', 'affiliate-wp' ),
			);
	}

	return array( 'payable' => true, 'code' => '', 'reason' => '' );
}

/**
 * Pays a single referral using the affiliate's effective payout method.
 *
 * The one central entry point for "pay this one referral by its method". Resolves
 * the affiliate's method, dispatches to the registered handler, and returns its
 * result. Each handler moves the money (PayPal/Stripe/Store Credit) or records a
 * manual payout, and always creates the payout record.
 *
 * @since 2.33.0
 *
 * @param int $referral_id Referral ID.
 * @return true|\WP_Error True on success, \WP_Error on failure.
 */
function affwp_pay_referral( $referral_id = 0 ) {

	$referral = affwp_get_referral( $referral_id );

	if ( ! is_a( $referral, '\AffWP\Referral' ) ) {
		return new \WP_Error( 'invalid_referral', __( 'The specified referral does not exist.', 'affiliate-wp' ) );
	}

	if ( 'unpaid' !== $referral->status ) {
		return new \WP_Error( 'referral_not_unpaid', __( 'Only unpaid referrals can be paid.', 'affiliate-wp' ) );
	}

	if ( empty( $referral->affiliate_id ) || false === affwp_get_affiliate_status( $referral->affiliate_id ) ) {
		return new \WP_Error( 'invalid_affiliate', __( 'There is no valid affiliate connected to this referral.', 'affiliate-wp' ) );
	}

	$method   = affwp_get_affiliate_payout_method( $referral->affiliate_id );
	$handlers = affwp_get_single_referral_payout_handlers();

	if ( empty( $handlers[ $method ] ) || ! is_callable( $handlers[ $method ] ) ) {
		return new \WP_Error(
			'unsupported_payout_method',
			/* translators: %s: payout method slug */
			sprintf( __( 'The "%s" payout method cannot pay a single referral directly.', 'affiliate-wp' ), $method )
		);
	}

	/**
	 * Fires before a single referral is paid via its resolved payout method.
	 *
	 * @since 2.33.0
	 *
	 * @param int    $referral_id Referral ID.
	 * @param string $method      Resolved payout-method slug.
	 */
	do_action( 'affwp_pre_pay_referral', $referral->ID, $method );

	$result = call_user_func( $handlers[ $method ], $referral->ID );

	/**
	 * Fires after a single referral pay attempt completes.
	 *
	 * @since 2.33.0
	 *
	 * @param int             $referral_id Referral ID.
	 * @param string          $method      Resolved payout-method slug.
	 * @param true|\WP_Error  $result      Handler result.
	 */
	do_action( 'affwp_paid_referral', $referral->ID, $method, $result );

	return $result;
}

/**
 * Pays a single referral via the Manual method.
 *
 * Records a manual payout and flips the referral to paid. This is also what the
 * "Record as paid" reconciliation does: no money moves through the store, but a
 * payout record is always created so paid status stays traceable.
 *
 * @since 2.33.0
 *
 * @param int $referral_id Referral ID.
 * @return true|\WP_Error
 */
function affwp_pay_referral_manual( $referral_id ) {

	$referral = affwp_get_referral( $referral_id );

	if ( ! is_a( $referral, '\AffWP\Referral' ) ) {
		return new \WP_Error( 'invalid_referral', __( 'The specified referral does not exist.', 'affiliate-wp' ) );
	}

	$payout_id = affwp_add_payout(
		array(
			'affiliate_id'  => $referral->affiliate_id,
			'referrals'     => $referral->ID,
			'amount'        => $referral->amount,
			'payout_method' => 'manual',
		)
	);

	if ( ! $payout_id ) {
		return new \WP_Error( 'payout_failed', __( 'A payout could not be created for this referral.', 'affiliate-wp' ) );
	}

	return true;
}
add_filter(
	'affwp_single_referral_payout_handlers',
	function ( $handlers ) {
		$handlers['manual'] = 'affwp_pay_referral_manual';
		return $handlers;
	}
);

/**
 * Pays a single referral via Store Credit.
 *
 * Creating a store-credit payout flips the referral to paid, which fires the Store
 * Credit integration's credit-on-paid hook to add the amount to the affiliate's
 * store balance (WooCommerce / EDD). Unlike PayPal/Stripe, the credit gate is left
 * enabled so the balance is actually credited.
 *
 * @since 2.33.0
 *
 * @param int $referral_id Referral ID.
 * @return true|\WP_Error
 */
function affwp_pay_referral_store_credit( $referral_id ) {

	$referral = affwp_get_referral( $referral_id );

	if ( ! is_a( $referral, '\AffWP\Referral' ) ) {
		return new \WP_Error( 'invalid_referral', __( 'The specified referral does not exist.', 'affiliate-wp' ) );
	}

	$payout_id = affwp_add_payout(
		array(
			'affiliate_id'  => $referral->affiliate_id,
			'referrals'     => $referral->ID,
			'amount'        => $referral->amount,
			'payout_method' => 'store_credit',
		)
	);

	if ( ! $payout_id ) {
		return new \WP_Error( 'payout_failed', __( 'A store credit payout could not be created for this referral.', 'affiliate-wp' ) );
	}

	return true;
}
add_filter(
	'affwp_single_referral_payout_handlers',
	function ( $handlers ) {
		if ( affwp_is_payout_method_enabled( 'store_credit' ) ) {
			$handlers['store_credit'] = 'affwp_pay_referral_store_credit';
		}
		return $handlers;
	}
);

/**
 * Renders the shared "Pay" confirm modal.
 *
 * Used by both the Referrals list table and the referral edit screen. Uses the
 * Alpine modal store (focus trap / Escape / backdrop / ARIA). The "Pay" actions
 * open it via affwp-list-row-actions.js, which fetches the method-resolved details
 * (affwp_pay_referral_preview) into the store data and pays via affwp_pay_referral
 * on confirm.
 *
 * @since 2.33.0
 */
function affwp_render_pay_referral_modal() {

	if ( ! function_exists( 'affwp_modal' ) ) {
		return;
	}

	$cancel = affwp_render_secondary_button(
		__( 'Cancel', 'affiliate-wp' ),
		array(
			// 'md' (py-2 px-3) matches the hand-rolled confirm button's py-2 so both
			// footer buttons share the same height. Default 'sm' (py-1.5) sat shorter.
			'size'       => 'md',
			'attributes' => array(
				'type'   => 'button',
				'@click' => '$store.modals.close(\'affwp-pay-referral\')',
			),
		)
	);

	$confirm = affwp_render_primary_button(
		__( 'Pay', 'affiliate-wp' ),
		array(
			'alpine_text'  => sprintf(
				"modalData.isProcessing ? '%s' : ( modalData.confirm_label || '%s' )",
				esc_js( __( 'Paying…', 'affiliate-wp' ) ),
				esc_js( __( 'Pay', 'affiliate-wp' ) )
			),
			'dynamic_icon' => array(
				'name'           => 'loading',
				'show_condition' => 'modalData.isProcessing',
			),
			'attributes'   => array(
				'type'      => 'button',
				'x-show'    => '!modalData.blocked',
				':disabled' => 'modalData.isLoading || modalData.isProcessing',
				'@click'    => "window.dispatchEvent(new CustomEvent('affwp:pay-referral-confirm', { detail: { id: modalData.referral_id } }))",
			),
		)
	);

	$loading              = esc_attr__( 'Loading', 'affiliate-wp' );
	$add_test_funds_label = esc_html__( 'Add test funds', 'affiliate-wp' );

	// Fixed summary rows (static label => Alpine field). The destination row is conditional, handled in the template below.
	$rows = '';
	foreach (
		array(
			esc_html__( 'Affiliate', 'affiliate-wp' ) => 'modalData.affiliate',
			esc_html__( 'Amount', 'affiliate-wp' )    => 'modalData.amount',
			esc_html__( 'Method', 'affiliate-wp' )     => 'modalData.method_label',
		) as $label => $field
	) {
		$rows .= sprintf(
			'<div class="flex items-center justify-between gap-4 py-2"><dt class="text-gray-500">%1$s</dt><dd class="font-medium text-gray-900 text-right" x-text="%2$s"></dd></div>',
			$label,
			esc_attr( $field )
		);
	}

	$content = <<<HTML
		<template x-if="isLoading">
			<div class="flex min-h-[200px] items-center justify-center"><span class="inline-block size-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-500" role="status" aria-label="{$loading}"></span></div>
		</template>
		<template x-if="!isLoading">
			<div class="space-y-4">
				<dl class="divide-y divide-gray-100 text-sm">
					{$rows}
					<div class="flex items-center justify-between gap-4 py-2" x-show="modalData.destination_value">
						<dt class="text-gray-500" x-text="modalData.destination_label"></dt>
						<dd class="font-medium text-gray-900 text-right break-all" x-text="modalData.destination_value"></dd>
					</div>
				</dl>
				<p class="text-sm text-gray-500" x-show="!modalData.blocked" x-text="modalData.warning"></p>
				<div class="rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-900 wrap-break-word" x-show="modalData.account_warning">
					<span x-text="modalData.account_warning"></span>
					<a x-show="modalData.action_url" :href="modalData.action_url" target="_blank" rel="noopener noreferrer" class="ml-1 font-medium underline hover:no-underline" x-text="modalData.action_label"></a>
					<div class="mt-2" x-show="modalData.can_add_test_funds">
						<button type="button" @click="addTestFunds( \$el )" class="inline-flex items-center rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 disabled:opacity-50">{$add_test_funds_label}</button>
					</div>
				</div>
				<p class="text-sm text-red-600" x-show="modalData.error" x-text="modalData.error"></p>
			</div>
		</template>
HTML;

	affwp_modal(
		array(
			'id'            => 'affwp-pay-referral',
			'title'         => __( 'Pay referral', 'affiliate-wp' ),
			'alpine_title'  => sprintf( "modalData.title || '%s'", esc_js( __( 'Pay referral', 'affiliate-wp' ) ) ),
			'size'          => 'sm',
			'rounded'       => '2xl',
			'content'       => $content,
			'custom_footer' => '<div class="flex justify-end gap-2" x-show="!modalData.isLoading">' . $cancel . $confirm . '</div>',
			'attributes'    => array(
				'x-data' => '{ get modalData() { const m = $store.modals.registry["affwp-pay-referral"]; return ( m && m.data ) || {}; }, get isLoading() { return this.modalData.isLoading || false; }, addTestFunds( btn ) { const d = this.modalData; if ( ! d || ! d.test_funds_nonce ) { return; } btn.disabled = true; fetch( window.ajaxurl, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: new URLSearchParams( { action: "affwp_stripe_create_test_charge", nonce: d.test_funds_nonce, amount: d.test_funds_amount || 50 } ).toString() } ).then( ( r ) => r.json() ).then( ( res ) => { if ( res && res.success ) { window.location.reload(); return; } btn.disabled = false; window.alert( ( res && res.data && res.data.message ) || ' . wp_json_encode( __( 'Could not add test funds.', 'affiliate-wp' ) ) . ' ); } ).catch( () => { btn.disabled = false; } ); } }',
			),
		)
	);
}

/**
 * Deletes a payout.
 *
 * @since 1.9
 *
 * @param int|\AffWP\Affiliate\Payout $payout Payout ID or object.
 * @return bool True if the payout was successfully deleted, otherwise false.
 */
function affwp_delete_payout( $payout ) {
	if ( ! $payout = affwp_get_payout( $payout ) ) {
		return false;
	}

	// Handle updating paid referrals to unpaid.
	if ( $payout && in_array( $payout->status, [ 'paid', 'processing' ] ) ) {
		$referrals = affiliate_wp()->affiliates->payouts->get_referral_ids( $payout );

		foreach ( $referrals as $referral_id ) {
			if ( 'paid' == affwp_get_referral_status( $referral_id ) ) {
				affwp_set_referral_status( $referral_id, 'unpaid' );
			}
		}
	}

	if ( affiliate_wp()->affiliates->payouts->delete( $payout->ID, 'payout' ) ) {
		/**
		 * Fires immediately after a payout has been deleted.
		 *
		 * @since 1.9
		 *
		 * @param int $payout_id Payout ID.
		 */
		do_action( 'affwp_delete_payout', $payout->ID );

		return true;
	}

	return false;
}

/**
 * Retrieves the referrals associated with a payout.
 *
 * @since 1.9
 *
 * @param int|\AffWP\Affiliate\Payout $payout Payout ID or object.
 * @return array|false List of referral objects associated with the payout, otherwise false.
 */
function affwp_get_payout_referrals( $payout = 0 ) {
	if ( ! $payout = affwp_get_payout( $payout ) ) {
		return false;
	}

	$referrals = affiliate_wp()->affiliates->payouts->get_referral_ids( $payout );

	return array_map( 'affwp_get_referral', $referrals );
}

/**
 * Retrieves the status label for a payout.
 *
 * @since 1.6
 * @since 2.6.1 The `$payout` parameter was renamed to `$payout_or_status` and now also accepts
 *              a payout status.
 *
 * @param int|\AffWP\Affiliate\Payout|string $payout_or_status Payout ID, object, or status.
 * @return string|false The localized version of the payout status label, otherwise false.
 */
function affwp_get_payout_status_label( $payout_or_status ) {

	if ( is_string( $payout_or_status ) ) {
		$payout = null;
		$status = $payout_or_status;
	} else {
		$payout = affwp_get_payout( $payout_or_status );

		if ( isset( $payout->status ) ) {
			$status = $payout->status;
		} else {
			return false;
		}
	}

	$statuses = affwp_get_payout_statuses();
	$label    = array_key_exists( $status, $statuses ) ? $statuses[ $status ] : $statuses['paid'];

	/**
	 * Filters the payout status label.
	 *
	 * @since 1.9
	 * @since 2.6.1 Added the `$status` parameter
	 *
	 * @param string                  $label  A localized version of the payout status label.
	 * @param \AffWP\Affiliate\Payout $payout Payout object.
	 * @param string                  $status Payout status.
	 */
	return apply_filters( 'affwp_payout_status_label', $label, $payout, $status );
}

/**
 * Retrieves the list of payout statuses and corresponding labels.
 *
 * @since 2.6.1
 *
 * @return array Key/value pairs of statuses where key is the status and the value is the label.
 */
function affwp_get_payout_statuses() {
	return [
		'processing' => _x( 'Processing', 'payout', 'affiliate-wp' ),
		'paid'       => _x( 'Paid', 'payout', 'affiliate-wp' ),
		'failed'     => __( 'Failed', 'affiliate-wp' ),
	];
}

/**
 * Maps a referral/payout status to a status-badge style variant, aligned with the admin badge
 * variants in includes/utils/helpers/badges.php so the frontend dashboard pills read consistently.
 *
 * The admin badge helpers (affwp_get_referral_status_badge / affwp_get_payout_status_badge) render
 * Tailwind markup, which isn't loaded on the classic affiliate-area frontend; the dashboard templates
 * use this variant with the BEM `.affwp-badge--{variant}` classes in assets/css/forms.css instead.
 *
 * @since 2.34.0
 *
 * @param string $status Status key (e.g. paid, unpaid, pending, processing, rejected, failed).
 * @return string Variant: success, warning, danger, info, or neutral.
 */
function affwp_get_status_badge_variant( $status ) {
	$variants = array(
		'paid'       => 'success',
		'completed'  => 'success',
		'unpaid'     => 'warning',
		'partial'    => 'warning',
		'pending'    => 'info',
		'processing' => 'info',
		'rejected'   => 'danger',
		'failed'     => 'danger',
	);

	$status  = strtolower( (string) $status );
	$variant = isset( $variants[ $status ] ) ? $variants[ $status ] : 'neutral';

	/**
	 * Filters the status-badge variant for a status key.
	 *
	 * @since 2.34.0
	 *
	 * @param string $variant Variant (success|warning|danger|info|neutral).
	 * @param string $status  Status key.
	 */
	return apply_filters( 'affwp_status_badge_variant', $variant, $status );
}

/**
 * Retrieves the list of payout methods and corresponding labels.
 *
 * @since 2.4
 *
 * @return array Key/value pairs of payout methods where key is the payout method and the value is the label.
 */
function affwp_get_payout_methods() {

	$payout_methods = [
		'manual' => __( 'Manual', 'affiliate-wp' ),
	];

	/**
	 * Filters the payout methods.
	 *
	 * @since 2.4
	 *
	 * @param array $payout_methods Payout methods.
	 */
	return apply_filters( 'affwp_payout_methods', $payout_methods );
}

/**
 * Returns a map of method identifier → clean display label, limited to enabled methods.
 *
 * Some method add-ons register their `affwp_get_payout_methods()` label as an HTML instructional
 * string (e.g. "PayPal - <a href=…>Enable and/or configure…</a>") when the method isn't
 * configured yet. That's useful for legacy list-table rendering but breaks selects and column
 * display. This helper gives UIs a clean, select-safe set of options.
 *
 * @since 2.33.0
 *
 * @param string $context Optional. `'admin'` (default) or `'affiliate'`. When set to `'affiliate'`,
 *                        methods that aren't meaningful for an affiliate to self-select (e.g. the
 *                        admin-only Manual method) are omitted.
 * @return array<string,string>
 */
function affwp_get_payout_methods_for_display( $context = 'admin' ) {
	$all     = affwp_get_payout_methods();
	$enabled = affwp_get_enabled_payout_methods();
	$out     = array();

	// Manual is admin-only: it represents "the admin pays you outside the system,"
	// which an affiliate has no reason to self-select.
	$admin_only = array( 'manual' );

	/*
	 * Methods the site admin has opted to hide from the affiliate-facing selector but keep
	 * available to assign via the admin Edit Affiliate screen. Populated on 2.33.0 upgrade
	 * for sites that had the legacy Store Credit `store-credit-change-payment-method = false`
	 * setting; extensible via the filter below.
	 *
	 * Only consulted in the affiliate context — admin assignment surfaces always see the
	 * full enabled set (that's why the hidden mechanism exists at all). Computing the
	 * default-method carve-out below would be wasted work on admin pages, so the whole
	 * branch is gated by context.
	 */
	if ( 'affiliate' === $context ) {
		$hidden = get_option( 'affwp_hidden_affiliate_payout_methods', array() );

		/*
		 * Carve-out: when the site default itself is one of the hidden methods, un-hide it for
		 * the affiliate picker. A method that's the fallback for every affiliate can't be
		 * "admin-only assignment" simultaneously — that combination makes the picker show a
		 * fake "(default)" row labeled like the method but actually saving an empty value,
		 * which confuses affiliates ("I picked Store Credit, why didn't it save?"). Only the
		 * hidden-option carve-out applies here; Manual stays hardcoded admin-only above because
		 * its semantic is genuinely admin-driven (offline payment).
		 */
		if ( is_array( $hidden ) && ! empty( $hidden ) ) {
			$site_default = affwp_get_default_payout_method();
			if ( ! empty( $site_default ) ) {
				$hidden = array_values( array_diff( $hidden, array( $site_default ) ) );
			}

			if ( ! empty( $hidden ) ) {
				$admin_only = array_merge( $admin_only, $hidden );
			}
		}
	}

	foreach ( $enabled as $key ) {
		if ( ! isset( $all[ $key ] ) ) {
			continue;
		}

		if ( 'affiliate' === $context && in_array( $key, $admin_only, true ) ) {
			continue;
		}

		$out[ $key ] = wp_strip_all_tags( $all[ $key ] );
	}

	// Alphabetical by label so dropdown order isn't dictated by arbitrary registration order.
	asort( $out, SORT_NATURAL | SORT_FLAG_CASE );

	return $out;
}

/**
 * Returns installed-but-disabled payout methods as a clean identifier → short-label map.
 *
 * These are rails that are registered (the add-on ships them) but not currently enabled —
 * e.g. Stripe or PayPal Payouts switched off in Settings → Payouts. Admin surfaces use this
 * to advertise "you could turn this on" without it being selectable. Methods that aren't
 * registered at all (never installed, or sunset) never appear here.
 *
 * @since 2.33.0
 *
 * @return array<string,string>
 */
function affwp_get_disabled_payout_methods_for_display() {
	$enabled = affwp_get_enabled_payout_methods();
	$out     = array();

	foreach ( affwp_get_payout_methods() as $key => $label ) {
		if ( in_array( $key, $enabled, true ) ) {
			continue;
		}

		$out[ $key ] = affwp_get_payout_method_short_label( $key, $label );
	}

	/**
	 * Filters the installed-but-disabled payout methods advertised on admin payout surfaces.
	 *
	 * The default set is every method registered on `affwp_payout_methods` that isn't enabled
	 * (Stripe and PayPal register unconditionally, so they're caught here). Methods that DON'T
	 * register while disabled — Store Credit only registers when its setting is on — opt in
	 * through this filter, gated by their own environment requirements (Store Credit needs an
	 * active WooCommerce or EDD integration). Admin discovery only; the affiliate picker never
	 * advertises disabled methods.
	 *
	 * @since 2.33.0
	 *
	 * @param array<string,string> $out     Map of disabled method key => short label.
	 * @param string[]             $enabled Currently-enabled method keys.
	 */
	$out = apply_filters( 'affwp_disabled_payout_methods_for_display', $out, $enabled );

	// A method opted in via the filter must never be one that's actually enabled.
	$out = array_diff_key( $out, array_flip( $enabled ) );

	asort( $out, SORT_NATURAL | SORT_FLAG_CASE );

	return $out;
}

/**
 * Returns a chip-sized name for a payout method, stripping the instructional label.
 *
 * Disabled rails register an HTML instructional label ("Stripe - <a…>Enable and/or
 * configure…</a> to enable this payout method"). The rail name is everything before the
 * first " - ", so the chip reads "Stripe" rather than the full sentence.
 *
 * @since 2.33.0
 *
 * @param string      $payout_method Method identifier.
 * @param string|null $label         Optional. Raw registered label to shorten. Looked up when null.
 * @return string
 */
function affwp_get_payout_method_short_label( $payout_method, $label = null ) {
	if ( null === $label ) {
		$methods = affwp_get_payout_methods();
		$label   = isset( $methods[ $payout_method ] ) ? $methods[ $payout_method ] : $payout_method;
	}

	$clean = wp_strip_all_tags( $label );
	$dash  = strpos( $clean, ' - ' );

	return false === $dash ? $clean : trim( substr( $clean, 0, $dash ) );
}

/**
 * Returns the Settings → Payouts URL a disabled-method chip links to.
 *
 * @since 2.33.0
 *
 * @param string $payout_method Method identifier.
 * @return string
 */
function affwp_get_payout_method_settings_url( $payout_method ) {
	$anchors = array(
		'stripe'       => 'payouts#stripe',
		'paypal'       => 'payouts#paypal',
		'store_credit' => 'payouts#store-credit',
	);

	$tab = isset( $anchors[ $payout_method ] ) ? $anchors[ $payout_method ] : 'payouts';

	/**
	 * Filters the Settings → Payouts URL a payout-method discovery chip links to.
	 *
	 * @since 2.33.0
	 *
	 * @param string $url           Settings URL for the method's configuration section.
	 * @param string $payout_method Method identifier.
	 */
	return apply_filters(
		'affwp_payout_method_settings_url',
		affwp_admin_url( 'settings', array( 'tab' => $tab ) ),
		$payout_method
	);
}

/**
 * Whether the interactive affiliate payout-method picker is presented.
 *
 * True only when affiliates may choose their own method AND there are at least two
 * selectable choices. A site default that is NOT itself affiliate-selectable (e.g.
 * default = Manual, or the default is hidden) adds a "use the site default" choice on
 * top of the concrete methods, mirroring affwp_get_affiliate_picker_options()'s
 * placeholder row — so "default = Manual + one method (e.g. PayPal)" is a real two-way
 * choice and the picker shows. With fewer than two choices there's nothing to pick, so
 * no picker (and no Alpine `method` scope) renders and surfaces fall back to the static
 * effective method.
 *
 * Single source of truth for this condition: the Affiliate Area template, the Portal,
 * and per-method integration sections (e.g. Stripe) all gate on it, so they can't
 * disagree about whether the reactive picker exists.
 *
 * @since 2.33.0
 *
 * @return bool
 */
function affwp_affiliate_payout_picker_is_visible() {

	if ( ! function_exists( 'affwp_affiliates_can_select_payout_method' )
		|| ! affwp_affiliates_can_select_payout_method()
		|| ! function_exists( 'affwp_get_payout_methods_for_display' )
	) {
		return false;
	}

	$selectable = affwp_get_payout_methods_for_display( 'affiliate' );
	$choices    = count( $selectable );

	// The "use site default" placeholder is a distinct choice when the default isn't itself selectable.
	$default = function_exists( 'affwp_get_default_payout_method' ) ? affwp_get_default_payout_method() : '';
	if ( '' !== $default && ! isset( $selectable[ $default ] ) ) {
		$choices++;
	}

	return $choices >= 2;
}

/**
 * Returns a human-readable label for a failure class.
 *
 * Used by the drawer's Next Steps card and the small in-row chip on
 * failed batch-children. The classifier enum values (snake_case keys)
 * never reach the UI; this helper translates them to display strings.
 *
 * @since 2.33.0
 *
 * @param string $class One of the AffWP\Payouts\Failure_Class constants.
 * @return string Human-readable label, or empty string for unknown input.
 */
function affwp_get_payout_failure_class_label( $class ) {
	$labels = array(
		'transient'                 => __( 'Temporary error', 'affiliate-wp' ),
		'affiliate_action_required' => __( 'Affiliate action required', 'affiliate-wp' ),
		'admin_action_required'     => __( 'Admin action required', 'affiliate-wp' ),
		'data_error'                => __( 'Data error', 'affiliate-wp' ),
		'unknown'                   => __( 'Unable to classify', 'affiliate-wp' ),
	);

	return $labels[ (string) $class ] ?? '';
}

/**
 * Builds the Next Steps card payload for a failed payout.
 *
 * Reads the payout's `failure_class` + method and returns a self-describing
 * structure consumed by the drawer JS:
 *
 *   [
 *     'headline'       => 'Andrew hasn't completed Stripe Connect',
 *     'description'    => 'They need to finish onboarding before this payout can succeed.',
 *     'primary_action' => [
 *       'label' => 'Send connection request',
 *       'kind'  => 'email_affiliate', // 'retry' | 'email_affiliate' | 'open_url' | 'edit_affiliate'
 *       'url'   => '',                // populated for 'open_url' / 'edit_affiliate'
 *     ],
 *   ]
 *
 * Returns NULL when the payout isn't failed or has no classified failure
 * class (the drawer will hide the card in that case).
 *
 * The `email_affiliate` kind is a Phase B stub: the kind is exposed in the
 * payload but Phase C wires the AJAX handler that actually sends the email.
 * Until then, clicking the button shows a "Coming soon" toast.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout         Payout object.
 * @param string                  $affiliate_name Affiliate display name for messaging.
 * @return array|null Next-steps payload, or NULL when not applicable.
 */
/**
 * Counts how many of a payout's referrals are in PayPal's UNCLAIMED state.
 *
 * Used by the Next Steps builder to detect "processing-but-stuck" payouts
 * where the affiliate must verify their PayPal email or the funds will be
 * returned. Cheap scan over the payout's referrals; we're only ever talking
 * about a handful of rows per payout.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout Payout object.
 * @return int Number of UNCLAIMED referrals.
 */
function affwp_count_payout_unclaimed_items( $payout ) {

	$referral_ids = affiliate_wp()->affiliates->payouts->get_referral_ids( $payout );
	if ( empty( $referral_ids ) ) {
		return 0;
	}

	$count = 0;
	foreach ( $referral_ids as $rid ) {
		$item_status = (string) affwp_get_referral_meta( (int) $rid, '_paypal_transaction_status', true );
		if ( 'UNCLAIMED' === $item_status ) {
			$count++;
		}
	}

	return $count;
}

/**
 * Returns IDs of referrals the payout claims to own but that no longer
 * exist in the referrals table.
 *
 * The payout row's `referrals` column carries the IDs that were linked
 * at payout-creation time, but referrals can be hard-deleted from the
 * Referrals list later. When that happens, the drawer's referral
 * lookup returns nothing and the empty-state copy reads "No referrals
 * are attached," which is misleading: one or more were attached, then
 * deleted. This helper exposes those orphaned IDs so the drawer can
 * say "Referral #508 no longer exists" instead.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout Payout object.
 * @return array Integer referral IDs missing from the referrals table.
 */
function affwp_get_payout_deleted_referral_ids( $payout ) {

	$referral_ids = affiliate_wp()->affiliates->payouts->get_referral_ids( $payout );
	if ( empty( $referral_ids ) ) {
		return array();
	}

	$missing = array();
	foreach ( $referral_ids as $rid ) {
		if ( ! affwp_get_referral( (int) $rid ) ) {
			$missing[] = (int) $rid;
		}
	}

	return $missing;
}

/**
 * Sum of per-referral provider fees on a payout, formatted as currency.
 *
 * Iterates the payout's referrals and totals whichever method-specific
 * fee meta is present (`_paypal_payout_fee` for PayPal items,
 * `_stripe_transfer_fee` for Stripe transfers). Returns an empty string
 * when no fee data is present so the Details panel can omit the row
 * rather than rendering "—" or a misleading zero.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout Payout object.
 * @return string Formatted total ("NZD 1.52"), or '' when no fees recorded.
 */
function affwp_get_payout_total_fees_formatted( $payout ) {

	$referral_ids = affiliate_wp()->affiliates->payouts->get_referral_ids( $payout );
	if ( empty( $referral_ids ) ) {
		return '';
	}

	$total       = 0.0;
	$any_fee     = false;
	$currency    = '';

	foreach ( $referral_ids as $rid ) {
		$referral = affwp_get_referral( (int) $rid );
		if ( ! $referral ) {
			continue;
		}
		if ( ! $currency ) {
			$currency = $referral->currency;
		}

		$paypal_fee = affwp_get_referral_meta( (int) $rid, '_paypal_payout_fee', true );
		$stripe_fee = affwp_get_referral_meta( (int) $rid, '_stripe_transfer_fee', true );
		$fee        = $paypal_fee !== '' ? $paypal_fee : $stripe_fee;

		if ( $fee !== '' && $fee !== false ) {
			$total  += (float) $fee;
			$any_fee = true;
		}
	}

	if ( ! $any_fee ) {
		return '';
	}

	return affwp_currency_filter( affwp_format_amount( $total ), $currency );
}

/**
 * Days remaining until PayPal returns unclaimed funds.
 *
 * PayPal's policy: unclaimed payouts are returned to the sender 30 days
 * after the payout was created. We don't have a per-item creation
 * timestamp, so we approximate from the parent payout's date. Result is
 * clamped at 0 (negative = already past the deadline).
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout Payout object.
 * @return int Days remaining (0+).
 */
function affwp_get_payout_unclaimed_days_remaining( $payout ) {

	$payout_ts = strtotime( (string) $payout->date );
	if ( ! $payout_ts ) {
		return 0;
	}

	$deadline  = $payout_ts + ( 30 * DAY_IN_SECONDS );
	$now       = (int) current_time( 'timestamp', true );
	$remaining = (int) ceil( ( $deadline - $now ) / DAY_IN_SECONDS );

	return max( 0, $remaining );
}

function affwp_build_payout_next_steps( $payout, $affiliate_name = '' ) {

	$method = (string) ( $payout->payout_method ?? '' );

	$method_label = affwp_get_payout_method_display_label( $method );
	if ( '' === $method_label ) {
		$method_label = ucfirst( str_replace( array( '_', '-' ), ' ', $method ) );
	}

	$name = $affiliate_name ?: __( 'The affiliate', 'affiliate-wp' );

	/*
	 * Processing-but-stuck branch (added 2.33.0).
	 *
	 * PayPal's "UNCLAIMED" state is operationally identical to a failed
	 * payout with `affiliate_action_required`: funds left the admin's
	 * PayPal account, but PayPal is holding them because the recipient
	 * doesn't have a verified PayPal account. PayPal returns the funds
	 * after 30 days if they remain unclaimed. The affiliate must verify
	 * their PayPal email to claim.
	 *
	 * The payment-system label says "Processing" (technically correct,
	 * funds are in flight) but the operational reality is "needs the
	 * affiliate to act or this WILL fail." Same human task as the failed-
	 * Stripe-not-connected case. Surface the same Next Steps card here
	 * rather than reserving it for `status=failed`.
	 *
	 * Detected via per-referral item_status meta (`_paypal_transaction_status`)
	 * being `UNCLAIMED`. A count > 0 is enough to trigger this branch.
	 */
	if ( 'processing' === $payout->status ) {
		$unclaimed = affwp_count_payout_unclaimed_items( $payout );
		if ( $unclaimed > 0 ) {
			/*
			 * Bake the live days-remaining count into the narration so the
			 * urgency signal flows through one surface. Previously the
			 * warning banner below the referrals carried the dynamic "in
			 * 6 days" countdown while this description said the static
			 * "after 30 days," telling the same story twice at different
			 * fidelities. With the warning banner suppressed (see the
			 * Next Steps-active branch in payout-drawer-referrals.php),
			 * this narration is now the single source for the deadline.
			 */
			/*
			 * The deadline is expressed as a single live countdown
			 * ("returned to you in N days") rather than restating the
			 * static 30-day policy beside it — on day one both numbers
			 * are 30, which reads like a templating bug. "Returned to
			 * you in N days" carries the consequence (the admin gets the
			 * funds back) and the timer in one phrase. With the warning
			 * banner suppressed, this is the single source for the
			 * deadline.
			 */
			$days_left   = affwp_get_payout_unclaimed_days_remaining( $payout );
			$description = $days_left > 0
				? sprintf(
					/* translators: %d: days remaining before PayPal returns the funds */
					_n(
						"PayPal emailed them a link to claim it. Once they sign in or create a free PayPal account with their payout email, the payment completes on its own. Unclaimed funds are returned to you in %d day.",
						"PayPal emailed them a link to claim it. Once they sign in or create a free PayPal account with their payout email, the payment completes on its own. Unclaimed funds are returned to you in %d days.",
						$days_left,
						'affiliate-wp'
					),
					$days_left
				)
				: __( "PayPal emailed them a link to claim it. Once they sign in or create a free PayPal account with their payout email, the payment completes on its own — but the 30-day window is up and these funds are about to be returned to you.", 'affiliate-wp' );

			$next = array(
				/*
				 * Eyebrow label rendered in the Next Steps card's header
				 * strip. Carries the operational class even though the
				 * payment-system status is "processing" (the payout has
				 * no failure_class set — that field only populates on
				 * status=failed). The label travels with the next_steps
				 * payload so the view doesn't have to triangulate.
				 */
				'label'       => affwp_get_payout_failure_class_label( 'affiliate_action_required' ),
				'headline'    => sprintf(
					/* translators: 1: affiliate name, 2: number of unclaimed referrals */
					_n(
						'%1$s needs to claim this PayPal payment',
						'%1$s needs to claim %2$d PayPal payments',
						$unclaimed,
						'affiliate-wp'
					),
					$name,
					$unclaimed
				),
				'description'    => $description,
				'primary_action' => array(
					'label' => __( 'Send reminder', 'affiliate-wp' ),
					'kind'  => 'email_affiliate',
					'url'   => '',
				),
			);

			return $next;
		}
	}

	if ( 'failed' !== $payout->status ) {
		return null;
	}

	$class = (string) ( $payout->failure_class ?? '' );

	if ( '' === $class ) {
		return null;
	}

	$next = null;

	switch ( $class ) {
		case 'affiliate_action_required':
			/*
			 * Description varies by actual system state, not just by class.
			 * Two switches govern reality:
			 *   - auto_email_payout_failures: did the system already email
			 *     the affiliate? If off, the "AffiliateWP emailed them" line is
			 *     a lie and must be omitted.
			 *   - auto_retry_failed_payouts: will the daily sweep pick this
			 *     up once they fix it? If off, "will retry automatically"
			 *     is a lie and must be omitted.
			 *
			 * We render the truth in four combinations rather than one
			 * static string. Cooldown meta presence (rendered separately
			 * as "Reminder sent N ago") confirms the email actually went
			 * out at runtime; this description is what we promise the
			 * system will do.
			 */
			$auto_email_on = (bool) affiliate_wp()->settings->get( 'auto_email_payout_failures', true );
			$auto_retry_on = (bool) affiliate_wp()->settings->get( 'auto_retry_failed_payouts', true );

			if ( $auto_email_on && $auto_retry_on ) {
				$description = sprintf(
					/* translators: %s: payout method name */
					__( "AffiliateWP emailed them instructions to finish their %s setup. The payout will retry automatically once that's done.", 'affiliate-wp' ),
					$method_label
				);
			} elseif ( $auto_email_on ) {
				$description = sprintf(
					/* translators: %s: payout method name */
					__( "AffiliateWP emailed them instructions to finish their %s setup. Retry manually once they've completed it.", 'affiliate-wp' ),
					$method_label
				);
			} elseif ( $auto_retry_on ) {
				$description = sprintf(
					/* translators: %s: payout method name */
					__( "Contact the affiliate to finish their %s setup. The payout will retry automatically once that's done.", 'affiliate-wp' ),
					$method_label
				);
			} else {
				$description = sprintf(
					/* translators: %s: payout method name */
					__( "Contact the affiliate to finish their %s setup, then retry the payout.", 'affiliate-wp' ),
					$method_label
				);
			}

			$next = array(
				/*
				 * Use generic "This affiliate" rather than interpolating the
				 * affiliate's display name. The affiliate's name is already
				 * shown in the drawer header and Details card; interpolating
				 * it into the headline reads ambiguously when the name
				 * happens to be a common noun ("admin needs to complete
				 * Stripe setup" — admin who?).
				 */
				'headline'       => sprintf(
					/* translators: %s: payout method name */
					__( 'This affiliate needs to complete %s setup', 'affiliate-wp' ),
					$method_label
				),
				'description'    => $description,
				'primary_action' => array(
					'label' => __( 'Send connection request', 'affiliate-wp' ),
					'kind'  => 'email_affiliate',
					'url'   => '',
				),
			);
			break;

		case 'admin_action_required':
			$dashboard_url = '';
			$dashboard_label = __( 'Open provider dashboard', 'affiliate-wp' );
			if ( 'stripe' === $method ) {
				$is_test         = (bool) affiliate_wp()->settings->get( 'paypal_test_mode' ); // Stripe shares the test-mode plugin setting in this build.
				$dashboard_url   = $is_test ? 'https://dashboard.stripe.com/test/balance/overview' : 'https://dashboard.stripe.com/balance/overview';
				$dashboard_label = __( 'Open Stripe dashboard', 'affiliate-wp' );
			} elseif ( 'paypal' === $method ) {
				$is_test         = (bool) affiliate_wp()->settings->get( 'paypal_test_mode' );
				$dashboard_url   = $is_test ? 'https://www.sandbox.paypal.com/listpayments-transactions/payouts' : 'https://www.paypal.com/listpayments-transactions/payouts';
				$dashboard_label = __( 'Open PayPal dashboard', 'affiliate-wp' );
			}
			$next = array(
				'headline'    => sprintf(
					/* translators: %s: payout method name */
					__( '%s rejected this payout for a platform-side reason', 'affiliate-wp' ),
					$method_label
				),
				'description' => __( 'Check the provider dashboard for details (often insufficient balance, capability missing, or an API key problem), then retry the payout.', 'affiliate-wp' ),
				'primary_action' => array(
					'label' => $dashboard_label,
					'kind'  => 'open_url',
					'url'   => $dashboard_url,
				),
			);
			break;

		case 'data_error':
			$next = array(
				'headline'    => sprintf(
					/* translators: %s: payout method name */
					__( '%s rejected this payout due to a data problem on the affiliate\'s profile', 'affiliate-wp' ),
					$method_label
				),
				'description' => __( 'Update the affiliate\'s payout details, then run the payout again.', 'affiliate-wp' ),
				'primary_action' => array(
					'label' => __( 'Edit affiliate profile', 'affiliate-wp' ),
					'kind'  => 'edit_affiliate',
					'url'   => affwp_admin_url(
						'affiliates',
						array(
							'action'       => 'edit_affiliate',
							'affiliate_id' => (int) $payout->affiliate_id,
						)
					),
				),
			);
			break;

		case 'transient':
			$next = array(
				'headline'    => __( 'Temporary error', 'affiliate-wp' ),
				'description' => sprintf(
					/* translators: %s: payout method name */
					__( '%s returned a temporary error (rate limit, timeout, or transient API issue). Safe to retry.', 'affiliate-wp' ),
					$method_label
				),
				'primary_action' => array(
					'label' => __( 'Retry Payout', 'affiliate-wp' ),
					'kind'  => 'retry',
					'url'   => '',
				),
			);
			break;

		case 'unknown':
		default:
			/*
			 * No classifier match — surface the raw error and let admin
			 * judge. Retry is still enabled (per Failure_Class::is_retryable)
			 * since "unknown" doesn't mean "non-retryable", it means we
			 * couldn't auto-dispatch.
			 */
			$next = array(
				'headline'       => __( 'Unable to classify this failure', 'affiliate-wp' ),
				'description'    => __( 'The error message below comes straight from the provider. You can retry the payout, or investigate in the provider dashboard.', 'affiliate-wp' ),
				'primary_action' => array(
					'label' => __( 'Retry Payout', 'affiliate-wp' ),
					'kind'  => 'retry',
					'url'   => '',
				),
			);
			break;
	}

	/*
	 * Populate the header-strip label for the failed branches. The
	 * processing-UNCLAIMED branch sets this directly because its $class
	 * is hardcoded; here we use the resolved $class. The label travels
	 * with the next_steps payload so the view doesn't have to fall back
	 * to drawer.payout.failure_class_label (which is empty for
	 * processing payouts even when next_steps fires).
	 */
	if ( is_array( $next ) ) {
		$next['label'] = affwp_get_payout_failure_class_label( $class );

		/*
		 * Attach the provider's verbatim error so the card shows the real reason
		 * (e.g. "PayPal: Payment to self is not allowed.") alongside the class
		 * guidance. PayPal stores it as the `error` key in the description JSON;
		 * other methods store a plain error string. Without this, a classified
		 * failure only ever showed generic class boilerplate.
		 */
		$provider_message = '';
		$decoded          = json_decode( (string) ( $payout->description ?? '' ), true );
		if ( is_array( $decoded ) && ! empty( $decoded['error'] ) ) {
			$provider_message = (string) $decoded['error'];
		} elseif ( 'failed' === $payout->status && '' !== (string) ( $payout->description ?? '' ) && null === $decoded ) {
			$provider_message = (string) $payout->description;
		}

		if ( '' !== $provider_message ) {
			$next['provider_message'] = $provider_message;
		}
	}

	return $next;
}

/**
 * Returns the payout that replaced a given payout via a method-switch.
 *
 * When a failed payout is switched via `affwp_switch_payout_method()` (or
 * carried through `affwp_retry_payout()`), its `superseded_by_payout_id`
 * column is set to the new payout's ID. Reading that column is the single
 * source of truth: it's the same field the list-table filter and the
 * `affwp_get_payout_predecessor()` inverse rely on, so all three agree.
 *
 * The column is only ever written by a real switch/retry, so historical
 * payouts whose referrals merely happen to have been re-paid in a separate
 * payout are correctly reported as NOT superseded.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout The (presumed superseded) payout.
 * @return int The replacement payout's ID, or 0 if none.
 */
function affwp_get_payout_superseded_by( $payout ) {

	$payout = affwp_get_payout( $payout );
	if ( ! $payout ) {
		return 0;
	}

	return (int) $payout->superseded_by_payout_id;
}

/**
 * Returns the ID of the failed payout that THIS payout replaced, or 0.
 *
 * The inverse of `affwp_get_payout_superseded_by()`: given a (current) payout,
 * find the prior attempt in the retry chain — the payout whose
 * `superseded_by_payout_id` points at this one. Because superseded attempts are
 * hidden from the list, this is how the drawer offers a "Retry of #M" link back
 * to the previous attempt so the chain stays navigable.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout The (presumed replacement) payout.
 * @return int The previous attempt's payout ID, or 0 if none.
 */
function affwp_get_payout_predecessor( $payout ) {
	$payout = affwp_get_payout( $payout );
	if ( ! $payout ) {
		return 0;
	}

	global $wpdb;
	$table = affiliate_wp()->affiliates->payouts->table_name;

	$prev = $wpdb->get_var(
		$wpdb->prepare(
			"SELECT payout_id FROM {$table} WHERE superseded_by_payout_id = %d ORDER BY payout_id DESC LIMIT 1",
			(int) $payout->ID
		)
	);

	return $prev ? (int) $prev : 0;
}

/**
 * Lists payout methods available to switch a failed payout TO.
 *
 * Used by the drawer's "Switch payout method" dropdown. Filters the
 * registered methods to:
 *
 *   1. Exclude the payout's current method (no-op to re-pick the same one).
 *   2. Keep methods that are either (a) always-available — manual,
 *      store_credit — or (b) currently ready for this affiliate per the
 *      `affwp_payout_method_is_affiliate_ready` filter chain.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout Failed payout context.
 * @return array Map of method slug => display label.
 */
function affwp_get_payout_methods_for_switch( $payout ) {

	$all_methods = affwp_get_payout_methods();
	$current     = (string) $payout->payout_method;
	$out         = array();

	foreach ( $all_methods as $slug => $label ) {

		if ( $slug === $current ) {
			continue;
		}

		/*
		 * Methods that don't need provider readiness (manual + store credit
		 * complete entirely server-side) always pass.
		 */
		$always_available = in_array( $slug, array( 'manual', 'store_credit' ), true );

		if ( ! $always_available && ! affwp_payout_method_is_affiliate_ready( $slug, (int) $payout->affiliate_id, $payout ) ) {
			continue;
		}

		if ( ! affwp_is_payout_method_enabled( $slug ) ) {
			continue;
		}

		$out[ $slug ] = array(
			'label'       => affwp_get_payout_method_display_label( $slug ) ?: $label,
			/*
			 * One-line, consequence-focused description per method.
			 * Tells the admin what the affiliate's experience will be if
			 * they switch to this method — not just what the method is.
			 * Switching a payout is moderately destructive (re-routes
			 * referrals, supersedes the current row), so the description
			 * is load-bearing for the decision, not decoration.
			 */
			'description' => affwp_get_payout_method_switch_description( $slug ),
		);
	}

	return $out;
}

/**
 * Returns a one-line, consequence-focused description for a payout method
 * presented in the drawer's Switch-method panel.
 *
 * @since 2.33.0
 *
 * @param string $method Method slug.
 * @return string Description, or empty when no copy is registered.
 */
function affwp_get_payout_method_switch_description( $method ) {

	$descriptions = array(
		'manual'       => __( 'Mark as paid outside AffiliateWP. You handle the transfer.', 'affiliate-wp' ),
		'store_credit' => __( "Credit the affiliate's account on this site.", 'affiliate-wp' ),
		'paypal'       => __( "Send directly to the affiliate's verified PayPal email.", 'affiliate-wp' ),
		'stripe'       => __( "Send directly to the affiliate's connected Stripe account.", 'affiliate-wp' ),
	);

	return $descriptions[ $method ] ?? '';
}

/**
 * Switches a failed payout's method to a different one.
 *
 * Leaves the original failed payout intact as an audit record (with an
 * appended description note documenting the switch), and creates a new
 * payout under the new method via the existing retry path. The retried
 * payout inherits the affiliate's referrals and amount and is processed
 * by the new method's batch handler.
 *
 * Mirrors `affwp_retry_payout()` but with an explicit method override,
 * because admins switching method are signaling "this affiliate's stored
 * method preference is fine; this ONE payout needs to route elsewhere."
 *
 * @since 2.33.0
 *
 * @param int    $payout_id  Failed payout to switch.
 * @param string $new_method Target method slug.
 * @return array|\WP_Error On success: ['payout_id' => int, 'batch_id' => int].
 *                         On failure: WP_Error with one of the codes below.
 */
/**
 * Unique marker that delimits the original payout description from the
 * appended audit-log JSON. Chosen to be visually distinctive and unlikely
 * to appear in any real PayPal status JSON or admin-authored description.
 *
 * @since 2.33.0
 *
 * @return string
 */
function affwp_payout_audit_log_marker() {
	return '__AFFWP_AUDIT_LOG__';
}

/**
 * Splits a payout description at the audit-log marker and returns the
 * "original" portion (everything before the marker). Used by the drawer
 * payload to give the Error renderer a clean description string without
 * the appended audit log mixed in.
 *
 * @since 2.33.0
 *
 * @param string $description Raw description from the payout row.
 * @return string Original description, trimmed.
 */
function affwp_payout_description_without_audit( $description ) {
	$description = (string) $description;
	$marker      = affwp_payout_audit_log_marker();
	$position    = strpos( $description, $marker );
	if ( false === $position ) {
		return rtrim( $description );
	}
	return rtrim( substr( $description, 0, $position ) );
}

/**
 * Extracts the audit log array from a payout description. Returns an empty
 * array when there's no audit section.
 *
 * @since 2.33.0
 *
 * @param string $description Raw description from the payout row.
 * @return array Array of audit-event arrays (chronological order, oldest first).
 */
function affwp_payout_description_audit_log( $description ) {
	$description = (string) $description;
	$marker      = affwp_payout_audit_log_marker();
	$position    = strpos( $description, $marker );
	if ( false === $position ) {
		return array();
	}
	$json    = trim( substr( $description, $position + strlen( $marker ) ) );
	$decoded = json_decode( $json, true );
	if ( ! is_array( $decoded ) ) {
		return array();
	}
	return $decoded;
}

function affwp_switch_payout_method( $payout_id, $new_method ) {

	$payout_id  = absint( $payout_id );
	$new_method = sanitize_key( $new_method );

	$payout = affwp_get_payout( $payout_id );
	if ( ! $payout ) {
		return new WP_Error( 'affwp_switch_not_found', __( 'Payout not found.', 'affiliate-wp' ) );
	}

	if ( 'failed' !== $payout->status ) {
		return new WP_Error( 'affwp_switch_not_failed', __( 'Only failed payouts can have their method switched.', 'affiliate-wp' ) );
	}

	$switchable = affwp_get_payout_methods_for_switch( $payout );
	if ( ! isset( $switchable[ $new_method ] ) ) {
		return new WP_Error( 'affwp_switch_invalid_method', __( 'That method is not available for this payout.', 'affiliate-wp' ) );
	}

	$old_method = (string) $payout->payout_method;
	$user       = wp_get_current_user();
	$admin_name = $user && $user->ID ? $user->display_name : __( 'an admin', 'affiliate-wp' );

	/*
	 * Run the retry FIRST so we know the new payout's ID, then append
	 * an audit note to the old payout's description that references the
	 * new payout. Drawer ALSO surfaces a "Replaced by #N" banner via the
	 * structured `superseded_by_payout_id` field (computed at drawer
	 * payload time from referral linkage); the description note is the
	 * static history record.
	 *
	 * Pin the retry to the requested method via the dedicated
	 * `affwp_retry_payout_method` seam, without persistently changing the
	 * affiliate's preference. This filter is applied last inside
	 * `affwp_retry_payout()`, so it overrides the method-resolution default
	 * (which otherwise keeps the failed method).
	 */
	$override = function () use ( $new_method ) {
		return $new_method;
	};
	add_filter( 'affwp_retry_payout_method', $override, 9999 );

	$result = affwp_retry_payout( $payout_id );

	remove_filter( 'affwp_retry_payout_method', $override, 9999 );

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

	$new_payout_id = (int) $result['payout_id'];

	/*
	 * Append the audit event to the payout's description, but as a
	 * structured JSON section after a unique marker rather than as a
	 * human prose suffix. Earlier the audit was appended with an
	 * em-dash separator, which (a) broke PayPal JSON descriptions
	 * downstream (the renderer's json_decode would fail on the trailing
	 * non-JSON text), (b) mixed audit data into the Error display, and
	 * (c) doesn't scale past two switches without becoming unreadable.
	 *
	 * Marker-delimited structure:
	 *   <original description here, untouched>
	 *   <newline><AFFWP_AUDIT_LOG_MARKER><newline>
	 *   <JSON array of audit events>
	 *
	 * The drawer payload (see affwp_build_payout_drawer_payload) splits
	 * on the marker, decodes the JSON, and exposes audit_log[] as a
	 * structured payload field that views render in their own surfaces
	 * (Supersession card, History list). The original description
	 * (everything before the marker) parses cleanly as PayPal JSON or
	 * plain text exactly as it did pre-switch.
	 */
	$existing_description = (string) $payout->description;
	$existing_audit       = affwp_payout_description_audit_log( $existing_description );
	$base_description     = affwp_payout_description_without_audit( $existing_description );

	$existing_audit[] = array(
		'event'         => 'method_switched',
		'from'          => $old_method,
		'from_label'    => affwp_get_payout_method_display_label( $old_method ) ?: $old_method,
		'to'            => $new_method,
		'to_label'      => affwp_get_payout_method_display_label( $new_method ) ?: $new_method,
		'new_payout_id' => $new_payout_id,
		'actor'         => $admin_name,
		'actor_user_id' => $user ? (int) $user->ID : 0,
		'at'            => current_time( 'mysql' ),
	);

	$marker          = affwp_payout_audit_log_marker();
	$new_description = rtrim( $base_description ) . "\n" . $marker . "\n" . wp_json_encode( $existing_audit );

	affiliate_wp()->affiliates->payouts->update(
		$payout_id,
		array( 'description' => $new_description ),
		'',
		'payout'
	);

	/**
	 * Fires after a failed payout's method has been switched and a new
	 * payout created under the new method.
	 *
	 * @since 2.33.0
	 *
	 * @param int    $payout_id      Original (failed) payout ID.
	 * @param string $old_method     Original method slug.
	 * @param string $new_method     Target method slug.
	 * @param int    $new_payout_id  New payout's ID.
	 * @param int    $new_batch_id   New batch ID (created by affwp_retry_payout).
	 */
	do_action(
		'affwp_payout_method_changed',
		$payout_id,
		$old_method,
		$new_method,
		(int) $result['payout_id'],
		(int) $result['batch_id']
	);

	return $result;
}

/**
 * Whether an affiliate is currently ready to receive a payout via the
 * given method.
 *
 * Cross-method readiness check. Each method registers its predicate via
 * the `affwp_payout_method_is_affiliate_ready` filter (Stripe checks the
 * Connect account ID + capability state; PayPal validates the affiliate's
 * paypal_email meta). Returns false by default so unregistered methods
 * never trigger an auto-retry.
 *
 * Used by the daily auto-retry sweep and the inline method-switch picker.
 *
 * @since 2.33.0
 *
 * @param string                  $method       Method slug.
 * @param int                     $affiliate_id Affiliate ID.
 * @param \AffWP\Affiliate\Payout|null $payout  Optional payout context.
 * @return bool
 */
function affwp_payout_method_is_affiliate_ready( $method, $affiliate_id, $payout = null ) {
	/**
	 * Filters whether an affiliate is ready to receive a payout via a method.
	 *
	 * Each method's init file is expected to hook this filter and return
	 * its readiness check result.
	 *
	 * @since 2.33.0
	 *
	 * @param bool                         $is_ready     Default false. Method-specific hook flips to true.
	 * @param string                       $method       Method slug.
	 * @param int                          $affiliate_id Affiliate ID.
	 * @param \AffWP\Affiliate\Payout|null $payout       Payout context (may be null).
	 */
	return (bool) apply_filters( 'affwp_payout_method_is_affiliate_ready', false, (string) $method, (int) $affiliate_id, $payout );
}

/**
 * Cap on how many payouts the daily auto-retry sweep retries per run.
 *
 * Prevents a sudden burst against the payment provider when a backlog
 * has accumulated. Subsequent days clear the rest. Filterable for sites
 * that want to burn through faster or slower.
 *
 * @since 2.33.0
 *
 * @return int
 */
function affwp_retry_sweep_per_run_limit() {
	/**
	 * Filters the per-run cap on auto-retry sweep retries.
	 *
	 * @since 2.33.0
	 *
	 * @param int $limit Default 100.
	 */
	return (int) apply_filters( 'affwp_retry_sweep_per_run_limit', 100 );
}

/**
 * Maximum number of times the auto-retry sweep will re-attempt a single
 * payout's referral set before giving up and leaving it for the admin.
 *
 * This is the hard stop that prevents a permanently-failing payout from being
 * retried forever — the case where a method's readiness signal can't actually
 * detect the blocker (e.g. PayPal, where any syntactically-valid email reads
 * "ready"), or a transient provider failure never clears. Each retry carries a
 * `retry_count` one higher than its parent; once that reaches this ceiling the
 * sweep skips it, so the worst case for one stuck payout is this many retries
 * total, ever — not this many per day.
 *
 * @since 2.33.0
 *
 * @return int
 */
function affwp_payout_max_retry_attempts() {
	/**
	 * Filters the maximum retry-chain depth for the auto-retry sweep.
	 *
	 * @since 2.33.0
	 *
	 * @param int $max Default 5.
	 */
	return max( 1, (int) apply_filters( 'affwp_payout_max_retry_attempts', 5 ) );
}

/**
 * The auto-retry go-forward watermark: a GMT `Y-m-d H:i:s` timestamp the sweep
 * uses as a hard floor on payout creation date. Payouts created before it are
 * never auto-retried, so enabling the feature on a site with historical failed
 * payouts can't trigger a retroactive blast.
 *
 * Empty string when no watermark has been recorded yet (the sweep establishes
 * one lazily on its first run).
 *
 * @since 2.33.0
 *
 * @return string GMT datetime, or '' when unset.
 */
function affwp_get_auto_retry_watermark() {
	return (string) get_option( 'affwp_auto_retry_watermark', '' );
}

/**
 * Runs the auto-retry sweep over retryable failed payouts.
 *
 * Walks failed payouts in two retryable classes and re-attempts them:
 *
 *   - `affiliate_action_required` — retried only once the affiliate's method
 *     reads as ready (e.g. Stripe Connect finished). Gated by the per-method
 *     readiness predicate.
 *   - `transient` — provider blip (rate limit, timeout, 5xx). No affiliate
 *     dependency, so retried unconditionally on the next daily pass. Daily
 *     (rather than minutes-later) cadence is deliberate: hammering a provider
 *     that just returned 429/503 would make things worse, so we back off a
 *     full day between attempts.
 *
 * Two hard limits bound the work and prevent the runaway loops the earlier
 * design allowed:
 *
 *   - `affwp_payout_max_retry_attempts()` — per-payout chain depth. The query
 *     excludes anything that has hit the ceiling, so a payout whose blocker
 *     never clears (the classic PayPal "valid-format-but-unclaimed email"
 *     case) is retried at most that many times total, then left for the admin.
 *   - `affwp_retry_sweep_per_run_limit()` — per-run cap, so a large backlog is
 *     drained over several days rather than in one burst.
 *
 * `affwp_retry_payout()` clears the originating payout's `failure_class` when it
 * dispatches a retry, so a given failure is never a candidate twice: the chain
 * advances one payout at a time instead of fanning out.
 *
 * Reusable: bound to the daily AS hook + the manual Tools admin action.
 *
 * @since 2.33.0
 *
 * @return array {
 *     @type int $candidates Number of payouts examined.
 *     @type int $ready      Number found ready (passed the readiness check).
 *     @type int $retried    Number actually retried (could be < ready if cap hit).
 *     @type int $skipped    Number skipped because affiliate not ready yet.
 * }
 */
function affwp_run_action_required_retry_sweep() {

	$limit  = affwp_retry_sweep_per_run_limit();
	$report = array(
		'candidates' => 0,
		'ready'      => 0,
		'retried'    => 0,
		'skipped'    => 0,
	);

	/*
	 * Honor the site-wide opt-out. Sites upgrading from 2.33 have this
	 * setting forced OFF during the 2.33.0 upgrade so no automated
	 * retries fire against historical failed payouts without explicit
	 * site-owner consent. Fresh installs default to ON.
	 */
	if ( ! (bool) affiliate_wp()->settings->get( 'auto_retry_failed_payouts', true ) ) {
		$report['disabled'] = true;
		return $report;
	}

	/*
	 * Go-forward watermark. The sweep NEVER retries a payout created before
	 * this timestamp, which makes a historical-backlog blast impossible: when
	 * an existing site enables auto-retry (or the 2.33.0 upgrade flips it on),
	 * its months of dormant failed payouts are all dated before the watermark
	 * and are permanently out of scope. Retries themselves are dated "now", so
	 * a chain that legitimately starts after the watermark continues normally.
	 *
	 * The watermark is set at upgrade time (see the v2.33.0 upgrade) and
	 * established lazily here on the first run for any site that lacks one, so
	 * the floor is "now" no matter how the feature came to be enabled. There is
	 * no code path that retries a pre-watermark payout.
	 */
	$watermark = affwp_get_auto_retry_watermark();
	if ( '' === $watermark ) {
		$watermark = gmdate( 'Y-m-d H:i:s' );
		update_option( 'affwp_auto_retry_watermark', $watermark );
	}
	$report['watermark'] = $watermark;

	$max_attempts = affwp_payout_max_retry_attempts();

	/*
	 * Cursor-paginated sweep. The previous implementation fetched all
	 * candidates (`number = -1`) which on a site with tens of thousands of
	 * dormant `affiliate_action_required` rows hammered the readiness check
	 * once per row every day. Now each daily run walks at most `$page_size`
	 * candidates starting where the last run left off, and resets to the
	 * head when a short page indicates the table has been walked. Worst
	 * case: a 50k-row backlog is fully revisited every ~25 daily runs at
	 * the default page size.
	 */
	$page_size = 2000;
	$cursor    = (int) get_option( 'affwp_retry_sweep_cursor', 0 );

	/*
	 * Direct cursor query — `get_payouts()` doesn't expose a `>` filter on
	 * the primary key, but the PRIMARY KEY + `failure_class` indexes make
	 * this scan O(page_size) regardless of backlog size. The `retry_count`
	 * ceiling is applied in SQL so capped payouts never even load.
	 */
	global $wpdb;
	$payouts_tbl = affiliate_wp()->affiliates->payouts->table_name;
	$payout_ids  = $wpdb->get_col(
		$wpdb->prepare(
			"SELECT payout_id FROM {$payouts_tbl}
			 WHERE status = %s
			   AND failure_class IN ( %s, %s )
			   AND retry_count < %d
			   AND superseded_by_payout_id = 0
			   AND date >= %s
			   AND payout_id > %d
			 ORDER BY payout_id ASC
			 LIMIT %d",
			'failed',
			\AffWP\Payouts\Failure_Class::AFFILIATE_ACTION_REQUIRED,
			\AffWP\Payouts\Failure_Class::TRANSIENT,
			$max_attempts,
			$watermark,
			$cursor,
			$page_size
		)
	);

	$payouts = array_filter( array_map( 'affwp_get_payout', $payout_ids ) );

	$report['candidates'] = count( $payouts );
	$report['cursor']     = $cursor;

	$last_seen_id = $cursor;

	foreach ( $payouts as $payout ) {

		$payout_id    = (int) $payout->payout_id;
		$last_seen_id = max( $last_seen_id, $payout_id );

		if ( $report['retried'] >= $limit ) {
			break;
		}

		$method = (string) $payout->payout_method;
		$class  = (string) $payout->failure_class;

		/*
		 * Affiliate-action failures wait for the affiliate to clear the
		 * blocker. Transient failures have no affiliate dependency, so they
		 * skip the readiness gate and retry directly.
		 */
		if ( \AffWP\Payouts\Failure_Class::AFFILIATE_ACTION_REQUIRED === $class
			&& ! affwp_payout_method_is_affiliate_ready( $method, (int) $payout->affiliate_id, $payout )
		) {
			$report['skipped']++;
			continue;
		}

		$report['ready']++;

		$result = affwp_retry_payout( $payout_id );
		if ( ! is_wp_error( $result ) ) {
			$report['retried']++;
		}
	}

	/*
	 * Advance the cursor. When the page returns fewer rows than requested we
	 * know we've reached the end of the table — reset to 0 so the next run
	 * starts from the head again. Otherwise persist last_seen_id so the next
	 * run continues from here.
	 */
	if ( count( $payouts ) < $page_size ) {
		update_option( 'affwp_retry_sweep_cursor', 0 );
	} else {
		update_option( 'affwp_retry_sweep_cursor', $last_seen_id );
	}

	return $report;
}

/*
 * Bind the sweep function to the daily AS recurring action registered in
 * Affiliate_WP_Scheduler::retry_action_required_payouts().
 */
add_action( 'affwp_retry_action_required_payouts', 'affwp_run_action_required_retry_sweep' );

/**
 * Teach the settings getter that a stored `0` is a real value for the two
 * failure-handling toggles, so an admin opt-out (or the v2.33.0 upgrade's
 * forced-off `0`) is honored everywhere.
 *
 * Affiliate_WP_Settings::get() treats a stored `0` as "missing" via its
 * `! empty()` check and returns the caller's default instead — and both of
 * these settings are read with a default of `true`. The admin Payouts tab
 * registers this same allow-list, but only in the `is_admin() || WP_CLI`
 * load path. The auto-retry sweep and the per-method email dispatch both run
 * under Action Scheduler in plain cron context, where that admin class never
 * loads — so without this always-on registration a site that turned the
 * feature OFF would still have the sweep retry and the emails fire, because
 * the cron read falls back to the `true` default.
 *
 * Registered here (in the unconditionally-loaded payout functions) so the
 * opt-out holds in admin, WP-CLI, and cron alike.
 *
 * @since 2.33.0
 *
 * @param array $allowed Settings keys whose stored `0` should be honored.
 * @return array
 */
function affwp_allow_zero_for_failure_toggles( $allowed ) {
	$allowed[] = 'auto_email_payout_failures';
	$allowed[] = 'auto_retry_failed_payouts';
	return $allowed;
}
add_filter( 'affwp_settings_zero_values_allowed', 'affwp_allow_zero_for_failure_toggles' );

/**
 * UNIX timestamp of the last auto-failure-email sent to an affiliate.
 *
 * @since 2.33.0
 *
 * @param int $affiliate_id Affiliate ID.
 * @return int Timestamp, or 0 when never sent.
 */
function affwp_failure_email_last_sent_at( $affiliate_id ) {
	if ( ! $affiliate_id ) {
		return 0;
	}
	return (int) affwp_get_affiliate_meta( (int) $affiliate_id, '_affwp_last_failure_email_sent_at', true );
}

/**
 * Human-friendly "X days ago" string for the last auto-failure-email
 * sent to an affiliate. Empty when never sent.
 *
 * @since 2.33.0
 *
 * @param int $affiliate_id Affiliate ID.
 * @return string e.g. "2 days ago", or empty string when never sent.
 */
function affwp_failure_email_last_sent_human( $affiliate_id ) {
	$ts = affwp_failure_email_last_sent_at( $affiliate_id );
	if ( ! $ts ) {
		return '';
	}
	return sprintf(
		/* translators: %s: human-readable time difference */
		__( '%s ago', 'affiliate-wp' ),
		human_time_diff( $ts, current_time( 'timestamp', true ) )
	);
}

/**
 * Default cooldown window for the auto-failure-email, in seconds.
 *
 * Keyed by affiliate ID (not by affiliate+method) so a customer who
 * fails on Stripe AND PayPal in the same week only receives one
 * outreach; chasing them twice for two methods is spammy.
 *
 * @since 2.33.0
 *
 * @return int Seconds.
 */
function affwp_failure_email_cooldown_seconds() {
	return 7 * DAY_IN_SECONDS;
}

/**
 * Whether the auto-failure-email cooldown is currently active for an affiliate.
 *
 * Drives the drawer's Next Steps card CTA state machine: when the cooldown
 * is active the card hides the primary "Send" button (the system already
 * sent the email recently, clicking would either spam or be silently
 * blocked) and surfaces a quieter "Resend reminder" link instead, gated by
 * a confirmation prompt on the JS side.
 *
 * @since 2.33.0
 *
 * @param int $affiliate_id Affiliate ID.
 * @return bool True while the cooldown window is still open since the last send.
 */
function affwp_failure_email_cooldown_active( $affiliate_id ) {
	$last = affwp_failure_email_last_sent_at( $affiliate_id );
	if ( ! $last ) {
		return false;
	}
	$cooldown = affwp_failure_email_cooldown_seconds();
	return ( time() - $last ) < $cooldown;
}

/**
 * Dispatches the affiliate-action-required email for a failed payout.
 *
 * Called by per-method processors after a payout transitions to `failed`
 * with `failure_class = affiliate_action_required`. Honors:
 *
 *   - The site-wide `auto_email_payout_failures` setting (default ON).
 *   - The per-affiliate cooldown (default 7 days, filterable).
 *   - The `affwp_should_send_failure_email` short-circuit filter.
 *   - The presence of a registered template for the payout's method.
 *
 * On send, records the timestamp on the affiliate's meta so the cooldown
 * and the drawer's "Last contacted N days ago" surface can read it.
 *
 * Idempotent in the sense that calling this twice within the cooldown
 * window for the same affiliate is a no-op.
 *
 * @since 2.33.0
 *
 * @param int  $payout_id Payout ID.
 * @param bool $force     Optional. Bypass the cooldown gate. Used by the
 *                        manual "Send connection request" admin action so an
 *                        admin can re-trigger the email outside the window.
 *                        Default false.
 * @return bool True if the email was sent; false on any skip path.
 */
function affwp_maybe_send_failure_email( $payout_id, $force = false ) {

	$payout = affwp_get_payout( $payout_id );
	if ( ! $payout ) {
		return false;
	}

	/*
	 * Two eligible states:
	 *
	 *   1. status=failed + failure_class=affiliate_action_required.
	 *      The original Phase C case: classifier ran on a per-method
	 *      processor failure and flagged it as needing affiliate action.
	 *
	 *   2. status=processing + has UNCLAIMED PayPal items. Added in
	 *      2.33.0 — the funds left the admin's PayPal account, but
	 *      PayPal is holding them because the recipient doesn't have a
	 *      verified account. PayPal returns the funds after 30 days if
	 *      they remain unclaimed. Operationally identical to case 1
	 *      (affiliate must act, or the payment quietly fails), so the
	 *      same outreach pipeline applies.
	 */
	$failed_action_required     = ( 'failed' === $payout->status && 'affiliate_action_required' === (string) $payout->failure_class );
	$processing_with_unclaimed  = ( 'processing' === $payout->status && affwp_count_payout_unclaimed_items( $payout ) > 0 );

	if ( ! $failed_action_required && ! $processing_with_unclaimed ) {
		return false;
	}

	$affiliate = affwp_get_affiliate( $payout->affiliate_id );
	if ( ! $affiliate ) {
		return false;
	}

	// Normalize to the canonical lowercase slug: the registry matches case-sensitively, and pre-2.34.0 PayPal rows stored the 'PayPal' label.
	$method = strtolower( (string) $payout->payout_method );

	if ( ! \AffWP\Payouts\Failure_Email_Registry::has_template( $method ) ) {
		return false;
	}

	/*
	 * The `auto_email_payout_failures` setting governs AUTOMATIC dispatch
	 * (the polling pipeline + initial-failure trigger). Manual sends
	 * triggered by the drawer's "Send setup reminder" / "Resend reminder"
	 * buttons (which pass $force=true) should always go through: the admin
	 * has explicitly authorized the send by clicking the button. Gating
	 * the manual path on the setting means the button silently fails for
	 * stores that prefer their own CRM-driven outreach — which is exactly
	 * the audience the setting toggle exists for, and which most needs a
	 * one-off manual nudge to work when it does fire.
	 */
	$setting_on = (bool) affiliate_wp()->settings->get( 'auto_email_payout_failures', true );

	/**
	 * Short-circuit filter for the failure-email dispatch.
	 *
	 * Returning false from this filter skips the send unconditionally
	 * for the AUTO path (the manual $force=true override is exempted —
	 * see the if() below).
	 *
	 * @since 2.33.0
	 *
	 * @param bool                    $should_send Default: setting toggle value.
	 * @param \AffWP\Affiliate\Payout $payout      Payout context.
	 * @param string                  $class       Failure class.
	 */
	$should_send = (bool) apply_filters( 'affwp_should_send_failure_email', $setting_on, $payout, $payout->failure_class );
	if ( ! $force && ! $should_send ) {
		return false;
	}

	if ( ! $force ) {
		$last_sent = (int) affwp_get_affiliate_meta( $affiliate->affiliate_id, '_affwp_last_failure_email_sent_at', true );
		$cooldown  = affwp_failure_email_cooldown_seconds();
		if ( $last_sent && ( time() - $last_sent ) < $cooldown ) {
			return false;
		}
	}

	$to = affwp_get_affiliate_email( $affiliate->affiliate_id );
	if ( ! $to ) {
		return false;
	}

	/*
	 * One email, shared SUBJECT, PER-METHOD BODY — all managed in Settings →
	 * Emails ("Payout Failed — Action Required"). The copy is composed from
	 * merge tags Affiliate_WP_Emails::send() resolves:
	 *   {name}, {site_name}                  → from affiliate_id / global
	 *   {affiliate_payout_settings_url}      → portal-aware settings page
	 *   {amount}                             → from the referral context set below
	 */
	$defaults = affwp_get_payout_failure_email_defaults();
	$subject  = (string) affiliate_wp()->settings->get( 'payout_failure_action_subject', $defaults['subject'] );

	/*
	 * Body is per-method: the admin-editable per-method setting, falling back to
	 * the method's registered default, then the generic default.
	 */
	$method_body_default = \AffWP\Payouts\Failure_Email_Registry::get_body_default( $method );
	if ( '' === $method_body_default ) {
		$method_body_default = $defaults['body'];
	}
	$body = (string) affiliate_wp()->settings->get( "payout_failure_{$method}_body", $method_body_default );

	/*
	 * Let the method swap in a state-appropriate body. The editable per-method
	 * body addresses the live, actionable state — for PayPal, an UNCLAIMED payout
	 * the affiliate can still claim. When the payout has TERMINALLY failed instead
	 * (PayPal returned the funds after the 30-day unclaimed window, or blocked the
	 * transaction), that copy can be stale: "check your inbox and claim it" points
	 * at money that's already back with the sender. $state lets the method (see the
	 * PayPal classifier's `affwp_payout_failure_email_body` callback) supply the
	 * right wording — code-level only, so it doesn't add a second editable field
	 * for a rare terminal state.
	 */
	$state = $processing_with_unclaimed ? 'unclaimed' : 'failed';
	$body  = (string) apply_filters( 'affwp_payout_failure_email_body', $body, $payout, $method, $state );

	$emailer = new Affiliate_WP_Emails();
	$emailer->__set( 'affiliate_id', $affiliate->affiliate_id );
	/*
	 * {amount} resolves from the referral context's `amount`. A payout spans
	 * multiple referrals, so hand the tag the payout total via a minimal stub —
	 * affwp_email_tag_amount() only reads `->amount`. Pre-format to 2 decimals
	 * since the tag's referral path currency-wraps the value as-is (no rounding).
	 */
	$emailer->__set( 'referral', (object) array( 'amount' => affwp_format_amount( $payout->amount ) ) );
	$sent = $emailer->send( $to, $subject, $body );

	if ( $sent ) {
		affwp_update_affiliate_meta( $affiliate->affiliate_id, '_affwp_last_failure_email_sent_at', time() );
	}

	return (bool) $sent;
}

/**
 * Default subject + body for the affiliate "payout failed — action required"
 * email. Method-neutral copy built from existing merge tags. Used as the
 * fallback when the admin hasn't customized the template in Settings → Emails.
 *
 * @since 2.33.0
 *
 * @return array{subject:string,body:string}
 */
function affwp_get_payout_failure_email_defaults() {

	$subject = __( 'Action needed to receive your commission', 'affiliate-wp' );

	$body = __(
		"Hi {name},\n\nWe couldn't send your {amount} commission from {site_name} — your payment details need a quick check.\n\nReview your payout settings and make sure everything is correct and confirmed with your payment provider:\n{affiliate_payout_settings_url}\n\nOnce that's done, we'll automatically try sending it again. Questions? Just reply to this email.\n\nThanks,\n{site_name}",
		'affiliate-wp'
	);

	return array(
		'subject' => $subject,
		'body'    => $body,
	);
}

/**
 * Single source of truth for the failure email's enable state.
 *
 * The email is editable in Settings → Emails AND toggleable in Settings →
 * Payouts. Rather than maintain two independent switches, the Emails-tab
 * entry (`payout_failure_action`) mirrors the `auto_email_payout_failures`
 * setting in both directions: this filter resolves the Emails-tab enabled
 * state from it (read), and `affwp_payout_failure_email_sync_toggle()` writes
 * it back on save.
 *
 * @since 2.33.0
 *
 * @param bool   $enabled            Incoming enabled state.
 * @param string $email_notification Email notification id.
 * @return bool
 */
function affwp_payout_failure_email_enabled_state( $enabled, $email_notification ) {
	if ( 'payout_failure_action' === $email_notification ) {
		return (bool) affiliate_wp()->settings->get( 'auto_email_payout_failures', true );
	}
	return $enabled;
}
add_filter( 'affwp_email_notification_enabled', 'affwp_payout_failure_email_enabled_state', 20, 2 );

/**
 * Mirror the Emails-tab toggle for the failure email back into the
 * `auto_email_payout_failures` setting on save, so the two surfaces stay in
 * sync. Stored as 1/0 (0 is honored by the zero-values-allowed filter).
 *
 * @since 2.33.0
 *
 * @param array $input Sanitized emails-tab settings.
 * @return array
 */
function affwp_payout_failure_email_sync_toggle( $input ) {
	$on = ! empty( $input['email_notifications']['payout_failure_action'] );
	$input['auto_email_payout_failures'] = $on ? 1 : 0;

	/*
	 * Sanitize the per-method failure-email bodies. Their field names are
	 * dynamic (payout_failure_{method}_body), so the emails tab's static
	 * body-field list can't cover them — clean them here with the same
	 * wp_kses_post treatment the framework gives other body fields.
	 */
	if ( class_exists( '\AffWP\Payouts\Failure_Email_Registry' ) ) {
		foreach ( \AffWP\Payouts\Failure_Email_Registry::methods() as $pf_method ) {
			$pf_field = 'payout_failure_' . $pf_method . '_body';
			if ( isset( $input[ $pf_field ] ) ) {
				$input[ $pf_field ] = wp_kses_post( $input[ $pf_field ] );
			}
		}
	}

	return $input;
}
add_filter( 'affwp_settings_emails_sanitize', 'affwp_payout_failure_email_sync_toggle', 20 );

/**
 * Whether a failed payout's Retry action is enabled (button not disabled).
 *
 * Reads the Failure_Class::is_retryable() classification.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout Payout object.
 * @return bool
 */
function affwp_is_payout_retry_enabled( $payout ) {
	$class     = (string) ( $payout->failure_class ?? '' );
	$is_failed = 'failed' === $payout->status;

	// A payout that has already been replaced by a retry can't be retried again.
	if ( (int) ( $payout->superseded_by_payout_id ?? 0 ) > 0 ) {
		return false;
	}

	return $is_failed && ( '' === $class || \AffWP\Payouts\Failure_Class::is_retryable( $class ) );
}

/**
 * Returns a human-readable label for a payout method.
 *
 * Always returns a clean label — no "(unknown)" or "(unavailable)" qualifiers. Surfaces
 * that need to flag a disabled or unrecognized method should compose their own qualifier
 * locally so the warning is scoped to the context that actually needs it.
 *
 * @since 2.4
 *
 * @param string $method Method identifier.
 * @return string
 */
function affwp_get_payout_method_display_label( $method ) {
	$all = affwp_get_payout_methods();

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

	/*
	 * Canonical labels for core methods, used when the live registry lacks the method
	 * (add-on inactive, or its label carried HTML) so brand casing stays correct for
	 * historical records and previews rather than a bare ucfirst().
	 */
	$canonical = array(
		'manual'          => __( 'Manual', 'affiliate-wp' ),
		'paypal'          => __( 'PayPal', 'affiliate-wp' ),
		'stripe'          => __( 'Stripe', 'affiliate-wp' ),
		'store_credit'    => __( 'Store Credit', 'affiliate-wp' ),
		'payouts-service' => __( 'Payouts Service', 'affiliate-wp' ),
	);

	$fallback = $canonical[ $method ] ?? ucfirst( str_replace( array( '_', '-' ), ' ', $method ) );

	if ( ! isset( $all[ $method ] ) ) {
		return $fallback;
	}

	$raw = $all[ $method ];

	// Legacy labels could contain HTML; fall back to the cleaned identifier so the UI renders safely.
	if ( $raw !== wp_strip_all_tags( $raw ) ) {
		return $fallback;
	}

	return $raw;
}

/**
 * Returns <img> markup for a payout method's brand mark, sized to the requested CSS class.
 *
 * Each method's brand SVG lives under `includes/payouts/methods/{method}/assets/icon.svg`.
 * Returns an empty string for methods that don't ship an icon. Callers should be tolerant
 * of this and lay out without an icon when the return is empty.
 *
 * @since 2.33.0
 *
 * @param string $method     Method identifier.
 * @param string $class_attr CSS class string applied to the <img>. Defaults to size-4 shrink-0.
 * @return string Escaped <img> markup or empty string.
 */
function affwp_get_payout_method_icon( $method, $class_attr = 'size-4 shrink-0' ) {

	$method = sanitize_key( $method );

	$map = [
		'paypal'          => 'paypal-payouts',
		'stripe'          => 'stripe-payouts',
		'store-credit'    => 'store-credit',
		'storecredit'     => 'store-credit',
		'store_credit'    => 'store-credit',
		'payouts-service' => 'payouts-service',
		'payoutsservice'  => 'payouts-service',
		'payouts_service' => 'payouts-service',
		'manual'          => 'manual-payout',
	];

	if ( isset( $map[ $method ] ) ) {
		$url = AFFILIATEWP_PLUGIN_URL . 'includes/payouts/methods/' . $map[ $method ] . '/assets/icon.svg';
		return sprintf(
			'<img src="%s" alt="" class="%s" aria-hidden="true">',
			esc_url( $url ),
			esc_attr( $class_attr )
		);
	}

	return '';
}

/**
 * Returns a preflight readiness status for an affiliate against a payout method.
 *
 * Used by the Preview Payout view to annotate per-row badges and the section banner.
 * Each method gets its own logic — methods that can route around missing setup
 * (like PayPal's invite flow) return 'invite' rather than 'blocked' so the admin
 * sees the actual outcome rather than a hard failure that won't actually happen.
 *
 * Possible return values:
 * - 'ready'   — affiliate has the required account/identity set up
 * - 'invite'  — affiliate lacks the explicit setup but the method's API will invite them
 * - 'blocked' — affiliate is missing required data and the payout will fail
 *
 * @since 2.33.0
 *
 * @param int    $affiliate_id Affiliate ID.
 * @param string $method       Payout method key.
 * @return string One of 'ready', 'invite', or 'blocked'.
 */
function affwp_preflight_payout_status( $affiliate_id, $method ) {

	$status = 'ready';

	switch ( $method ) {

		case 'paypal':
			$affiliate = affwp_get_affiliate( $affiliate_id );
			if ( ! $affiliate ) {
				$status = 'blocked';
				break;
			}

			// An explicit, valid Payment Email is ready: a known-good address is configured for payouts.
			if ( ! empty( $affiliate->payment_email ) && is_email( $affiliate->payment_email ) ) {
				$status = 'ready';
				break;
			}

			/*
			 * No explicit Payment Email: PayPal sends to the affiliate's WP account email. The
			 * payout still flows; the affiliate has 30 days to claim via PayPal sign-up if no
			 * account exists at that address.
			 */
			$user_email = affwp_get_affiliate_email( $affiliate_id );
			if ( $user_email && is_email( $user_email ) ) {
				$status = 'invite';
				break;
			}

			$status = 'blocked';
			break;

		case 'stripe':
			$user_id           = affwp_get_affiliate_user_id( $affiliate_id );
			$stripe_account_id = $user_id ? get_user_meta( $user_id, 'affwp_stripe_payouts_account_id', true ) : '';

			// Stripe uses OAuth: only the affiliate can connect, so without a stored account ID the payout has nowhere to land.
			$status = ! empty( $stripe_account_id ) ? 'ready' : 'blocked';
			break;
	}

	return $status;
}

/**
 * Builds the "load more" button label for a Preview Payout section.
 *
 * Two-state copy, both phrased as "more": while more than one batch is still
 * hidden, the label pairs the size of the *next* click with how many rows are
 * hidden right now ("Show 100 more · 412 hidden"). On the final click the
 * "· N hidden" suffix drops off ("Show 25 more"), so the missing suffix is the
 * cue that one more click reveals everything left.
 *
 * Mirrors the JS `buildLoadMoreLabel()` in assets/js/admin-preview-payout.js so
 * server-rendered and AJAX-updated buttons read identically.
 *
 * @since 2.33.0
 *
 * @param int $remaining Rows still hidden right now.
 * @param int $batch     Maximum rows the next click will reveal.
 * @return string Localized button copy (escaped at the render site).
 */
function affwp_get_preview_load_more_label( $remaining, $batch ) {

	$remaining = max( 0, (int) $remaining );
	$batch     = max( 1, (int) $batch );

	if ( $remaining > $batch ) {
		return sprintf(
			/* translators: 1: rows the next click reveals, 2: total rows still hidden right now. */
			__( 'Show %1$d more · %2$d hidden', 'affiliate-wp' ),
			$batch,
			$remaining
		);
	}

	return sprintf(
		/* translators: %d: rows the final click reveals. */
		__( 'Show %d more', 'affiliate-wp' ),
		$remaining
	);
}

/**
 * Initial number of rows to render for a Preview Payout section before the
 * "load more" button takes over.
 *
 * A section at or under the cap renders in full with no button. A small `$snap`
 * of headroom also renders the section in full when only a few rows would
 * otherwise sit behind the button, so a near-miss never shows a "Show 2 more"
 * stub. Past that, the section caps at `$cap` and the rest load on demand.
 *
 * @since 2.34.0
 *
 * @param int $count Total rows in the section.
 * @param int $cap   Initial render cap. 0 or less renders every row.
 * @param int $snap  Extra rows tolerated before the button appears. Default 0.
 * @return int Rows to render up front (never more than $count).
 */
function affwp_get_preview_visible_count( $count, $cap, $snap = 0 ) {

	$count = max( 0, (int) $count );
	$cap   = (int) $cap;
	$snap  = max( 0, (int) $snap );

	if ( $cap <= 0 || $count <= $cap + $snap ) {
		return $count;
	}

	return $cap;
}

/**
 * Bulk-fetches the affiliate + user records needed by the preview payout row renderers
 * for a set of affiliate IDs, returning a lookup keyed by affiliate_id.
 *
 * The per-row render path otherwise issues 3+ lookups per affiliate (`affwp_get_affiliate`,
 * `get_affiliate_name`, `affwp_get_affiliate_email`). With a 4k-eligible run that meant ~12k
 * queries before cache warm. This helper preloads the affiliate rows + their user accounts
 * with two queries, then the row renderer reads from the lookup at O(1).
 *
 * @since 2.33.0
 *
 * @param int[] $affiliate_ids List of affiliate IDs to preload.
 * @return array Map of affiliate_id => [ 'affiliate' => AffWP\Affiliate|false, 'name' => string|null, 'email' => string|false ].
 */
function affwp_preload_preview_payout_affiliates( array $affiliate_ids ) {

	$lookup = array();

	$ids = array_values( array_unique( array_filter( array_map( 'absint', $affiliate_ids ) ) ) );
	if ( empty( $ids ) ) {
		return $lookup;
	}

	$affiliates = affiliate_wp()->affiliates->get_affiliates(
		array(
			'affiliate_id' => $ids,
			'number'       => -1,
		)
	);

	$by_id   = array();
	$user_ids = array();
	foreach ( (array) $affiliates as $affiliate ) {
		if ( ! is_object( $affiliate ) || ! isset( $affiliate->affiliate_id ) ) {
			continue;
		}
		$by_id[ (int) $affiliate->affiliate_id ] = $affiliate;
		if ( ! empty( $affiliate->user_id ) ) {
			$user_ids[] = (int) $affiliate->user_id;
		}
	}

	// Warm the user cache in one query so get_userdata() below doesn't run a SELECT per row.
	if ( ! empty( $user_ids ) && function_exists( 'cache_users' ) ) {
		cache_users( array_values( array_unique( $user_ids ) ) );
	}

	foreach ( $ids as $affiliate_id ) {
		$affiliate = $by_id[ $affiliate_id ] ?? false;
		$name      = $affiliate ? affiliate_wp()->affiliates->get_affiliate_name( $affiliate_id ) : null;

		$email = false;
		if ( $affiliate && ! empty( $affiliate->user_id ) ) {
			$user = get_userdata( (int) $affiliate->user_id );
			if ( $user && ! empty( $user->user_email ) && is_email( $user->user_email ) ) {
				$email = $user->user_email;
			}
		}

		$lookup[ $affiliate_id ] = array(
			'affiliate' => $affiliate,
			'name'      => $name,
			'email'     => $email,
		);
	}

	return $lookup;
}

/**
 * Renders the affiliate identity cell (avatar + name, with an optional secondary line)
 * shared by the Preview Payout rows.
 *
 * Output is a single `role="cell"` so it drops straight into the grid rows. The name links
 * to $view_url when both are present; an empty $name renders the "(user deleted)" state.
 *
 * @since 2.34.0
 *
 * @param array $args {
 *     @type string     $name     Affiliate display name. Empty renders the "(user deleted)" state.
 *     @type string|int $email    Avatar source (email or user ID) for the Gravatar lookup.
 *     @type string     $view_url Affiliate view URL. When set and a name exists, the name links here.
 *     @type string     $meta     Optional secondary line (e.g. "1 referral · name@example.com").
 *     @type int        $size     Avatar diameter in px. Default 32.
 *     @type string     $class    Extra classes appended to the cell container. Optional.
 * }
 * @return string Escaped HTML for the identity cell.
 */
function affwp_render_preview_affiliate_cell( $args = [] ) {

	$args = wp_parse_args(
		$args,
		[
			'name'     => '',
			'email'    => '',
			'view_url' => '',
			'meta'     => '',
			'size'     => 32,
			'class'    => '',
		]
	);

	$name     = (string) $args['name'];
	$view_url = (string) $args['view_url'];
	$meta     = (string) $args['meta'];
	$classes  = trim( 'flex items-center min-w-0 ' . (string) $args['class'] );

	ob_start();
	?>
	<div role="cell" class="<?php echo esc_attr( $classes ); ?>">
		<div>
			<?php echo affwp_render_avatar( [ 'name' => $name, 'source' => (string) $args['email'], 'size' => (int) $args['size'] ] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper outputs escaped markup. ?>
		</div>
		<div class="ml-3 min-w-0 flex-1">
			<?php if ( '' !== $name ) : ?>
				<p class="text-sm font-semibold text-gray-900 m-0">
					<?php if ( '' !== $view_url ) : ?>
						<a href="<?php echo esc_url( $view_url ); ?>" target="_blank" class="hover:underline"><?php echo esc_html( $name ); ?></a>
					<?php else : ?>
						<?php echo esc_html( $name ); ?>
					<?php endif; ?>
				</p>
			<?php else : ?>
				<p class="text-sm font-semibold text-gray-400 m-0"><?php esc_html_e( '(user deleted)', 'affiliate-wp' ); ?></p>
			<?php endif; ?>
			<?php if ( '' !== $meta ) : ?>
				<p class="text-sm text-gray-500 m-0"><?php echo esc_html( $meta ); ?></p>
			<?php endif; ?>
		</div>
	</div>
	<?php
	return ob_get_clean();
}

/**
 * Renders a single affiliate row inside a Preview Payout per-method section.
 *
 * Factored out of the view so the visible-rows loop and the x-collapse overflow loop emit
 * identical markup. Returns a div-based row (not a <tr>) because x-collapse animates height
 * on block-level containers, not table rows.
 *
 * @since 2.33.0
 *
 * @param int        $affiliate_id Affiliate ID.
 * @param array      $payout       Per-affiliate payout record: { amount, referrals }.
 * @param array|null $lookup       Optional bulk-prefetch lookup (see
 *                                 {@see affwp_preload_preview_payout_affiliates()}) used to
 *                                 skip the per-row affiliate/name/email queries when the
 *                                 caller has already prefetched. Pass `null` to fall back
 *                                 to per-row lookups (backwards compatible).
 * @return string Escaped HTML for one row.
 */
function affwp_render_preview_payout_row( $affiliate_id, $payout, $lookup = null ) {

	if ( is_array( $lookup ) && isset( $lookup[ $affiliate_id ] ) ) {
		$affiliate = $lookup[ $affiliate_id ]['affiliate'];
		$name      = $lookup[ $affiliate_id ]['name'];
		$email     = $lookup[ $affiliate_id ]['email'];
	} else {
		$affiliate = affwp_get_affiliate( $affiliate_id );
		$name      = affiliate_wp()->affiliates->get_affiliate_name( $affiliate_id );
		$email     = $affiliate ? affwp_get_affiliate_email( $affiliate_id ) : false;
	}
	$ref_count = isset( $payout['referrals'] ) ? count( $payout['referrals'] ) : 0;
	$view_url  = affwp_admin_url(
		'affiliates',
		array(
			'action'       => 'view_affiliate',
			'affiliate_id' => $affiliate_id,
		)
	);
	$amount = isset( $payout['amount'] ) ? $payout['amount'] : 0;

	$meta_parts = array();
	if ( $ref_count > 0 ) {
		$meta_parts[] = sprintf(
			/* translators: %d: number of referrals */
			_n( '%d referral', '%d referrals', $ref_count, 'affiliate-wp' ),
			$ref_count
		);
	}
	if ( $email ) {
		$meta_parts[] = $email;
	}

	$status = isset( $payout['status'] ) ? $payout['status'] : 'ready';

	switch ( $status ) {
		case 'blocked':
			$badge = affwp_get_badge(
				array(
					'text'    => __( 'Not connected', 'affiliate-wp' ),
					'variant' => 'warning',
				)
			);
			break;
		default:
			// 'ready' and 'invite' (account-email fallback) are both normal payouts and render no
			// badge, so the eye latches onto the genuine blockers.
			$badge = '';
			break;
	}

	$amount_class = 'blocked' === $status ? 'text-gray-400 line-through' : 'text-gray-900';

	ob_start();
	?>
	<div role="row" class="grid grid-cols-[1fr_auto_8rem] items-start gap-4 px-6 py-2.5">
		<?php
		echo affwp_render_preview_affiliate_cell( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper outputs escaped markup.
			[
				'name'     => $name,
				'email'    => $email,
				'view_url' => $view_url,
				'meta'     => implode( ' · ', $meta_parts ),
			]
		);
		?>
		<div role="cell"><?php echo $badge; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper outputs escaped markup. ?></div>
		<div role="cell" class="text-right font-medium tabular-nums <?php echo esc_attr( $amount_class ); ?>"><?php echo affwp_currency_filter( affwp_format_amount( $amount ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- entity-encoded HTML from helper. ?></div>
	</div>
	<?php
	return ob_get_clean();
}

/**
 * Renders a single row inside the Preview Payout "Cannot be paid this run" section.
 *
 * The invalid section uses div-based rows (not <tr>) so the visible/load-more pipeline
 * shares the same DOM contract as the per-method sections — the AJAX handler appends
 * into a `role="rowgroup"` container in both cases.
 *
 * @since 2.33.0
 *
 * @param int        $affiliate_id Affiliate ID.
 * @param string     $status       Failure reason key (see
 *                                 {@see affwp_get_preview_payout_request_failed_reason_label()}).
 * @param array|null $lookup       Optional bulk-prefetch lookup (see
 *                                 {@see affwp_preload_preview_payout_affiliates()}).
 * @return string Escaped HTML for one row.
 */
function affwp_render_preview_invalid_row( $affiliate_id, $status, $lookup = null ) {

	if ( is_array( $lookup ) && isset( $lookup[ $affiliate_id ] ) ) {
		$affiliate = $lookup[ $affiliate_id ]['affiliate'];
		$name      = $lookup[ $affiliate_id ]['name'];
		$email     = $lookup[ $affiliate_id ]['email'];
	} else {
		$affiliate = affwp_get_affiliate( $affiliate_id );
		$name      = affiliate_wp()->affiliates->get_affiliate_name( $affiliate_id );
		$email     = $affiliate ? affwp_get_affiliate_email( $affiliate_id ) : false;
	}

	$view_url = affwp_admin_url(
		'affiliates',
		array(
			'action'       => 'view_affiliate',
			'affiliate_id' => $affiliate_id,
		)
	);

	$reason_label = affwp_get_preview_payout_request_failed_reason_label( $status );

	$resolve_url = '';
	if ( 'previous_payout_failed' === $status ) {
		$resolve_url = affwp_admin_url(
			'payouts',
			array(
				'status'       => 'failed',
				'affiliate_id' => $affiliate_id,
			)
		);
	}

	ob_start();
	?>
	<div role="row" class="grid grid-cols-[1fr_minmax(0,33%)] items-start gap-4 px-6 py-2.5">
		<?php
		echo affwp_render_preview_affiliate_cell( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper outputs escaped markup.
			[
				'name'     => $name,
				'email'    => $email,
				'view_url' => $view_url,
				'meta'     => $email ? $email : '',
			]
		);
		?>
		<div role="cell" class="text-gray-600 wrap-anywhere">
			<?php
			/*
			 * Reason labels (see affwp_get_preview_payout_request_failed_reasons) are
			 * intentionally phrase-form without trailing punctuation so they read
			 * cleanly when shown alone. When followed by a capitalized CTA link,
			 * add the period so the two read as separate sentences instead of a
			 * run-on phrase.
			 */
			echo esc_html( $reason_label );
			echo $resolve_url ? '.' : '';
			?>
			<?php if ( $resolve_url ) : ?>
				<a href="<?php echo esc_url( $resolve_url ); ?>" class="text-affwp-brand-500 hover:underline whitespace-nowrap"><?php esc_html_e( 'View failed payouts', 'affiliate-wp' ); ?></a>
			<?php endif; ?>
		</div>
	</div>
	<?php
	return ob_get_clean();
}

/**
 * Returns an affiliate-facing label for a payout method.
 *
 * Verb-form labels that answer the affiliate's mental question "how am I being paid?"
 * instead of the noun-form product names ("PayPal Payouts") used on admin surfaces.
 * Used by the affiliate-area Payout Method picker, the status line, and any other
 * affiliate-facing surface that names a payout method.
 *
 * Falls back to the registry display label for any method not in the default map —
 * so add-on methods continue to render with their registered names.
 *
 * @since 2.33.0
 *
 * @param string $method Method identifier.
 * @return string
 */
function affwp_get_payout_method_affiliate_label( $method ) {

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

	$labels = array(
		'manual'          => __( 'Paid directly', 'affiliate-wp' ),
		'paypal'          => __( 'Paid via PayPal', 'affiliate-wp' ),
		'stripe'          => __( 'Paid via Stripe', 'affiliate-wp' ),
		'store_credit'    => __( 'Paid via Store Credit', 'affiliate-wp' ),
		'payouts-service' => __( 'Paid via Payouts Service', 'affiliate-wp' ),
	);

	if ( isset( $labels[ $method ] ) ) {
		return $labels[ $method ];
	}

	return affwp_get_payout_method_display_label( $method );
}

/**
 * Registers the Payout Method column on the core Referrals admin list table.
 *
 * @since 2.33.0
 *
 * @param array $prepared_columns Prepared columns.
 * @param array $columns          The columns for this list table.
 * @param mixed $instance         List table instance.
 * @return array
 */
function affwp_register_referral_payout_method_column( $prepared_columns, $columns = array(), $instance = null ) {

	if ( isset( $prepared_columns['payout_method'] ) ) {
		return $prepared_columns;
	}

	$type_index = array_search( 'type', array_keys( $prepared_columns ), true );
	$offset     = false === $type_index ? count( $prepared_columns ) : $type_index + 1;

	$prepared_columns = array_slice( $prepared_columns, 0, $offset, true ) +
		array( 'payout_method' => __( 'Payout Method', 'affiliate-wp' ) ) +
		array_slice( $prepared_columns, $offset, null, true );

	return $prepared_columns;
}
add_filter( 'affwp_referral_table_columns', 'affwp_register_referral_payout_method_column', 5, 3 );

/**
 * Provides the Payout Method value for a referral row.
 *
 * Paid referrals read the actual method from the linked payout record so history is
 * accurate. Unpaid referrals read the affiliate's configured method (what they *would*
 * be paid with if paid today).
 *
 * @since 2.33.0
 *
 * @param string          $value    Existing value.
 * @param \AffWP\Referral $referral Referral object.
 * @return string
 */
function affwp_render_referral_payout_method_column_value( $value, $referral ) {

	if ( 'paid' === $referral->status ) {
		// Historical context: the method that actually ran this payout.
		$method = affwp_get_referral_historical_payout_method( $referral );
	} else {
		// Effective context: the method that will pay this referral when the next batch runs.
		$method = affwp_get_affiliate_effective_method( $referral->affiliate_id );
	}

	if ( empty( $method ) ) {
		return $value;
	}

	return affwp_get_payout_method_display_label( $method );
}
add_filter( 'affwp_referral_table_payout_method', 'affwp_render_referral_payout_method_column_value', 5, 2 );

/**
 * Registers the Payout Method column on the core Affiliates admin list table.
 *
 * Positioned directly after the Rate column so the two per-affiliate payout settings
 * (how much they earn, where it goes) sit together, before the activity columns.
 *
 * @since 2.33.0
 *
 * @param array $prepared_columns Prepared columns.
 * @param array $columns          The columns for this list table.
 * @param mixed $instance         List table instance.
 * @return array
 */
function affwp_register_affiliate_payout_method_column( $prepared_columns, $columns = array(), $instance = null ) {

	if ( isset( $prepared_columns['payout_method'] ) ) {
		return $prepared_columns;
	}

	$keys   = array_keys( $prepared_columns );
	$offset = array_search( 'rate', $keys, true );
	$offset = false === $offset ? count( $prepared_columns ) : $offset + 1;

	$prepared_columns = array_slice( $prepared_columns, 0, $offset, true ) +
		array( 'payout_method' => __( 'Payout Method', 'affiliate-wp' ) ) +
		array_slice( $prepared_columns, $offset, null, true );

	return $prepared_columns;
}
add_filter( 'affwp_affiliate_table_columns', 'affwp_register_affiliate_payout_method_column', 5, 3 );

/**
 * Provides the Payout Method value for an affiliate row.
 *
 * Shows the affiliate's effective method — what their next batch payout will route
 * through. Always resolves to a value (falls back to site default or 'manual').
 *
 * @since 2.33.0
 *
 * @param string           $value     Existing value.
 * @param \AffWP\Affiliate $affiliate Affiliate object.
 * @return string
 */
function affwp_render_affiliate_payout_method_column_value( $value, $affiliate ) {

	$method = affwp_get_affiliate_effective_method( $affiliate );

	if ( empty( $method ) ) {
		return $value;
	}

	return affwp_get_payout_method_display_label( $method );
}
add_filter( 'affwp_affiliate_table_payout_method', 'affwp_render_affiliate_payout_method_column_value', 5, 2 );

/**
 * Returns whether affiliates can choose their own payout method from the Affiliate Area.
 *
 * Defaults to OFF on every install. Affiliate self-selection is opt-in: the Affiliate Area
 * dropdown only routes affiliate money once the admin deliberately turns it on, so the choice
 * never appears as a side effect of enabling a second payout method. The Payouts settings tab
 * nudges the admin to enable it once two or more methods are available.
 *
 * @since 2.33.0
 *
 * @return bool
 */
function affwp_affiliates_can_select_payout_method() {
	return (bool) affiliate_wp()->settings->get( 'allow_affiliate_payout_method_selection', false );
}

/**
 * Retrieves the site-default payout method used when an affiliate has no method set.
 *
 * @since 2.33.0
 *
 * @return string Method identifier (e.g. 'manual', 'paypal'). Falls back to 'manual' if the
 *                configured default isn't currently enabled.
 */
function affwp_get_default_payout_method() {

	$configured = affiliate_wp()->settings->get( 'default_payout_method', 'manual' );

	if ( ! affwp_is_payout_method_enabled( $configured ) ) {
		/*
		 * Fall back to Manual only if it's enabled (an add-on can filter it out); otherwise
		 * the first enabled method, so the resolver always returns an enabled method.
		 */
		if ( affwp_is_payout_method_enabled( 'manual' ) ) {
			$configured = 'manual';
		} else {
			$enabled    = function_exists( 'affwp_get_enabled_payout_methods' ) ? array_values( affwp_get_enabled_payout_methods() ) : array();
			$configured = ! empty( $enabled ) ? (string) $enabled[0] : 'manual';
		}
	}

	/**
	 * Filters the resolved site-default payout method.
	 *
	 * @since 2.33.0
	 *
	 * @param string $configured The resolved default payout method.
	 */
	return apply_filters( 'affwp_default_payout_method', $configured );
}

/**
 * Retrieves the configured payout method for an affiliate, resolved in priority order:
 * admin override → affiliate's own pick → site default.
 *
 * The admin override (`payout_method_override` meta) lets a site admin force a specific
 * method for an affiliate from the Edit Affiliate screen. When set, it wins over the
 * affiliate's self-selected method. When not set, the affiliate's pick applies (or the
 * site default if they haven't picked).
 *
 * Does NOT check whether the method is currently configured or that the affiliate has valid
 * destination details (e.g. a PayPal email) — callers that want to actually route a payment
 * are responsible for that check, so they can surface a warning instead of silently retargeting.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @return string Method identifier.
 */
function affwp_get_affiliate_payout_method( $affiliate ) {

	$affiliate = affwp_get_affiliate( $affiliate );

	if ( ! $affiliate ) {
		return affwp_get_default_payout_method();
	}

	$override = affwp_get_affiliate_meta( $affiliate->affiliate_id, 'payout_method_override', true );

	if ( ! empty( $override ) ) {
		$method = $override;
	} else {
		$method = affwp_get_affiliate_meta( $affiliate->affiliate_id, 'payout_method', true );

		if ( empty( $method ) ) {
			$method = affwp_get_default_payout_method();
		}
	}

	/**
	 * Filters the resolved payout method for an affiliate.
	 *
	 * @since 2.33.0
	 *
	 * @param string             $method    Resolved payout method identifier.
	 * @param \AffWP\Affiliate   $affiliate Affiliate object.
	 */
	return apply_filters( 'affwp_get_affiliate_payout_method', $method, $affiliate );
}

/**
 * Returns the admin-set payout method override for an affiliate, or an empty string when
 * no override is set.
 *
 * The override is the value set by a site admin from the Edit Affiliate screen — distinct
 * from the affiliate's own pick (stored in `payout_method` meta). Use this when the UI
 * needs to know whether an admin override exists, separately from the resolved effective
 * method.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @return string Method identifier, or empty when no override is set.
 */
function affwp_get_affiliate_payout_method_override( $affiliate ) {

	$affiliate = affwp_get_affiliate( $affiliate );

	if ( ! $affiliate ) {
		return '';
	}

	$override = affwp_get_affiliate_meta( $affiliate->affiliate_id, 'payout_method_override', true );

	return is_string( $override ) ? $override : '';
}

/**
 * Returns whether an admin override is currently set for an affiliate's payout method.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @return bool True when an override is set, false otherwise.
 */
function affwp_has_payout_method_override( $affiliate ) {
	return '' !== affwp_get_affiliate_payout_method_override( $affiliate );
}

/**
 * Returns the affiliate's *own* saved payout method (their dashboard pick), or an empty
 * string when they haven't picked one. Bypasses both the admin override and the site
 * default fallback so the caller sees the raw affiliate-selected value.
 *
 * Use this when the UI needs to show "Affiliate picked: X" alongside an admin override,
 * or when audit logic needs to distinguish "no pick" from "fell back to default."
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @return string Method identifier, or empty when the affiliate has not picked.
 */
function affwp_get_affiliate_payout_method_pick( $affiliate ) {

	$affiliate = affwp_get_affiliate( $affiliate );

	if ( ! $affiliate ) {
		return '';
	}

	$pick = affwp_get_affiliate_meta( $affiliate->affiliate_id, 'payout_method', true );

	return is_string( $pick ) ? $pick : '';
}

/**
 * Resolves the affiliate's payout method to a value that's currently usable.
 *
 * `affwp_get_affiliate_payout_method()` returns whatever's saved in meta, even when the
 * saved method has since been disabled site-wide. That's correct for the resolver (callers
 * processing a payout need to know the literal saved value so they can warn / route around
 * it), but it's wrong for affiliate-facing display, where surfacing "Paid via PayPal
 * (unavailable)" is more confusing than helpful.
 *
 * This helper returns the saved method when it's currently enabled, falling back to the
 * site default otherwise. The site default itself falls back to 'manual' when not enabled,
 * so this always returns a usable identifier.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @return string Method identifier that is currently enabled, or 'manual'.
 */
function affwp_get_affiliate_usable_payout_method( $affiliate ) {

	$method = affwp_get_affiliate_payout_method( $affiliate );

	$usable = affwp_is_payout_method_enabled( $method ) ? $method : affwp_get_default_payout_method();

	/**
	 * Filters the affiliate's usable (effective) payout method, after the enabled-method / site-default
	 * fallback. Lets a payout method declare itself unusable for a specific affiliate — e.g. Stripe when
	 * the platform can't pay the affiliate's country — so the affiliate falls back to the site default.
	 *
	 * @since 2.34.0
	 *
	 * @param string              $usable    The resolved usable method.
	 * @param string              $method    The affiliate's explicitly assigned method.
	 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
	 */
	return apply_filters( 'affwp_affiliate_usable_payout_method', $usable, $method, $affiliate );
}

/*
 * Context-named accessors.
 *
 * The codebase has historically conflated three different questions about an affiliate's
 * payout method, leading to UI surfaces reading the wrong source of truth and disagreeing
 * with each other (e.g. affiliate area showing PayPal while Referrals list shows "Stripe
 * (unknown)"). These named helpers make each question explicit at the call site:
 *
 *   - Effective: "what method will/does apply when a batch runs?" → resolver
 *   - Explicit:  "what did the affiliate explicitly pick?" → raw saved meta
 *   - Historical: "what method actually ran this referral's payout?" → payout record
 *
 * Surfaces should declare their context by calling the matching helper. Mixing contexts
 * at a single call site is almost always a bug.
 */

/**
 * Returns the affiliate's *effective* payout method — what will route their payouts when
 * the next batch runs. Wraps `affwp_get_affiliate_usable_payout_method()` with a clearer
 * name that documents intent at the call site.
 *
 * Use this for: affiliate-area status line, picker pre-selection (when there's a saved
 * method), Affiliates list "Payout Method" column, Referrals list (unpaid rows), batch
 * processor routing.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @return string Method identifier (always resolves to something — falls back to default
 *                or 'manual').
 */
function affwp_get_affiliate_effective_method( $affiliate ) {
	return affwp_get_affiliate_usable_payout_method( $affiliate );
}

/**
 * Returns the affiliate's *explicit* saved payout method — what they picked in the
 * Affiliate Area picker. Returns empty when no explicit choice has been made; does NOT
 * fall back to the site default. Wraps `affwp_get_affiliate_payout_method()` with a
 * clearer name.
 *
 * Use this for: "did this affiliate make a choice yet" gating, detecting saved-but-
 * disabled method states.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @return string Method identifier, or empty when no explicit choice.
 */
function affwp_get_affiliate_explicit_method( $affiliate ) {
	return affwp_get_affiliate_payout_method( $affiliate );
}

/**
 * Computes the full payload an affiliate-facing payout-method picker needs to render.
 *
 * Centralizes the picker resolution logic shared by the core Affiliate Area template
 * and the Affiliate Portal addon, so both surfaces agree on: which methods to list,
 * whether the "site default" placeholder option is needed, and which row to preselect.
 *
 * Returned shape:
 *  - allow_choose       (bool)   Whether the admin allows affiliates to self-select.
 *  - options            (array)  slug => affiliate-facing label. Includes the placeholder
 *                                row at key `''` when applicable (placeholder first).
 *  - current_value      (string) Slug to preselect. `''` when the placeholder represents
 *                                the active selection (no explicit choice + default not
 *                                in the selectable list).
 *  - has_placeholder    (bool)   Whether `options` includes the leading placeholder row.
 *  - site_default_key   (string) Resolved site default method identifier.
 *  - site_default_label (string) Affiliate-facing label for the site default.
 *  - should_render      (bool)   Whether the picker dropdown should render at all.
 *                                False when self-selection is off or fewer than 2 rows
 *                                exist — callers fall through to a status line instead.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @return array{
 *     allow_choose: bool,
 *     options: array<string, string>,
 *     current_value: string,
 *     has_placeholder: bool,
 *     site_default_key: string,
 *     site_default_label: string,
 *     should_render: bool,
 * }
 */
function affwp_get_affiliate_picker_options( $affiliate ) {

	$affiliate_obj = affwp_get_affiliate( $affiliate );
	$affiliate_id  = $affiliate_obj ? $affiliate_obj->affiliate_id : 0;

	$allow_choose       = affwp_affiliates_can_select_payout_method();
	$site_default_key   = affwp_get_default_payout_method();
	$site_default_label = affwp_get_payout_method_affiliate_label( $site_default_key );

	$override_value = $affiliate_id ? affwp_get_affiliate_payout_method_override( $affiliate_id ) : '';
	$is_locked      = '' !== $override_value;

	/*
	 * The method the locked affiliate is actually paid via: the override, unless it's since
	 * been disabled, in which case they fall back to the usable method (site default). Showing
	 * the dead override here would disagree with what they're paid.
	 */
	$locked_method = $override_value;
	if ( $is_locked
		&& function_exists( 'affwp_is_payout_method_enabled' )
		&& ! affwp_is_payout_method_enabled( $override_value )
	) {
		$locked_method = affwp_get_affiliate_usable_payout_method( $affiliate_id );
	}

	$methods = affwp_get_payout_methods_for_display( 'affiliate' );

	$result = array(
		'allow_choose'         => $allow_choose,
		'options'              => array(),
		'current_value'        => '',
		'has_placeholder'      => false,
		'site_default_key'     => (string) $site_default_key,
		'site_default_label'   => (string) $site_default_label,
		'should_render'        => false,
		'is_locked_by_admin'   => $is_locked,
		'admin_override_value' => (string) $override_value,
		'admin_override_label' => $is_locked ? (string) affwp_get_payout_method_affiliate_label( $locked_method ) : '',
	);

	/*
	 * Admin override short-circuits the picker into a single-option, locked representation.
	 * The affiliate sees what they're paid via (the override, or its fallback when disabled)
	 * but cannot change it.
	 */
	if ( $is_locked ) {
		$result['options']       = array( $locked_method => $result['admin_override_label'] );
		$result['current_value'] = (string) $locked_method;
		$result['should_render'] = true;
		return $result;
	}

	if ( ! $allow_choose ) {
		return $result;
	}

	$saved_value = $affiliate_id ? affwp_get_affiliate_meta( $affiliate_id, 'payout_method', true ) : '';

	/*
	 * An enabled saved pick is a real choice even if it's since been hidden from the picker
	 * (hidden blocks new selections; it must not drop an affiliate already on the method).
	 * Treat it as explicit and inject it as an option below, so it shows as their selection
	 * and saving preserves it instead of reverting to the site default.
	 */
	$saved_is_enabled    = ! empty( $saved_value )
		&& function_exists( 'affwp_is_payout_method_enabled' )
		&& affwp_is_payout_method_enabled( $saved_value );
	$has_explicit_choice = $saved_is_enabled || ( ! empty( $saved_value ) && isset( $methods[ $saved_value ] ) );

	/*
	 * The placeholder row is only useful when the site default is NOT one of the
	 * affiliate-selectable methods (e.g. default = Manual; default = hidden via
	 * `affwp_hidden_affiliate_payout_methods`). When the default IS already in the
	 * picker, the placeholder + the explicit option route to identical outcomes and
	 * the duplication confuses the affiliate.
	 */
	$show_default_placeholder = '' !== $site_default_key && ! isset( $methods[ $site_default_key ] );

	$options = array();

	if ( $show_default_placeholder ) {
		/*
		 * Plain affiliate label, no "(default)" suffix: "default" is a site-configuration
		 * concept that doesn't aid the affiliate's choice, and the picker's checkmark already
		 * marks their current method.
		 */
		$placeholder_label = ! empty( $site_default_label )
			? $site_default_label
			: __( 'Use site default', 'affiliate-wp' );

		$options[''] = $placeholder_label;
	}

	/*
	 * Surface the site default first so it reads as the "primary" option the affiliate
	 * is already on, with the alternatives following. No "(default)" suffix: it's a
	 * site-configuration concept that doesn't aid the affiliate's choice, and the picker's
	 * checkmark already shows their current method. When the default is admin-only
	 * (Manual) this branch is a no-op (the default isn't in `$methods`); the placeholder
	 * above covers that case, where naming the default carries real meaning.
	 */
	if ( '' !== $site_default_key && isset( $methods[ $site_default_key ] ) ) {
		$options[ $site_default_key ] = affwp_get_payout_method_affiliate_label( $site_default_key );
	}

	foreach ( $methods as $method_key => $method_label ) {
		if ( isset( $options[ $method_key ] ) ) {
			continue;
		}
		$options[ $method_key ] = affwp_get_payout_method_affiliate_label( $method_key );
	}

	// Keep the affiliate's current enabled pick selectable even when hidden, so it isn't lost on save.
	if ( $has_explicit_choice && ! isset( $options[ $saved_value ] ) ) {
		$options[ $saved_value ] = affwp_get_payout_method_affiliate_label( $saved_value );
	}

	if ( $has_explicit_choice ) {
		$current_value = (string) $saved_value;
	} elseif ( $show_default_placeholder ) {
		$current_value = '';
	} else {
		$current_value = (string) $site_default_key;
	}

	$result['options']         = $options;
	$result['current_value']   = $current_value;
	$result['has_placeholder'] = $show_default_placeholder;
	// Two or more selectable rows is the threshold; the placeholder counts as a row because picking it is a real outcome.
	$result['should_render']   = count( $options ) >= 2;

	return $result;
}

/**
 * Persists an affiliate's picker selection, with shared validation across surfaces.
 *
 * Validation order:
 *  1. Empty raw value is a NO-OP — preserves prior explicit choice (or absence) and
 *     keeps "use site default" semantics intact. This is the contract that the
 *     placeholder row depends on: selecting it must not destroy a previously saved
 *     method, and must not pin the default's current slug into meta (so future admin
 *     changes to the default continue to apply).
 *  2. Method must be visible to affiliates (guards crafted POSTs against assigning
 *     admin-hidden methods through the affiliate-facing form).
 *  3. Method must be currently enabled site-wide (guards stale submissions when the
 *     admin disables a processor between page load and form submit).
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @param mixed                $raw_value Submitted picker value (string-ish).
 * @return true|\WP_Error True on success or NO-OP; WP_Error on validation failure.
 */
function affwp_save_affiliate_picker_choice( $affiliate, $raw_value ) {

	$affiliate_obj = affwp_get_affiliate( $affiliate );

	if ( ! $affiliate_obj ) {
		return new WP_Error(
			'invalid_affiliate',
			__( 'Invalid affiliate.', 'affiliate-wp' )
		);
	}

	/*
	 * Guard against crafted POSTs from a locked picker. With an admin override set the UI is
	 * read-only, so accepting any submitted value would let a hand-crafted request circumvent it.
	 */
	if ( affwp_has_payout_method_override( $affiliate_obj->affiliate_id ) ) {
		return new WP_Error(
			'admin_override_active',
			__( 'Your payout method has been set by the site admin and cannot be changed.', 'affiliate-wp' )
		);
	}

	$value = sanitize_key( (string) $raw_value );

	/*
	 * No-op on empty: the placeholder row submits '', and any "leave it alone" submission
	 * must not delete or overwrite the affiliate's prior state.
	 */
	if ( '' === $value ) {
		return true;
	}

	/*
	 * Retaining the method the affiliate is already on is always allowed, even if it's since
	 * been hidden. Hidden blocks new selections; it must not stop re-saving the current method.
	 */
	$current_pick = (string) affwp_get_affiliate_meta( $affiliate_obj->affiliate_id, 'payout_method', true );
	if ( $value === $current_pick && affwp_is_payout_method_enabled( $value ) ) {
		return true;
	}

	$visible = affwp_get_payout_methods_for_display( 'affiliate' );
	if ( ! isset( $visible[ $value ] ) ) {
		return new WP_Error(
			'invalid_method',
			__( 'That payout method is not available.', 'affiliate-wp' )
		);
	}

	if ( ! affwp_is_payout_method_enabled( $value ) ) {
		return new WP_Error(
			'method_disabled',
			__( 'That payout method is currently disabled.', 'affiliate-wp' )
		);
	}

	affwp_update_affiliate_meta( $affiliate_obj->affiliate_id, 'payout_method', $value );

	return true;
}

/**
 * Returns the *historical* payout method that actually ran a referral's payout. Reads the
 * linked payout record's `payout_method` column, which is the source of truth for "what
 * paid this row." Returns empty when the referral is unpaid or has no linked payout.
 *
 * Use this for: Referrals list (paid rows), Payouts admin list "Payout Method" column,
 * Payout drawer "Method" field, reconciliation reports.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Referral $referral Referral ID or object.
 * @return string Method identifier, or empty when no historical method exists.
 */
function affwp_get_referral_historical_payout_method( $referral ) {

	if ( is_numeric( $referral ) ) {
		$referral = affwp_get_referral( $referral );
	}

	if ( ! $referral || empty( $referral->payout_id ) ) {
		return '';
	}

	$payout = affwp_get_payout( $referral->payout_id );

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

	return (string) $payout->payout_method;
}

/**
 * Payout methods for which the affiliate-facing Payment Email field is relevant.
 *
 * The `affwp_payment_email_relevant_methods` filter (default: PayPal) lets any method declare
 * it needs the field; Manual is always included because the field doubles as the routing
 * reference for affiliates paid outside the system. This is the single list the reactive
 * x-show conditions (Affiliate Area + Portal) are built from.
 *
 * @since 2.33.0
 *
 * @return string[] Method identifiers.
 */
function affwp_get_payment_email_methods() {
	return array( 'paypal', 'manual' );
}

/**
 * Builds an AlpineJS boolean expression that is true when the bound `method` variable is one
 * of the payment-email-relevant methods. Used for the Payment Email field's reactive x-show
 * so it follows affwp_get_payment_email_methods() rather than a hardcoded list.
 *
 * @since 2.33.0
 *
 * @param string $var Alpine variable name to compare. Default 'method'.
 * @return string e.g. "method === 'paypal' || method === 'manual'". Empty string if no methods.
 */
function affwp_payment_email_methods_alpine_condition( $var = 'method' ) {
	$parts = array();

	foreach ( affwp_get_payment_email_methods() as $method ) {
		$parts[] = sprintf( "%s === '%s'", $var, esc_js( $method ) );
	}

	return implode( ' || ', $parts );
}

/**
 * Whether the Payment Email field should be surfaced in affiliate-facing UI.
 *
 * Visibility is method-scoped to the *individual affiliate's* experience, not to the
 * site's overall capabilities. Three gates, any of which surfaces the field:
 *  (a) The affiliate's effective method is in the list of methods that need the email
 *      (PayPal).
 *  (b) The affiliate's effective method is 'manual'. With no automated method routing
 *      payouts, the field serves as a routing reference for whoever is paying the
 *      affiliate outside the system.
 *  (c) Affiliates are allowed to choose their payout method AND at least one selectable
 *      method needs the field. Lets the affiliate pre-fill before switching, instead of
 *      a two-step "switch first, then enter" flow.
 *
 * When affiliate self-selection is off and the affiliate's method doesn't need the
 * field, it hides. Showing a field the affiliate can't act on is dead UI.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
 * @return bool
 */
function affwp_affiliate_needs_payment_email( $affiliate ) {

	$methods_that_need_email = array( 'paypal' );

	$visible = false;

	/*
	 * Admin override short-circuit: when an admin has fixed the method, the pre-fill logic
	 * below doesn't apply (the affiliate can't switch). Visibility depends only on whether the
	 * override method needs the email; manual counts (gate (b)) as the routing reference for
	 * whoever pays the affiliate outside the system.
	 */
	$override = function_exists( 'affwp_get_affiliate_payout_method_override' )
		? affwp_get_affiliate_payout_method_override( $affiliate )
		: '';

	if ( '' !== $override ) {
		// If the override method is now disabled, base the field on the usable method they're actually paid via.
		$effective = ( function_exists( 'affwp_is_payout_method_enabled' ) && ! affwp_is_payout_method_enabled( $override ) )
			? affwp_get_affiliate_usable_payout_method( $affiliate )
			: $override;

		$visible = 'manual' === $effective || in_array( $effective, $methods_that_need_email, true );

		/** This filter is documented further down in this function. */
		return (bool) apply_filters( 'affwp_affiliate_needs_payment_email', $visible, $affiliate );
	}

	$usable = affwp_get_affiliate_usable_payout_method( $affiliate );

	/*
	 * Gate (a): the affiliate's current effective method needs the field.
	 * Gate (b): manual fallback uses the field as a routing reference for whoever pays the
	 * affiliate outside the system.
	 */
	if ( 'manual' === $usable || in_array( $usable, $methods_that_need_email, true ) ) {
		$visible = true;
	}

	/*
	 * Gate (c): when affiliates can choose their method, surface the field if any selectable
	 * method needs it, so they can pre-fill before switching rather than switch-save-then-fill.
	 * When self-selection is off this gate doesn't fire: a field for a method they can't pick
	 * is dead UI.
	 */
	if ( ! $visible
		&& function_exists( 'affwp_affiliates_can_select_payout_method' )
		&& affwp_affiliates_can_select_payout_method()
		&& function_exists( 'affwp_get_payout_methods_for_display' )
	) {
		$selectable = affwp_get_payout_methods_for_display( 'affiliate' );
		foreach ( $methods_that_need_email as $method_id ) {
			if ( isset( $selectable[ $method_id ] ) ) {
				$visible = true;
				break;
			}
		}
	}

	/**
	 * Filters whether the Payment Email field should be surfaced in affiliate-facing UI.
	 *
	 * Provided as a support escape hatch: the field was always-visible pre-2.33.0, and
	 * sites that have built workflows around that assumption can restore the legacy
	 * behavior with a one-liner:
	 *
	 *     add_filter( 'affwp_affiliate_needs_payment_email', '__return_true' );
	 *
	 * @since 2.33.0
	 *
	 * @param bool                 $visible   Whether to show the Payment Email field.
	 * @param int|\AffWP\Affiliate $affiliate Affiliate ID or object.
	 */
	return (bool) apply_filters( 'affwp_affiliate_needs_payment_email', $visible, $affiliate );
}

/**
 * Resolves the payout_method for a batch from the candidate affiliate list.
 *
 * Returns the single method identifier when every candidate resolves to the
 * same method, or 'mixed' when more than one method is represented. Empty input
 * yields 'mixed' as a conservative default. Short-circuits on the second
 * distinct method so large uniform lists don't over-work.
 *
 * Used at batch-row creation time so the Summary card / header / list table
 * reads as the concrete method (e.g. "Stripe") from the moment a processing
 * batch is visible, instead of flipping from "Mixed" to "Stripe" only once
 * the finalize-time recount runs.
 *
 * @since 2.33.0
 *
 * @param int[] $affiliate_ids Candidate affiliate IDs for the batch.
 * @return string Method identifier, or 'mixed'.
 */
function affwp_resolve_batch_payout_method( array $affiliate_ids ): string {

	$methods = array();

	// Prime the affiliate meta cache in one query so the resolver below avoids a meta lookup per row.
	if ( $affiliate_ids ) {
		update_meta_cache( 'affiliate', array_map( 'intval', $affiliate_ids ) );
	}

	foreach ( $affiliate_ids as $affiliate_id ) {
		$method = (string) affwp_get_affiliate_usable_payout_method( (int) $affiliate_id );

		if ( '' === $method ) {
			continue;
		}

		$methods[ $method ] = true;

		if ( count( $methods ) > 1 ) {
			return 'mixed';
		}
	}

	return 1 === count( $methods ) ? (string) array_key_first( $methods ) : 'mixed';
}

/**
 * Retrieves a payout batch object.
 *
 * @since 2.33.0
 *
 * @param int|\AffWP\Affiliate\Payout_Batch $batch Batch ID or object.
 * @return \AffWP\Affiliate\Payout_Batch|false Batch object on success, false on failure.
 */
function affwp_get_payout_batch( $batch = 0 ) {

	if ( is_object( $batch ) && isset( $batch->batch_id ) ) {
		$batch_id = $batch->batch_id;
	} elseif ( is_numeric( $batch ) ) {
		$batch_id = absint( $batch );
	} else {
		return false;
	}

	return affiliate_wp()->affiliates->payout_batches->get_object( $batch_id );
}

/**
 * Adds a new payout batch record.
 *
 * @since 2.33.0
 *
 * @param array $args Batch column values.
 * @return int|false Batch ID on success, false on failure.
 */
function affwp_add_payout_batch( $args = array() ) {
	return affiliate_wp()->affiliates->payout_batches->add( $args );
}

/**
 * Retries a failed payout.
 *
 * Detaches the referrals from the failed payout record, creates a fresh payout
 * inside a batch (new or supplied), and re-runs the method-specific processor
 * via the standard `affwp_batch_generate_payouts_completed` action. The failed
 * payout itself is left in place as an audit trail.
 *
 * When `$batch_id` is `0` (default), a new batch is created with
 * `retry_of_batch_id` pointing to the failed payout's batch, and the
 * batch-complete lifecycle hook fires here. When `$batch_id` is supplied, the
 * caller owns the batch lifecycle — typically the bulk-retry path, which
 * groups N retries under a single batch and calls `recount()` + the
 * `affwp_batch_generate_payouts_completed` action once at the end.
 *
 * @since 2.33.0
 *
 * @param int $payout_id The failed payout to retry.
 * @param int $batch_id  Optional. Existing batch to attach the new payout to.
 *                       Default `0` (create a new batch).
 * @return array|WP_Error `{ payout_id, batch_id }` on success, `WP_Error` on failure.
 */
function affwp_retry_payout( $payout_id, $batch_id = 0 ) {

	$payout_id = absint( $payout_id );
	$batch_id  = absint( $batch_id );
	$payout    = $payout_id ? affwp_get_payout( $payout_id ) : false;

	if ( ! $payout ) {
		return new WP_Error( 'affwp_retry_not_found', __( 'Payout not found.', 'affiliate-wp' ) );
	}

	if ( 'failed' !== $payout->status ) {
		return new WP_Error( 'affwp_retry_not_failed', __( 'Only failed payouts can be retried.', 'affiliate-wp' ) );
	}

	$referral_ids = [];
	if ( ! empty( $payout->referrals ) ) {
		$referral_ids = array_filter( array_map( 'absint', explode( ',', $payout->referrals ) ) );
	}

	if ( empty( $referral_ids ) ) {
		return new WP_Error( 'affwp_retry_no_referrals', __( 'This payout has no referrals attached to retry.', 'affiliate-wp' ) );
	}

	// Detach referrals from the old (failed) payout so the new payout can claim them.
	foreach ( $referral_ids as $rid ) {
		affiliate_wp()->referrals->update( $rid, [ 'payout_id' => 0, 'status' => 'unpaid' ], '', 'referral' );
	}

	/*
	 * When no batch was supplied, create one tagged as a retry of the original.
	 * When a batch_id is supplied, the caller is orchestrating a multi-payout
	 * retry and we attach to their batch instead.
	 */
	$auto_batch = 0 === $batch_id;

	if ( $auto_batch ) {
		$batch_id = affwp_add_payout_batch( [
			'created_by'        => get_current_user_id(),
			'payout_method'     => 'mixed',
			'status'            => 'processing',
			'retry_of_batch_id' => (int) $payout->batch_id,
		] );

		if ( ! $batch_id ) {
			return new WP_Error( 'affwp_retry_batch_failed', __( 'Failed to create retry batch.', 'affiliate-wp' ) );
		}
	}

	/*
	 * Retry through the method that FAILED. The sweep gates its readiness check
	 * on that method, so executing on a different one would defeat the gate, and
	 * an admin clicking "Retry" on a Stripe payout expects Stripe. We must NOT
	 * fall through to the affiliate's resolved/default method, which can be
	 * 'manual' (marking a payout paid with no real disbursement) — a silent fake
	 * success. Paying via a different method is a separate, explicit action
	 * (`affwp_switch_payout_method()`), which redirects the retry through the
	 * filter below.
	 */
	$original_method = (string) $payout->payout_method;

	/**
	 * Filters the method a retried payout runs through. Defaults to the method
	 * that originally failed. `affwp_switch_payout_method()` hooks this to
	 * redirect a retry to an admin-chosen method without changing the
	 * affiliate's stored preference.
	 *
	 * @since 2.33.0
	 *
	 * @param string                  $payout_method   Resolved method for the retry (default: failed method).
	 * @param \AffWP\Affiliate\Payout $payout          The failed payout being retried.
	 * @param string                  $original_method The method that originally failed.
	 */
	$payout_method = (string) apply_filters( 'affwp_retry_payout_method', $original_method, $payout, $original_method );

	$initial_status = apply_filters(
		'affwp_batch_payout_initial_status',
		'paid',
		$payout_method,
		(int) $payout->affiliate_id,
		(int) $batch_id
	);

	$new_payout_id = affwp_add_payout( [
		'affiliate_id'  => (int) $payout->affiliate_id,
		'referrals'     => $referral_ids,
		'amount'        => (float) $payout->amount,
		'payout_method' => $payout_method,
		'batch_id'      => (int) $batch_id,
		'status'        => $initial_status,
		// Carry the chain depth forward so the sweep's attempt ceiling applies
		// across retries, not just to the original.
		'retry_count'   => (int) $payout->retry_count + 1,
	] );

	if ( ! $new_payout_id ) {
		return new WP_Error( 'affwp_retry_payout_failed', __( 'Failed to create retry payout.', 'affiliate-wp' ) );
	}

	/*
	 * Idempotency + supersession marker. Record the replacement's ID on the
	 * originating payout. This does double duty:
	 *
	 *   - The sweep excludes superseded rows (`superseded_by_payout_id = 0`),
	 *     so a failure is never retried twice — the chain advances one payout
	 *     at a time instead of fanning out (the runaway loop, worst for
	 *     methods whose readiness signal can't see the blocker, e.g. PayPal).
	 *   - The admin list hides superseded rows by default, collapsing a retry
	 *     chain to its current tail, while the row stays as an audit record
	 *     (failure_class and description preserved).
	 */
	affiliate_wp()->affiliates->payouts->update( $payout_id, array( 'superseded_by_payout_id' => (int) $new_payout_id ), '', 'payout' );

	/*
	 * Auto-batch path owns the lifecycle hooks — the caller-supplied-batch path
	 * fires these once at the end of the loop, so we skip them here.
	 */
	if ( $auto_batch ) {
		affiliate_wp()->affiliates->payout_batches->recount( (int) $batch_id );
		do_action( 'affwp_batch_generate_payouts_completed', (int) $batch_id );
	}

	return [
		'payout_id' => (int) $new_payout_id,
		'batch_id'  => (int) $batch_id,
	];
}

/**
 * Retrieves the label for a payout method.
 *
 * @since 2.4
 *
 * @param string $payout_method Optional. Payout method. Default empty.
 * @return string $label The localized version of the payout method label. If the payout method
 *                       isn't registered, the default 'Manual' label will be returned.
 */
function affwp_get_payout_method_label( $payout_method = '' ) {

	$payout_methods = affwp_get_payout_methods();
	$label          = array_key_exists( $payout_method, $payout_methods ) ? $payout_methods[ $payout_method ] : $payout_methods['manual'];

	/**
	 * Filters the payout method label.
	 *
	 * @since 2.4
	 *
	 * @param string $label         A localized version of the payout method label.
	 * @param string $payout_method Payout method.
	 */
	return apply_filters( 'affwp_payout_method_label', $label, $payout_method );
}

/**
 * Checks if a given payout method is enabled.
 *
 * @since 2.4
 *
 * @param string $payout_method Payout method.
 * @return bool $enabled True if the payout method is enabled. False otherwise.
 */
function affwp_is_payout_method_enabled( $payout_method ) {

	$payout_methods = affwp_get_payout_methods();
	$enabled        = array_key_exists( $payout_method, $payout_methods ) ? true : false;

	/**
	 * Filters the payout method enabled boolean.
	 *
	 * @since 2.4
	 *
	 * @param bool   $enabled       True if the payout method is enabled. False otherwise.
	 * @param string $payout_method Payout method.
	 */
	return (bool) apply_filters( 'affwp_is_payout_method_enabled', $enabled, $payout_method );
}

/**
 * Retrieves a list of all enabled payout methods.
 *
 * @since 2.4
 *
 * @return array Enabled payout methods.
 */
function affwp_get_enabled_payout_methods() {

	$enabled_methods = [];

	foreach ( affwp_get_payout_methods() as $payout_method => $label ) {
		if ( affwp_is_payout_method_enabled( $payout_method ) ) {
			$enabled_methods[] = $payout_method;
		}
	}

	return $enabled_methods;
}

/**
 * Retrieves the list of preview payout request failed reasons and corresponding labels.
 *
 * @since 2.4
 *
 * @return array Key/value pairs of reasons where key is the reason and the value is the label.
 */
function affwp_get_preview_payout_request_failed_reasons() {
	$reasons = [
		'invalid_account'               => __( 'Invalid affiliate account', 'affiliate-wp' ),
		'invalid_ps_account'            => __( 'Invalid Payouts Service account', 'affiliate-wp' ),
		'minimum_payout'                => __( 'Doesn&#8217;t meet the minimum payout amount', 'affiliate-wp' ),
		'no_ps_account'                 => __( 'Hasn&#8217;t created a Payouts Service account', 'affiliate-wp' ),
		'no_ps_payout_method'           => __( 'Hasn&#8217;t submitted payout method on Payouts Service', 'affiliate-wp' ),
		'no_referrals'                  => __( 'No referrals within the specified date range', 'affiliate-wp' ),
		'previous_payout_failed'        => __( 'Has a previously failed payout', 'affiliate-wp' ),
		'ps_account_disabled'           => __( 'Account temporarily disabled on the Payouts Service', 'affiliate-wp' ),
		'unable_to_retrieve_ps_account' => __( 'Unable to retrieve Payouts Service account', 'affiliate-wp' ),
		'unable_to_validate_payout'     => __( 'Unable to validate payout on the Payouts Service', 'affiliate-wp' ),
		'user_account_deleted'          => __( 'Affiliate user account deleted', 'affiliate-wp' ),
	];

	/**
	 * Filters the preview payout request failed reasons.
	 *
	 * @since 2.4
	 *
	 * @param array $reasons Array of key/value pairs of reasons.
	 */
	return apply_filters( 'affwp_get_preview_payout_request_failed_reasons', $reasons );
}

/**
 * Retrieves the label for a preview payout request failed reason.
 *
 * @since 2.4
 *
 * @param string $reason Preview payout request failed reason.
 * @return string The localized version of the reason label.
 */
function affwp_get_preview_payout_request_failed_reason_label( $reason ) {

	$reasons = affwp_get_preview_payout_request_failed_reasons();
	$label   = array_key_exists( $reason, $reasons ) ? $reasons[ $reason ] : sanitize_text_field( $reason );

	return $label;
}

/**
 * Validate payout data on the Payouts Service.
 *
 * @since 2.4.2
 *
 * @param array $data Payout data.
 * @return array Validated payout data.
 */
function affwp_validate_payouts_service_payout_data( $data ) {

	$body_args = [
		'payout_data'   => $data,
		'currency'      => affwp_get_currency(),
		'affwp_version' => AFFILIATEWP_VERSION,
	];

	$headers = affwp_get_payouts_service_http_headers();

	$args = [
		'body'      => $body_args,
		'headers'   => $headers,
		'timeout'   => 60,
		'sslverify' => false,
	];

	$request = wp_remote_post( PAYOUTS_SERVICE_URL . '/wp-json/payouts/v1/validate-payout', $args );

	$valid_payout_data   = [];
	$invalid_payout_data = [];

	foreach ( $data as $affiliate_id => $affiliate_data ) {
		$invalid_payout_data[ $affiliate_id ] = 'unable_to_validate_payout';
	}

	if ( ! is_wp_error( $request ) ) {

		$response      = json_decode( wp_remote_retrieve_body( $request ) );
		$response_code = wp_remote_retrieve_response_code( $request );

		if ( 200 === (int) $response_code && $response->status ) {

			$valid_payout_data   = affwp_object_to_array( $response->valid_payout_data );
			$invalid_payout_data = affwp_object_to_array( $response->invalid_payout_data );

		}
	}

	$validated_payout_data = [
		'valid_payout_data'   => $valid_payout_data,
		'invalid_payout_data' => $invalid_payout_data,
	];

	return $validated_payout_data;
}

/**
 * Determines whether the payouts service is enabled and configured.
 *
 * @since 2.6.1
 *
 * @return bool True if the service is enabled and configured, otherwise false.
 */
function affwp_is_payouts_service_enabled() {
	return affiliate_wp()->affiliates->payouts->service_register->is_service_enabled();
}

/**
 * Retrieve the headers to be sent for HTTP request to the Payouts Service.
 *
 * @since 2.6.8
 *
 * @param bool $add_authorization_header Optional. Whether to return the Authorization header.
 *                                       Default true.
 * @return array HTTP headers.
 */
function affwp_get_payouts_service_http_headers( $add_authorization_header = true ) {

	$headers = [
		'Payouts-Service-Platform'         => 'affiliatewp',
		'Payouts-Service-Platform-Url'     => site_url(),
		'Payouts-Service-Platform-Version' => AFFILIATEWP_VERSION,
	];

	if ( true === $add_authorization_header ) {
		$vendor_id  = affiliate_wp()->settings->get( 'payouts_service_vendor_id', 0 );
		$access_key = affiliate_wp()->settings->get( 'payouts_service_access_key', '' );

		$headers['Authorization'] = 'Basic ' . base64_encode( $vendor_id . ':' . $access_key );
	}

	return $headers;
}

/**
 * Checks if the Payouts Service has an active connection.
 *
 * Unlike affwp_is_payouts_service_enabled(), this only checks the connection status
 * without requiring the toggle to be on or credentials to be valid. Used for the
 * sunset notification which should show for any connected site regardless of toggle state.
 *
 * @since 2.32.0
 *
 * @see affwp_is_payouts_service_enabled() For the stricter "enabled and configured" check.
 *
 * @return bool True if the Payouts Service connection status is active.
 */
function affwp_is_payouts_service_connected() {
	return 'active' === affiliate_wp()->settings->get( 'payouts_service_connection_status', '' );
}

/**
 * Checks if the Payouts Service sunset date has passed.
 *
 * Uses the site's local timezone (via current_time) so the sunset occurs on
 * September 30 in the customer's timezone, not UTC.
 *
 * @since 2.32.0
 *
 * @return bool True if the current date is past the sunset date.
 */
function affwp_is_payouts_service_sunset_passed() {
	return current_time( 'Y-m-d' ) > AFFWP_PAYOUTS_SERVICE_SUNSET_DATE;
}

/**
 * Checks if at least one alternative payout method (non-Payouts Service) is active.
 *
 * Looks for Stripe Payouts, PayPal Payouts, or Store Credit in the payment methods registry.
 *
 * @since 2.32.0
 *
 * @return bool True if at least one alternative payout method is active.
 */
function affwp_has_alternative_payout_method() {

	/*
	 * AffiliateWP_Payment_Methods is only available in admin context.
	 * Fall back to checking settings directly when the registry isn't loaded.
	 */
	if ( ! class_exists( 'AffiliateWP_Payment_Methods' ) ) {
		return (bool) affiliate_wp()->settings->get( 'stripe_payouts', false )
			|| (bool) affiliate_wp()->settings->get( 'paypal_payouts', false )
			|| (bool) affiliate_wp()->settings->get( 'store-credit', false );
	}

	$alternative_methods = [ 'stripe', 'paypal', 'store_credit' ];

	foreach ( $alternative_methods as $method_id ) {
		$method = AffiliateWP_Payment_Methods::get( $method_id );

		if ( $method && 'active' === $method['status'] ) {
			return true;
		}
	}

	return false;
}

/**
 * Determines whether the Payouts Service sunset notification should be shown.
 *
 * @since 2.32.0
 *
 * @return bool True if the notification should be displayed.
 */
function affwp_should_show_payouts_sunset_notification() {
	if ( ! affwp_is_payouts_service_connected() ) {
		return false;
	}

	// Auto-remove: if an alternative method is active, the user has already switched.
	if ( ! affiliate_wp()->settings->get( 'enable_payouts_service', false )
		&& affwp_has_alternative_payout_method() ) {
		return false;
	}

	return true;
}

/**
 * Retrieves a payout by a given field and value.
 *
 * @since 2.7
 *
 * @param string $field Payout object field.
 * @param mixed  $value Field value.
 * @return \AffWP\Affiliate\Payout|\WP_Error Payout object if found, otherwise a WP_Error object.
 */
function affwp_get_payout_by( $field, $value ) {
	$result = affiliate_wp()->affiliates->payouts->get_by( $field, $value );

	if ( is_object( $result ) ) {
		$payout = affwp_get_payout( intval( $result->payout_id ) );
	} else {
		$payout = new \WP_Error(
			'invalid_payout_field',
			sprintf( 'No payout could be retrieved with a(n) \'%1$s\' field value of %2$s.', $field, $value )
		);
	}

	return $payout;
}

/**
 * Computes the eligible-payouts set for a Pay Affiliates run.
 *
 * Single source of truth for the live eligibility chip on the Pay Affiliates form
 * ({@see affwp_ajax_payout_eligibility()}), the Step 2 Preview Payout screen
 * ({@see includes/admin/payouts/preview.php}), and the batch processor's
 * candidate compile ({@see \AffWP\Payouts\Batch_Payout_Processor::pre_fetch()}).
 *
 * Prior to extraction, the three surfaces held near-identical pipelines that
 * drifted into bugs — most recently the dedup gate (which caused failed
 * payouts to silently exclude retry candidates) and the per-method addon
 * dispatch (which caused the chip count to disagree with the preview count
 * for Stripe/PayPal-blocked affiliates).
 *
 * @since 2.33.0
 *
 * @param array $args {
 *     Filter inputs. All keys optional.
 *
 *     @type array|int|false $affiliate_id        Affiliate ID(s), or false for all.
 *     @type array           $date                ['start' => Y-m-d|false, 'end' => Y-m-d|false].
 *     @type float           $minimum             Per-affiliate minimum.
 *     @type bool            $bypass_holding      Skip holding-period filter.
 *     @type string          $override_method     Legacy "pay everyone via X" mode. Empty for per-affiliate routing.
 *     @type array           $method_filter       Per-affiliate routing: drop affiliates whose configured method
 *                                                isn't in this list. Ignored in override mode.
 *     @type bool            $apply_addon_filters Fire each method's `affwp_preview_payout_data_{method}` and
 *                                                `affwp_preview_payout_invalid_affiliates_{method}` slices.
 *     @type bool            $track_invalid       Collect per-affiliate invalid reasons. Off for the chip,
 *                                                on for the preview screen.
 *     @type string          $referral_shape      'full' = preview row (id/amount/date/description),
 *                                                'id'   = referral_id ints,
 *                                                'none' = drop referral tracking.
 *     @type bool            $include_currency    Stamp each row with a `currency` field (batch processor only).
 *     @type string          $args_filter         get_referrals args filter to fire: 'preview' | 'generate' | 'none'.
 *     @type mixed           $args_filter_context Second arg passed to the args filter (the processor instance for 'generate').
 * }
 *
 * @return array{
 *     payouts: array<int, array{amount: float, referrals: array, currency?: string, account_id?: string}>,
 *     invalid_affiliates: array<int, string>,
 *     total: float,
 *     count: int,
 * }
 */
function affwp_compute_eligible_payouts( array $args = array() ): array {

	$args = array_merge(
		array(
			'affiliate_id'        => false,
			'referral_id'         => false,
			'date'                => array(
				'start' => false,
				'end'   => false,
			),
			'minimum'             => 0,
			'bypass_holding'      => false,
			'override_method'     => '',
			'method_filter'       => array(),
			'apply_addon_filters' => false,
			'track_invalid'       => false,
			'referral_shape'      => 'full',
			'include_currency'    => false,
			'args_filter'         => 'preview',
			'args_filter_context' => null,
		),
		$args
	);

	$minimum             = (float) $args['minimum'];
	$bypass_holding      = (bool) $args['bypass_holding'];
	$override_method     = (string) $args['override_method'];
	$is_override         = '' !== $override_method;
	$method_filter       = (array) $args['method_filter'];
	$apply_addon_filters = (bool) $args['apply_addon_filters'];
	$track_invalid       = (bool) $args['track_invalid'];
	$referral_shape      = (string) $args['referral_shape'];
	$include_currency    = (bool) $args['include_currency'];

	$query_args = array(
		'status'       => 'unpaid',
		'number'       => -1,
		'date'         => $args['date'],
		'affiliate_id' => $args['affiliate_id'],
		'referral_id'  => $args['referral_id'],
	);

	switch ( $args['args_filter'] ) {
		case 'preview':
			/** This filter is documented in includes/admin/payouts/preview.php */
			$query_args = apply_filters( 'affwp_preview_payout_get_referrals_args', $query_args );
			break;
		case 'generate':
			/** This filter is documented in includes/payouts/class-batch-payout-processor.php */
			$query_args = apply_filters( 'affwp_generate_payout_get_referrals_args', $query_args, $args['args_filter_context'] );
			break;
	}

	$payouts             = array();
	$invalid_affiliates  = array();
	$failed_skipped_aids = array();

	/*
	 * Per-request memoization on the AGGREGATED base result — NOT the raw
	 * referrals. The referrals are fetched in bounded pages below so a large
	 * unpaid pool never lands in memory all at once (a 26k-row fetch held as
	 * hydrated objects, plus the old raw-referral cache, exhausted 256MB).
	 *
	 * The Pay Affiliates eligibility AJAX endpoint calls this function up to
	 * three times per debounced filter change (with-method, with-minimum-only,
	 * no-filters pool). They share date + affiliate_id, so the base aggregation
	 * (the fetch + per-referral loop — the expensive part) is identical across
	 * calls; only $minimum and the method args differ, and those are applied to
	 * a copy of the base further down. Caching the small affiliate-keyed base
	 * (not the 26k raw rows) keeps the waterfall fast without the memory blowup.
	 *
	 * Key covers everything the loop output depends on: post-filter $query_args,
	 * holding bypass, referral shape, currency inclusion, and the override method
	 * (the payouts-service branch stamps account data during the loop). Scope:
	 * static map, per-request; released when the PHP request ends.
	 */
	static $base_cache = array();
	$base_key = md5( serialize( array( $query_args, $bypass_holding, $referral_shape, $include_currency, $override_method ) ) );

	if ( isset( $base_cache[ $base_key ] ) ) {
		$payouts            = $base_cache[ $base_key ]['payouts'];
		$invalid_affiliates = $base_cache[ $base_key ]['invalid'];
	} else {

		$global_currency = $include_currency ? affwp_get_currency() : '';

		/*
		 * Fetch unpaid referrals in bounded pages so the working set is one page, not the whole
		 * pool. Ordered by primary key for stable pagination; the aggregation below is
		 * commutative, so page order doesn't affect the result.
		 */
		$page_size = 2000;
		$offset    = 0;

		do {
			$page_args = array_merge(
				$query_args,
				array(
					'number'  => $page_size,
					'offset'  => $offset,
					'orderby' => 'referral_id',
					'order'   => 'ASC',
				)
			);

			$referrals = affiliate_wp()->referrals->get_referrals( $page_args );
			$fetched   = count( $referrals );

			if ( 0 === $fetched ) {
				break;
			}

			/*
			 * Gate must match affwp_add_payout, which rejects any referral with a payout_id
			 * regardless of status. Include 'failed' so eligibility and creation agree, otherwise
			 * preview totals drift from batch outcomes.
			 */
			$existing_payout_ids = array();
			$failed_payout_ids   = array();
			$candidate_ids       = array_unique( array_filter( wp_list_pluck( $referrals, 'payout_id' ) ) );
			if ( $candidate_ids ) {
				global $wpdb;
				$payouts_table = affiliate_wp()->affiliates->payouts->table_name;
				$placeholders  = implode( ',', array_fill( 0, count( $candidate_ids ), '%d' ) );
				$rows          = $wpdb->get_results(
					$wpdb->prepare(
						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholders are %d only.
						"SELECT payout_id, status FROM {$payouts_table} WHERE status IN ('processing', 'paid', 'failed') AND payout_id IN ({$placeholders})",
						$candidate_ids
					)
				);
				foreach ( $rows as $row ) {
					$pid                         = (int) $row->payout_id;
					$existing_payout_ids[ $pid ] = true;
					if ( 'failed' === $row->status ) {
						$failed_payout_ids[ $pid ] = true;
					}
				}
			}

	foreach ( $referrals as $referral ) {

		if ( ! $bypass_holding && affwp_is_referral_within_holding_period( $referral ) ) {
			continue;
		}

		if ( ! empty( $referral->payout_id ) && isset( $existing_payout_ids[ (int) $referral->payout_id ] ) ) {
			if ( isset( $failed_payout_ids[ (int) $referral->payout_id ] ) ) {
				$failed_skipped_aids[ (int) $referral->affiliate_id ] = true;
			}
			continue;
		}

		$aid = (int) $referral->affiliate_id;

		if ( isset( $invalid_affiliates[ $aid ] ) ) {
			continue;
		}

		if ( ! isset( $payouts[ $aid ] ) ) {

			/*
			 * Payouts-service does a per-affiliate account lookup that stamps account_id onto
			 * the row and short-circuits the affiliate's remaining referrals when invalid.
			 */
			if ( 'payouts-service' === $override_method ) {
				$account = affwp_get_payouts_service_account( $aid );
				if ( false === $account['valid'] ) {
					if ( $track_invalid ) {
						$invalid_affiliates[ $aid ] = $account['status'];
					}
					continue;
				}
				$payouts[ $aid ] = array(
					'amount'     => 0.0,
					'account_id' => $account['account_id'],
					'referrals'  => array(),
				);
			} else {
				$payouts[ $aid ] = array(
					'amount'    => 0.0,
					'referrals' => array(),
				);
			}

			if ( $include_currency ) {
				$payouts[ $aid ]['currency'] = ! empty( $referral->currency ) ? $referral->currency : $global_currency;
			}
		}

		$payouts[ $aid ]['amount'] += (float) $referral->amount;

		if ( 'none' !== $referral_shape ) {
			$payouts[ $aid ]['referrals'][] = ( 'full' === $referral_shape )
				? array(
					'id'          => (int) $referral->referral_id,
					'amount'      => (float) $referral->amount,
					'date'        => $referral->date_i18n(),
					'description' => $referral->description ? wp_trim_words( $referral->description, 8 ) : '',
				)
				: (int) $referral->referral_id;
		}
	} // end foreach over the current referral page

			unset( $referrals, $existing_payout_ids, $failed_payout_ids, $candidate_ids );

			/*
			 * Evict this page's hydrated referrals so the object cache doesn't accumulate the
			 * whole pool across pages. Scoped to the 'referrals' group, leaving other caches
			 * intact. No-op on backends without group flush, which store outside PHP memory anyway.
			 */
			if ( function_exists( 'wp_cache_flush_group' ) ) {
				wp_cache_flush_group( affiliate_wp()->referrals->cache_group );
			}

			$offset += $page_size;
		} while ( $fetched === $page_size );

		// Affiliates gated only by failed payouts surface here so the preview reflects what the batch pays.
		foreach ( $failed_skipped_aids as $aid => $_ ) {
			if ( ! isset( $payouts[ $aid ] ) && ! isset( $invalid_affiliates[ $aid ] ) ) {
				$invalid_affiliates[ $aid ] = 'previous_payout_failed';
			}
		}

		$base_cache[ $base_key ] = array(
			'payouts' => $payouts,
			'invalid' => $invalid_affiliates,
		);
	}

	// Override mode: one filter pair across the whole set, BEFORE minimum.
	if ( $is_override ) {

		if ( 'payouts-service' === $override_method && ! empty( $payouts ) ) {
			$ps      = affwp_validate_payouts_service_payout_data( $payouts );
			$payouts = $ps['valid_payout_data'];
			if ( $track_invalid ) {
				$invalid_affiliates = array_replace_recursive( $invalid_affiliates, $ps['invalid_payout_data'] );
			}
		}

		if ( $apply_addon_filters ) {

			/** This filter is documented in includes/admin/payouts/preview.php */
			$invalid_returned = apply_filters( "affwp_preview_payout_invalid_affiliates_{$override_method}", $invalid_affiliates, $payouts, $override_method );

			if ( $track_invalid && is_array( $invalid_returned ) ) {
				$invalid_affiliates = $invalid_returned;
			}

			// Drop addon-flagged invalid affiliates from $payouts so the chip count doesn't include affiliates the preview excludes.
			if ( is_array( $invalid_returned ) ) {
				foreach ( $invalid_returned as $invalid_aid => $_reason ) {
					unset( $payouts[ $invalid_aid ] );
				}
			}

			/** This filter is documented in includes/admin/payouts/preview.php */
			$payouts = apply_filters( "affwp_preview_payout_data_{$override_method}", $payouts, $override_method );
			if ( ! is_array( $payouts ) ) {
				$payouts = array();
			}
		}
	}

	if ( $minimum > 0 ) {
		foreach ( $payouts as $aid => $payout ) {
			if ( (float) $payout['amount'] < $minimum ) {
				unset( $payouts[ $aid ] );
				if ( $track_invalid && ! isset( $invalid_affiliates[ $aid ] ) ) {
					$invalid_affiliates[ $aid ] = 'minimum_payout';
				}
			}
		}
	}

	/*
	 * Per-affiliate routing: method filter chip, then per-method addon dispatch on each slice.
	 *
	 * Both branches resolve the affiliate's effective method (saved if still enabled, else the
	 * site default, else 'manual') rather than the raw saved meta, which can disagree with the
	 * method that actually runs the payment (e.g. saved='paypal' where PayPal is now disabled).
	 * Filtering on the raw value would drop those affiliates from the preview while the batch
	 * processor still pays them, drifting both display counts and actual outcome.
	 */
	if ( ! $is_override ) {

		/*
		 * Bulk-prime affiliate meta cache before the per-affiliate resolver loop
		 * Each call to affwp_get_affiliate_usable_payout_method() reads
		 * the 'payout_method' meta — without a primed cache, that's N round
		 * trips for N candidates (a cold-cache pass on 4k affiliates =
		 * 4k SELECTs). update_meta_cache() batches into one
		 * `WHERE affiliate_id IN (...)` SELECT and populates the wp_cache so
		 * the resolver loop becomes O(1) per call.
		 *
		 * No-op when $payouts is empty (early-out by update_meta_cache itself).
		 * Free on warm caches — the function checks before fetching.
		 */
		if ( ! empty( $payouts ) ) {
			update_meta_cache( 'affiliate', array_map( 'absint', array_keys( $payouts ) ) );
		}

		if ( ! empty( $method_filter ) && ! empty( $payouts ) ) {
			foreach ( array_keys( $payouts ) as $aid ) {
				$effective = affwp_get_affiliate_usable_payout_method( $aid );
				if ( ! in_array( $effective, $method_filter, true ) ) {
					unset( $payouts[ $aid ] );
				}
			}
		}

		if ( $apply_addon_filters && ! empty( $payouts ) ) {

			$by_method = array();
			foreach ( $payouts as $aid => $payout ) {
				$m_key                       = affwp_get_affiliate_usable_payout_method( $aid );
				$by_method[ $m_key ][ $aid ] = $payout;
			}

			foreach ( $by_method as $m_key => $slice ) {
				$original_keys  = array_keys( $slice );
				$original_slice = $slice;

				/** This filter is documented in includes/admin/payouts/preview.php */
				$slice = apply_filters( "affwp_preview_payout_data_{$m_key}", $slice, $m_key );

				/*
				 * Pass the pre-data-filter slice so addons can flag affiliates the data filter
				 * dropped; otherwise they vanish from the preview instead of surfacing in
				 * "Cannot be paid this run" with the addon's reason.
				 */
				/** This filter is documented in includes/admin/payouts/preview.php */
				$slice_invalid = apply_filters( "affwp_preview_payout_invalid_affiliates_{$m_key}", array(), $original_slice, $m_key );

				if ( is_array( $slice_invalid ) ) {
					foreach ( $slice_invalid as $invalid_aid => $reason ) {
						unset( $payouts[ $invalid_aid ] );
						if ( $track_invalid && ! isset( $invalid_affiliates[ $invalid_aid ] ) ) {
							$invalid_affiliates[ $invalid_aid ] = $reason;
						}
					}
				}

				$kept_keys = is_array( $slice ) ? array_keys( $slice ) : array();
				foreach ( array_diff( $original_keys, $kept_keys ) as $dropped_aid ) {
					unset( $payouts[ $dropped_aid ] );
				}

				if ( is_array( $slice ) ) {
					foreach ( $slice as $kept_aid => $transformed ) {
						if ( isset( $payouts[ $kept_aid ] ) ) {
							$payouts[ $kept_aid ] = $transformed;
						}
					}
				}
			}
		}
	}

	$total = 0.0;
	foreach ( $payouts as $payout ) {
		$total += (float) $payout['amount'];
	}

	return array(
		'payouts'            => $payouts,
		'invalid_affiliates' => $invalid_affiliates,
		'total'              => $total,
		'count'              => count( $payouts ),
	);
}
