<?php
/**
 * Shared payout-preview rendering.
 *
 * The grouped-by-method payout preview (total header, per-method sections with
 * per-affiliate rows, and the "Cannot be paid this run" section) is rendered in
 * two places: the full Pay Affiliates preview screen ({@see preview.php}) and the
 * Referrals bulk-pay drawer. These functions are the single source of truth so the
 * two surfaces stay visually and behaviorally identical.
 *
 * @package    AffiliateWP
 * @subpackage Admin/Payouts
 * @since      2.33.0
 */

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

/**
 * Computes and groups the eligible-payout preview data shared by the preview
 * screen and the bulk-pay drawer.
 *
 * Wraps {@see affwp_compute_eligible_payouts()} and folds its flat affiliate-keyed
 * result into the per-method structure the preview body renders, applying the
 * per-affiliate preflight status and the headline accounting (ready / invite /
 * blocked counts and the "going out" total that excludes blocked rows).
 *
 * @since 2.33.0
 *
 * @param array $args {
 *     Build arguments. Compute args are forwarded to affwp_compute_eligible_payouts().
 *
 *     @type bool   $per_affiliate_routing Whether each affiliate routes to its own method.
 *                                         When false, $payout_method overrides for the batch.
 *     @type string $payout_method         Batch-level method override (when not per-affiliate).
 *     @type array  $method_filter         Method keys to seed as empty sections (so a filtered
 *                                          method with no eligible affiliates still shows).
 *     // ...plus any affwp_compute_eligible_payouts() arg (affiliate_id, referral_id, date,
 *     //    minimum, bypass_holding, override_method, apply_addon_filters, etc.).
 * }
 * @return array {
 *     @type array $payouts_by_method   Method-keyed groups (label, total, count, ready_count,
 *                                       invite_count, blocked_count, blocked_total, payouts[]).
 *     @type array $invalid_affiliates  Affiliate ID => reason key.
 *     @type float $referrals_total     Sum of all eligible payout amounts.
 *     @type int   $payouts_count       Number of payable affiliates.
 *     @type int   $invalid_count       Number of unpayable affiliates.
 *     @type float $going_out_total     Total minus blocked (what will actually leave the account).
 *     @type int   $going_out_count     ready + invite affiliate count.
 *     @type int   $grand_ready_count
 *     @type int   $grand_invite_count
 *     @type int   $grand_blocked_count
 *     @type float $grand_blocked_total
 * }
 */
function affwp_build_payout_preview_data( array $args ) {

	$per_affiliate_routing = ! empty( $args['per_affiliate_routing'] );
	$payout_method         = isset( $args['payout_method'] ) ? (string) $args['payout_method'] : '';
	$method_filter         = isset( $args['method_filter'] ) && is_array( $args['method_filter'] ) ? $args['method_filter'] : array();

	// Strip our presentation-only keys; everything else (including method_filter) is forwarded
	// to the compute engine. method_filter does double duty: compute uses it to filter affiliates
	// by method, and we reuse it below to seed empty sections for explicitly filtered methods.
	$compute_args = $args;
	unset( $compute_args['per_affiliate_routing'], $compute_args['payout_method'] );

	$compute_args = wp_parse_args(
		$compute_args,
		array(
			'override_method' => $per_affiliate_routing ? '' : $payout_method,
			'method_filter'   => $method_filter,
			'track_invalid'   => true,
			'referral_shape'  => 'id',
		)
	);

	$eligible           = affwp_compute_eligible_payouts( $compute_args );
	$payouts            = isset( $eligible['payouts'] ) ? $eligible['payouts'] : array();
	$invalid_affiliates = isset( $eligible['invalid_affiliates'] ) ? $eligible['invalid_affiliates'] : array();
	$referrals_total    = isset( $eligible['total'] ) ? (float) $eligible['total'] : 0.0;

	$payouts_by_method = array();
	$payouts_count     = count( $payouts );

	foreach ( $payouts as $aid => $payout ) {
		$method_key = $per_affiliate_routing
			? affwp_get_affiliate_usable_payout_method( $aid )
			: $payout_method;

		if ( ! isset( $payouts_by_method[ $method_key ] ) ) {
			$payouts_by_method[ $method_key ] = affwp_get_empty_payout_preview_method_group( $method_key );
		}

		$preflight        = affwp_preflight_payout_status( $aid, $method_key );
		$payout['status'] = $preflight;

		$payouts_by_method[ $method_key ]['payouts'][ $aid ] = $payout;
		$payouts_by_method[ $method_key ]['count']++;

		// Section total tracks only payouts that will leave the admin's account; blocked rows are summed separately so the headline number is honest.
		if ( 'invite' === $preflight ) {
			$payouts_by_method[ $method_key ]['invite_count']++;
			$payouts_by_method[ $method_key ]['total'] += $payout['amount'];
		} elseif ( 'blocked' === $preflight ) {
			$payouts_by_method[ $method_key ]['blocked_count']++;
			$payouts_by_method[ $method_key ]['blocked_total'] += $payout['amount'];
		} else {
			$payouts_by_method[ $method_key ]['ready_count']++;
			$payouts_by_method[ $method_key ]['total'] += $payout['amount'];
		}
	}

	// Aggregate preflight counts across all methods for the hero panel accounting.
	$grand_ready_count   = 0;
	$grand_invite_count  = 0;
	$grand_blocked_count = 0;
	$grand_blocked_total = 0;
	foreach ( $payouts_by_method as $m ) {
		$grand_ready_count   += $m['ready_count'];
		$grand_invite_count  += $m['invite_count'];
		$grand_blocked_count += $m['blocked_count'];
		$grand_blocked_total += $m['blocked_total'];
	}

	// Seed zero-match entries for explicitly filtered methods so they still show an empty section.
	if ( $per_affiliate_routing && ! empty( $method_filter ) ) {
		foreach ( $method_filter as $method_key ) {
			if ( ! isset( $payouts_by_method[ $method_key ] ) ) {
				$payouts_by_method[ $method_key ] = affwp_get_empty_payout_preview_method_group( $method_key );
			}
		}
	}

	if ( ! empty( $payouts_by_method ) ) {
		uasort(
			$payouts_by_method,
			static function ( $a, $b ) {
				return $b['total'] <=> $a['total'];
			}
		);
	}

	$invalid_count   = count( $invalid_affiliates );
	$going_out_total = max( 0, $referrals_total - $grand_blocked_total );
	$going_out_count = $grand_ready_count + $grand_invite_count;

	return array(
		'payouts'             => $payouts,
		'payouts_by_method'   => $payouts_by_method,
		'invalid_affiliates'  => $invalid_affiliates,
		'referrals_total'     => $referrals_total,
		'payouts_count'       => $payouts_count,
		'invalid_count'       => $invalid_count,
		'going_out_total'     => $going_out_total,
		'going_out_count'     => $going_out_count,
		'grand_ready_count'   => $grand_ready_count,
		'grand_invite_count'  => $grand_invite_count,
		'grand_blocked_count' => $grand_blocked_count,
		'grand_blocked_total' => $grand_blocked_total,
	);
}

/**
 * Returns the empty per-method group skeleton used by the preview data builder.
 *
 * @since 2.33.0
 *
 * @param string $method_key Payout method identifier.
 * @return array
 */
function affwp_get_empty_payout_preview_method_group( $method_key ) {
	return array(
		'label'         => affwp_get_payout_method_display_label( $method_key ),
		'total'         => 0,
		'count'         => 0,
		'ready_count'   => 0,
		'invite_count'  => 0,
		'blocked_count' => 0,
		'blocked_total' => 0,
		'payouts'       => array(),
	);
}

/**
 * Per-method liquidity pre-flight warnings (currently Stripe balance).
 *
 * Stripe is the only method whose remote balance we can query. When a method comes
 * back short we surface a blocking warning so the admin tops up rather than
 * processing a batch we know will fail. Shared by the preview screen and the drawer.
 *
 * @since 2.33.0
 *
 * @param array $payouts_by_method Grouped method data from affwp_build_payout_preview_data().
 * @return array Method key => warning array (severity, available, required, shortfall, ...).
 */
function affwp_get_payout_preview_method_warnings( array $payouts_by_method ) {

	$method_warnings = array();

	if (
		isset( $payouts_by_method['stripe'] )
		&& $payouts_by_method['stripe']['total'] > 0
		&& function_exists( 'affwp_stripe_payouts_is_admin_connected' )
		&& function_exists( 'affwp_stripe_payouts_check_platform_balance' )
		&& affwp_stripe_payouts_is_admin_connected()
	) {
		$stripe_currency = strtoupper( affwp_get_currency() );
		$stripe_required = (float) $payouts_by_method['stripe']['total'];
		$balance_state   = affwp_stripe_payouts_check_platform_balance();

		if ( ! is_wp_error( $balance_state ) ) {
			$stripe_available     = 0;
			$other_currency_label = '';

			if ( ! empty( $balance_state['available'] ) ) {
				foreach ( $balance_state['available'] as $bal ) {
					if ( empty( $bal['currency'] ) ) {
						continue;
					}
					$bucket_currency = strtoupper( (string) $bal['currency'] );
					if ( $bucket_currency === $stripe_currency ) {
						$stripe_available = ( (int) $bal['amount'] ) / 100;
					} elseif ( '' === $other_currency_label && (int) $bal['amount'] > 0 ) {
						$other_currency_label = sprintf(
							'%s %s',
							number_format_i18n( ( (int) $bal['amount'] ) / 100, 2 ),
							$bucket_currency
						);
					}
				}
			}

			if ( $stripe_required > $stripe_available ) {
				$is_test_mode = function_exists( 'affwp_stripe_payouts_is_test_mode' ) && affwp_stripe_payouts_is_test_mode();

				$method_warnings['stripe'] = array(
					'severity'             => 'blocking',
					'available'            => $stripe_available,
					'required'             => $stripe_required,
					'shortfall'            => $stripe_required - $stripe_available,
					'site_currency'        => $stripe_currency,
					'other_currency_label' => $other_currency_label,
					'test_mode'            => $is_test_mode,
					'dashboard_url'        => $is_test_mode
						? 'https://dashboard.stripe.com/test/balance'
						: 'https://dashboard.stripe.com/balance',
				);
			}
		}
	}

	return $method_warnings;
}

/**
 * Renders the payout preview body: hero panel (total + per-method cards), per-method
 * sections with per-affiliate rows, and the "Cannot be paid this run" section.
 *
 * Shared by the Pay Affiliates preview screen (context 'page', with deep-link jump
 * navigation and load-more pagination) and the Referrals bulk-pay drawer (context
 * 'drawer', which renders a bounded hand-picked selection so it skips pagination and
 * deep-link chrome).
 *
 * @since 2.33.0
 *
 * @param array $data   Output of affwp_build_payout_preview_data().
 * @param array $config {
 *     @type string $context          'page' or 'drawer'. Default 'page'.
 *     @type bool   $deep_link        Wire jump-link / "See details" deep-link handlers. Default true.
 *     @type int    $row_cap          Initial rows per section before load-more. 0 = render all. Default 5.
 *     @type int    $load_more_batch  Rows fetched per "load more" click. Default 100.
 *     @type int    $invalid_row_cap  Initial rows for the cannot-be-paid section. 0 = all. Default 5.
 *     @type int    $row_snap         Render a section in full when this many rows or fewer would otherwise hide behind the button. Default 0.
 *     @type bool   $load_more        Render load-more buttons. Default true.
 *     @type array  $method_warnings  Pre-flight warnings from affwp_get_payout_preview_method_warnings().
 *     @type array  $lookup           Preloaded affiliate/user data (affwp_preload_preview_payout_affiliates()).
 * }
 * @return string Rendered HTML.
 */
function affwp_render_payout_preview_body( array $data, array $config = array() ) {

	$config = wp_parse_args(
		$config,
		array(
			'context'             => 'page',
			'deep_link'           => true,
			'row_cap'             => 5,
			'load_more_batch'     => 100,
			'invalid_row_cap'     => 5,
			'row_snap'            => 0,
			'load_more'           => true,
			'method_warnings'     => array(),
			'lookup'              => null,
			// The drawer owns its own referral-level "won't be paid" reconciliation (it pays a
			// hand-picked selection, so a referral silently dropped by compute — e.g. already in
			// a payout — needs explaining). It suppresses these affiliate-level sections to avoid
			// double-listing. The Pay Affiliates screen keeps both (it pays by filter).
			'show_invalid_section' => true,
			'show_empty_fallback'  => true,
		)
	);

	$payouts_by_method   = $data['payouts_by_method'];
	$invalid_affiliates  = $data['invalid_affiliates'];
	$payouts_count       = $data['payouts_count'];
	$invalid_count       = $data['invalid_count'];
	$going_out_total     = $data['going_out_total'];
	$going_out_count     = $data['going_out_count'];
	$grand_blocked_count = $data['grand_blocked_count'];
	$grand_blocked_total = $data['grand_blocked_total'];

	$deep_link       = (bool) $config['deep_link'];
	$method_row_cap  = (int) $config['row_cap'];
	$invalid_row_cap = (int) $config['invalid_row_cap'];
	$row_snap        = max( 0, (int) $config['row_snap'] );
	$load_more       = (bool) $config['load_more'];
	$method_warnings = $config['method_warnings'];

	// Lazily preload affiliate lookup for whatever rows we're about to render if not supplied.
	$lookup = $config['lookup'];
	if ( null === $lookup ) {
		$ids = array();
		foreach ( $payouts_by_method as $m ) {
			$visible = affwp_get_preview_visible_count( count( $m['payouts'] ), $method_row_cap, $row_snap );
			$slice   = array_slice( $m['payouts'], 0, $visible, true );
			foreach ( array_keys( $slice ) as $aid ) {
				$ids[] = (int) $aid;
			}
		}
		$inv_visible = affwp_get_preview_visible_count( count( $invalid_affiliates ), $invalid_row_cap, $row_snap );
		$inv_slice   = array_slice( array_keys( $invalid_affiliates ), 0, $inv_visible, true );
		$lookup      = affwp_preload_preview_payout_affiliates( array_merge( $ids, array_map( 'intval', $inv_slice ) ) );
	}

	ob_start();

	$has_payouts = ! empty( $payouts_by_method ) && $payouts_count > 0;

	if ( $has_payouts ) :
		?>
		<!-- Hero panel: total + per-method jump-links. -->
		<div class="max-w-5xl rounded-xl bg-white ring-1 ring-gray-200 @container overflow-hidden">
			<div class="px-6 py-6">
				<div>
					<p class="text-sm/5 font-medium text-gray-500 m-0"><?php esc_html_e( 'Total payout', 'affiliate-wp' ); ?></p>
					<p class="mt-2 text-5xl font-semibold tracking-tight tabular-nums m-0">
						<?php echo affwp_currency_filter( affwp_format_amount( $going_out_total ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- affwp_currency_filter() returns entity-encoded HTML; double-escaping breaks currencies whose symbol is an HTML entity. ?>
					</p>
					<?php
					$has_exceptions = ( $grand_blocked_count > 0 ) || ( $invalid_count > 0 );
					if ( $has_exceptions ) :
						?>
						<p class="mt-2 text-sm/5 text-gray-600 m-0">
							<span class="font-medium text-amber-800">
								<?php
								echo esc_html(
									sprintf(
										/* translators: 1: count of affiliates being paid, 2: total affiliates considered this run */
										__( 'Paying %1$d of %2$d affiliates', 'affiliate-wp' ),
										(int) $going_out_count,
										(int) ( $payouts_count + $invalid_count )
									)
								);
								?>
							</span>
							<?php if ( $grand_blocked_count > 0 ) : ?>
								<?php
								echo esc_html(
									sprintf(
										/* translators: 1: amount pending, 2: not-connected count */
										_n(
											' · %1$s pending, %2$d not connected',
											' · %1$s pending, %2$d not connected',
											$grand_blocked_count,
											'affiliate-wp'
										),
										affwp_currency_filter( affwp_format_amount( $grand_blocked_total ) ),
										(int) $grand_blocked_count
									)
								);
								?>
							<?php endif; ?>
							<?php if ( $invalid_count > 0 ) : ?>
								<?php
								echo esc_html(
									sprintf(
										/* translators: %d: number of affiliates that cannot be paid */
										_n( ' · %d unpayable.', ' · %d unpayable.', $invalid_count, 'affiliate-wp' ),
										(int) $invalid_count
									)
								);
								?>
								<?php if ( $deep_link ) : ?>
									<a
										href="#affwp-invalid-affiliates"
										x-data
										@click.prevent="history.replaceState(null, '', '#affwp-invalid-affiliates'); $store.deepLink.processDeepLink('affwp-invalid-affiliates')"
										class="font-medium text-gray-700 underline underline-offset-2 hover:no-underline"
									><?php esc_html_e( 'See details', 'affiliate-wp' ); ?></a>
								<?php endif; ?>
							<?php endif; ?>
						</p>
					<?php endif; ?>
				</div>
			</div>

			<?php if ( count( $payouts_by_method ) > 1 ) : ?>
				<?php
				$method_count = count( $payouts_by_method );

				$use_balanced_medium = $method_count >= 4;
				if ( $use_balanced_medium ) {
					$medium_cols = (int) ceil( $method_count / 2 );
					$medium_rows = (int) ceil( $method_count / $medium_cols );
					$ghost_count = max( 0, ( $medium_cols * $medium_rows ) - $method_count );
				} else {
					$medium_cols = $method_count;
					$ghost_count = 0;
				}

				$medium_grid_class = $use_balanced_medium
					? '@sm:grid-cols-[repeat(var(--affwp-medium-cols),minmax(0,1fr))]'
					: '';

				if ( $method_count >= 6 ) {
					$wide_grid_class  = '@5xl:grid-cols-[repeat(var(--affwp-method-cols),minmax(0,1fr))]';
					$ghost_hide_class = '@5xl:hidden';
				} elseif ( 5 === $method_count ) {
					$wide_grid_class  = '@4xl:grid-cols-[repeat(var(--affwp-method-cols),minmax(0,1fr))]';
					$ghost_hide_class = '@4xl:hidden';
				} elseif ( 4 === $method_count ) {
					$wide_grid_class  = '@3xl:grid-cols-[repeat(var(--affwp-method-cols),minmax(0,1fr))]';
					$ghost_hide_class = '@3xl:hidden';
				} elseif ( 3 === $method_count ) {
					$wide_grid_class  = '@xl:grid-cols-[repeat(var(--affwp-method-cols),minmax(0,1fr))]';
					$ghost_hide_class = '';
				} else {
					$wide_grid_class  = '@lg:grid-cols-[repeat(var(--affwp-method-cols),minmax(0,1fr))]';
					$ghost_hide_class = '';
				}
				?>
				<div
					class="grid grid-cols-1 <?php echo esc_attr( trim( "$medium_grid_class $wide_grid_class" ) ); ?> border-t border-gray-200"
					style="--affwp-method-cols: <?php echo (int) $method_count; ?>; --affwp-medium-cols: <?php echo (int) $medium_cols; ?>;"
				>
					<?php foreach ( $payouts_by_method as $method_key => $method_data ) : ?>
						<?php
						$method_icon   = affwp_get_payout_method_icon( $method_key, 'size-4 shrink-0' );
						$method_anchor = 'affwp-method-' . ( sanitize_html_class( $method_key ) ?: 'method' );
						$card_classes  = '@container min-w-0 -mt-px -ml-px border-t border-l border-gray-200 bg-white px-4 py-4 @[14rem]:px-6';
						?>
						<?php if ( $deep_link ) : ?>
							<a
								href="#<?php echo esc_attr( $method_anchor ); ?>"
								x-data
								@click.prevent="history.replaceState(null, '', '#<?php echo esc_attr( $method_anchor ); ?>'); $store.deepLink.processDeepLink('<?php echo esc_attr( $method_anchor ); ?>')"
								class="<?php echo esc_attr( $card_classes ); ?> hover:bg-gray-50"
							>
						<?php else : ?>
							<div class="<?php echo esc_attr( $card_classes ); ?>">
						<?php endif; ?>
								<p class="flex items-center gap-1.5 text-sm/5 font-medium text-gray-500 m-0 min-w-0">
									<?php echo $method_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns trusted SVG. ?>
									<span class="truncate"><?php echo esc_html( $method_data['label'] ); ?></span>
								</p>
								<p class="mt-1.5 text-lg @[12rem]:text-xl @[18rem]:text-2xl font-semibold tracking-tight tabular-nums whitespace-nowrap <?php echo 0 === $method_data['total'] ? 'text-gray-400' : 'text-gray-900'; ?> m-0"><?php echo affwp_currency_filter( affwp_format_amount( $method_data['total'] ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- entity-encoded HTML from helper. ?></p>
						<?php if ( $deep_link ) : ?>
							</a>
						<?php else : ?>
							</div>
						<?php endif; ?>
					<?php endforeach; ?>

					<?php for ( $i = 0; $i < $ghost_count; $i++ ) : ?>
						<div
							aria-hidden="true"
							class="hidden @sm:block -mt-px -ml-px border-t border-l border-gray-200 bg-white <?php echo esc_attr( $ghost_hide_class ); ?>"
						></div>
					<?php endfor; ?>
				</div>
			<?php endif; ?>
		</div>

		<?php
		/*
		 * Page context fires the documented after-total hook so add-ons can inject between the
		 * hero and the sections. Keyed off the batch method suffix passed by the caller; the
		 * drawer omits it (a hand-picked, per-referral selection has no batch-level method).
		 */
		if ( isset( $config['after_total_hook_method'] ) ) {
			/**
			 * Fires immediately after the referrals total and before the per-method sections.
			 *
			 * The dynamic portion of the hook name, `$payout_method`, refers to the payout method.
			 *
			 * @since 2.4
			 *
			 * @param float $referrals_total Referrals total.
			 * @param array $data            Payout data.
			 */
			do_action( "affwp_preview_payout_after_referrals_total_{$config['after_total_hook_method']}", $data['referrals_total'], $data['payouts'] );
		}
		?>

		<?php foreach ( $payouts_by_method as $method_key => $method_data ) : ?>
			<?php
			$method_icon    = affwp_get_payout_method_icon( $method_key, 'size-5 shrink-0' );
			$method_slug    = sanitize_html_class( $method_key ) ?: 'method';
			$method_visible = affwp_get_preview_visible_count( $method_data['count'], $method_row_cap, $row_snap );
			$overflow       = max( 0, $method_data['count'] - $method_visible );

			ob_start();
			/**
			 * Fires inside each per-method preview section so an add-on can output a
			 * method-specific note (e.g. "PayPal sends invites for unrecognized emails").
			 *
			 * @since 2.4
			 */
			do_action( "affwp_preview_payout_note_{$method_key}" );
			$method_note = trim( ob_get_clean() );
			$method_note = preg_replace( '#<h2[^>]*>.*?</h2>#is', '', $method_note );
			$method_note = trim( $method_note );

			$breakdown_parts = array();
			if ( $method_data['ready_count'] > 0 ) {
				$breakdown_parts[] = sprintf(
					/* translators: %d: ready affiliate count */
					_n( '%d ready', '%d ready', $method_data['ready_count'], 'affiliate-wp' ),
					(int) $method_data['ready_count']
				);
			}
			if ( $method_data['blocked_count'] > 0 ) {
				$breakdown_parts[] = sprintf(
					/* translators: %d: affiliates blocked by missing setup */
					_n( '%d not connected', '%d not connected', $method_data['blocked_count'], 'affiliate-wp' ),
					(int) $method_data['blocked_count']
				);
			}
			$show_breakdown      = count( $breakdown_parts ) > 1;
			$section_total_muted = 0 === $method_data['count']
				|| ( $method_data['blocked_count'] > 0 && $method_data['ready_count'] + $method_data['invite_count'] === 0 );
			?>
			<section id="affwp-method-<?php echo esc_attr( $method_slug ); ?>"<?php echo $deep_link ? ' data-deeplink-highlight="ring-2! ring-affwp-brand-500! ring-offset-2 transition-all"' : ''; ?> class="mt-6 max-w-5xl rounded-xl bg-white ring-1 ring-gray-200 overflow-hidden scroll-mt-24">
				<header class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2 border-b border-gray-200 px-6 py-4">
					<div class="flex items-center gap-2.5 flex-wrap">
						<?php echo $method_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns trusted SVG. ?>
						<h2 class="text-base/6 font-semibold tracking-tight m-0"><?php echo esc_html( $method_data['label'] ); ?></h2>
						<p class="text-sm/5 text-gray-500 m-0">
							<?php if ( $show_breakdown ) : ?>
								<?php echo esc_html( implode( ' · ', $breakdown_parts ) ); ?>
							<?php else : ?>
								<?php
								echo esc_html(
									sprintf(
										/* translators: %d: number of affiliates in this method group */
										_n( '%d affiliate', '%d affiliates', $method_data['count'], 'affiliate-wp' ),
										(int) $method_data['count']
									)
								);
								?>
							<?php endif; ?>
						</p>
					</div>
					<p class="text-base/6 font-semibold tabular-nums <?php echo $section_total_muted ? 'text-gray-400' : ''; ?> m-0"><?php echo affwp_currency_filter( affwp_format_amount( $method_data['total'] ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- entity-encoded HTML from helper. ?></p>
				</header>

				<?php if ( isset( $method_warnings[ $method_key ] ) && 'blocking' === $method_warnings[ $method_key ]['severity'] ) : ?>
					<?php
					$warning = $method_warnings[ $method_key ];

					// In test mode the "top up" CTA opens our in-app test-funds tool instead of sending the
					// admin out to the Stripe dashboard; live mode keeps the dashboard link since real funds
					// can only be added there. Limited to the full page context, the only surface whose
					// admin_footer renders the queued modal (the drawer keeps the dashboard-link fallback).
					$topup_in_app = ! empty( $warning['test_mode'] )
						&& 'stripe' === $method_key
						&& 'page' === $config['context']
						&& function_exists( 'affwp_stripe_payouts_render_test_topup_modal' );
					?>
					<div class="flex items-start gap-2.5 border-b border-gray-200 bg-red-50/60 px-6 py-3 text-sm/6 text-red-900">
						<span class="flex h-6 shrink-0 items-center"><?php echo \AffiliateWP\Utils\Icons::generate( 'triangle-warning', '', [ 'class' => 'size-4 text-red-600' ] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Icons::generate returns kses-sanitized SVG. ?></span>
						<p class="m-0 max-w-prose">
							<?php
							echo esc_html(
								sprintf(
									/* translators: 1: required amount with currency, 2: available balance with currency, 3: shortfall with currency, 4: site currency code */
									__( 'Your Stripe %4$s balance is %2$s but this batch needs %1$s — %3$s short. Process this batch and these transfers will fail with an insufficient-funds error.', 'affiliate-wp' ),
									affwp_currency_filter( affwp_format_amount( $warning['required'] ) ),
									affwp_currency_filter( affwp_format_amount( $warning['available'] ) ),
									affwp_currency_filter( affwp_format_amount( $warning['shortfall'] ) ),
									$warning['site_currency']
								)
							);
							?>
							<?php if ( ! empty( $warning['other_currency_label'] ) ) : ?>
								<br>
								<?php
								echo esc_html(
									sprintf(
										/* translators: 1: balance in a non-site currency (e.g. "625.41 NZD"), 2: site currency code (e.g. "USD") */
										__( 'You hold %1$s in your Stripe account, but Stripe will not convert across currencies for transfers — payouts settle in %2$s and need %2$s in your balance.', 'affiliate-wp' ),
										$warning['other_currency_label'],
										$warning['site_currency']
									)
								);
								?>
							<?php endif; ?>
							<?php if ( $topup_in_app ) : ?>
								<button type="button" x-data @click="$store.modals.open('affwp-stripe-topup-modal')" class="font-medium underline underline-offset-2 hover:no-underline cursor-pointer bg-transparent border-0 p-0 text-red-900"><?php esc_html_e( 'Add test funds', 'affiliate-wp' ); ?></button>
							<?php else : ?>
								<a href="<?php echo esc_url( $warning['dashboard_url'] ); ?>" target="_blank" rel="noopener" class="font-medium underline underline-offset-2 hover:no-underline"><?php esc_html_e( 'Top up in Stripe', 'affiliate-wp' ); ?></a>
							<?php endif; ?>
						</p>
					</div>
					<?php
					if ( $topup_in_app ) {
						// Queues the test-funds modal to admin_footer (registered by ID, so safe to call once per stripe section).
						affwp_stripe_payouts_render_test_topup_modal( (float) $warning['shortfall'], (float) $warning['required'] );
					}
					?>
				<?php endif; ?>

				<?php if ( $method_data['blocked_count'] > 0 && 'stripe' === $method_key ) : ?>
					<div class="flex items-start gap-2.5 border-b border-gray-200 bg-amber-50/60 px-6 py-3 text-sm/6 text-amber-900">
						<span class="flex h-6 shrink-0 items-center"><?php echo \AffiliateWP\Utils\Icons::generate( 'circle-info', '', [ 'class' => 'size-4 text-amber-600' ] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Icons::generate returns kses-sanitized SVG. ?></span>
						<?php
						$affwp_stripe_auto_handled = (bool) affiliate_wp()->settings->get( 'auto_email_payout_failures', true )
							&& (bool) affiliate_wp()->settings->get( 'auto_retry_failed_payouts', true );
						?>
						<p class="m-0 max-w-prose"><?php
						echo esc_html(
							$affwp_stripe_auto_handled
								? __( "Affiliates who haven't connected yet are emailed a link to connect their Stripe account, then paid automatically once they do.", 'affiliate-wp' )
								: __( "Affiliates who haven't connected yet can't be paid until they connect their Stripe account.", 'affiliate-wp' )
						);
						?></p>
					</div>
				<?php endif; ?>

				<?php if ( $method_note !== '' ) : ?>
					<div class="border-b border-gray-200 bg-gray-50 px-6 py-2.5 text-sm/6 text-gray-600 [&_p]:m-0 [&_p]:max-w-prose [&_p+p]:mt-1.5 [&_a]:underline [&_a]:underline-offset-2">
						<?php echo $method_note; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- captured from documented public hook; addon authors own this surface. ?>
					</div>
				<?php endif; ?>

				<?php if ( 0 === $method_data['count'] ) : ?>
					<p class="px-6 py-6 text-sm/6 text-gray-500 m-0"><?php esc_html_e( 'No eligible affiliates are configured for this method this run.', 'affiliate-wp' ); ?></p>
				<?php else : ?>
					<div role="table" class="text-sm/6">
						<div role="rowgroup" class="divide-y divide-gray-200" data-affwp-preview-rows>
							<?php
							$visible_rows = array_slice( $method_data['payouts'], 0, $method_visible, true );
							?>
							<?php foreach ( $visible_rows as $affiliate_id => $payout ) : ?>
								<?php echo affwp_render_preview_payout_row( $affiliate_id, $payout, $lookup ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper outputs escaped markup. ?>
							<?php endforeach; ?>
						</div>

						<?php if ( $load_more && $overflow > 0 ) : ?>
							<button
								type="button"
								class="affwp-preview-load-more w-full bg-gray-50 hover:bg-gray-100 px-6 py-2 text-center cursor-pointer transition-colors border-t border-gray-200 disabled:cursor-wait disabled:opacity-60"
								data-section="method"
								data-method-key="<?php echo esc_attr( $method_key ); ?>"
								data-offset="<?php echo esc_attr( (int) $method_visible ); ?>"
								data-remaining="<?php echo esc_attr( (int) $overflow ); ?>"
								data-batch="<?php echo esc_attr( (int) $config['load_more_batch'] ); ?>"
								data-method-label="<?php echo esc_attr( $method_data['label'] ); ?>"
								aria-busy="false"
							>
								<span class="inline-flex items-center gap-1.5 text-sm/5 font-medium text-gray-600">
									<?php \AffiliateWP\Utils\Icons::render( 'chevron-down', [ 'class' => 'size-3.5 text-gray-400 shrink-0' ] ); ?>
									<span data-affwp-load-more-label>
										<?php echo esc_html( affwp_get_preview_load_more_label( $overflow, (int) $config['load_more_batch'] ) ); ?>
									</span>
									<span data-affwp-load-more-busy hidden><?php esc_html_e( 'Loading…', 'affiliate-wp' ); ?></span>
								</span>
							</button>
						<?php endif; ?>
					</div>
				<?php endif; ?>
			</section>
		<?php endforeach; ?>
		<?php endif; // $has_payouts — hero + per-method sections. ?>

		<?php if ( ! empty( $invalid_affiliates ) && ! empty( $config['show_invalid_section'] ) ) : ?>
			<?php
			$invalid_visible_count = affwp_get_preview_visible_count( $invalid_count, $invalid_row_cap, $row_snap );
			$invalid_visible       = array_slice( $invalid_affiliates, 0, $invalid_visible_count, true );
			$invalid_overflow      = max( 0, $invalid_count - $invalid_visible_count );
			?>
			<section id="affwp-invalid-affiliates"<?php echo $deep_link ? ' data-deeplink-highlight="ring-2! ring-affwp-brand-500! ring-offset-2 transition-all"' : ''; ?> class="mt-6 max-w-5xl rounded-xl bg-white ring-1 ring-gray-200 overflow-hidden scroll-mt-24">
				<header class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2 border-b border-gray-200 px-6 py-4">
					<div class="flex items-center gap-2.5">
						<h2 class="text-base/6 font-semibold tracking-tight m-0"><?php esc_html_e( 'Cannot be paid this run', 'affiliate-wp' ); ?></h2>
						<p class="text-sm/5 text-gray-500 m-0">
							<?php
							echo esc_html(
								sprintf(
									/* translators: %d: number of affiliates */
									_n( '%d affiliate', '%d affiliates', $invalid_count, 'affiliate-wp' ),
									(int) $invalid_count
								)
							);
							?>
						</p>
					</div>
				</header>
				<div role="table" class="text-sm/6" aria-label="<?php esc_attr_e( 'Affiliates that cannot be paid this run', 'affiliate-wp' ); ?>">
					<div role="rowgroup" class="divide-y divide-gray-200" data-affwp-preview-rows>
						<?php foreach ( $invalid_visible as $affiliate_id => $status ) : ?>
							<?php echo affwp_render_preview_invalid_row( $affiliate_id, $status, $lookup ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper outputs escaped markup. ?>
						<?php endforeach; ?>
					</div>

					<?php if ( $load_more && $invalid_overflow > 0 ) : ?>
						<button
							type="button"
							class="affwp-preview-load-more w-full bg-gray-50 hover:bg-gray-100 px-6 py-2 text-center cursor-pointer transition-colors border-t border-gray-200 disabled:cursor-wait disabled:opacity-60"
							data-section="invalid"
							data-offset="<?php echo esc_attr( (int) $invalid_visible_count ); ?>"
							data-remaining="<?php echo esc_attr( (int) $invalid_overflow ); ?>"
							data-batch="<?php echo esc_attr( (int) $config['load_more_batch'] ); ?>"
							aria-busy="false"
						>
							<span class="inline-flex items-center gap-1.5 text-sm/5 font-medium text-gray-600">
								<?php \AffiliateWP\Utils\Icons::render( 'chevron-down', [ 'class' => 'size-3.5 text-gray-400 shrink-0' ] ); ?>
								<span data-affwp-load-more-label>
									<?php echo esc_html( affwp_get_preview_load_more_label( $invalid_overflow, (int) $config['load_more_batch'] ) ); ?>
								</span>
								<span data-affwp-load-more-busy hidden><?php esc_html_e( 'Loading…', 'affiliate-wp' ); ?></span>
							</span>
						</button>
					<?php endif; ?>
				</div>
			</section>
		<?php endif; ?>

		<?php if ( ! $has_payouts && empty( $invalid_affiliates ) && ! empty( $config['show_empty_fallback'] ) ) : ?>
			<p class="text-sm/6 text-gray-500 m-0"><?php esc_html_e( 'There is nothing to pay in this selection.', 'affiliate-wp' ); ?></p>
		<?php endif; ?>
		<?php

	return ob_get_clean();
}
