<?php
/**
 * Payout Drawer — AJAX handlers
 *
 * Four endpoints backing the payout detail drawer:
 *   - affwp_get_payout_drawer_data   (opens the drawer)
 *   - affwp_payout_drawer_check_status
 *   - affwp_payout_drawer_retry
 *   - affwp_payout_drawer_delete
 *
 * @package     AffiliateWP
 * @subpackage  Admin/Payouts
 * @since       2.33.0
 */

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

add_action( 'wp_ajax_affwp_get_payout_drawer_data', 'affwp_ajax_get_payout_drawer_data' );
add_action( 'wp_ajax_affwp_payout_drawer_check_status', 'affwp_ajax_payout_drawer_check_status' );
add_action( 'wp_ajax_affwp_payout_drawer_retry', 'affwp_ajax_payout_drawer_retry' );
add_action( 'wp_ajax_affwp_payout_drawer_delete', 'affwp_ajax_payout_drawer_delete' );
add_action( 'wp_ajax_affwp_payout_drawer_send_failure_email', 'affwp_ajax_payout_drawer_send_failure_email' );
add_action( 'wp_ajax_affwp_payout_drawer_switch_method', 'affwp_ajax_payout_drawer_switch_method' );

add_action( 'wp_ajax_affwp_get_batch_drawer_data', 'affwp_ajax_get_batch_drawer_data' );
add_action( 'wp_ajax_affwp_batch_drawer_retry_failed', 'affwp_ajax_batch_drawer_retry_failed' );

add_action( 'wp_ajax_affwp_dismiss_default_payout_mismatch_hint', 'affwp_ajax_dismiss_default_payout_mismatch_hint' );
add_action( 'wp_ajax_affwp_dismiss_payout_selection_nudge', 'affwp_ajax_dismiss_payout_selection_nudge' );

/**
 * Persists the admin's dismissal of the "Let affiliates choose how they're paid" nudge on the
 * Settings → Payouts page. A sticky per-user flag: once dismissed it stays dismissed, so an
 * admin who deliberately runs multiple methods with self-selection off (e.g. hand-assigning
 * methods per affiliate) isn't nagged again.
 *
 * @since 2.33.0
 */
function affwp_ajax_dismiss_payout_selection_nudge() {

	check_ajax_referer( 'affwp_dismiss_payout_selection_nudge' );

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

	update_user_meta( get_current_user_id(), 'affwp_payout_selection_nudge_dismissed', '1' );

	wp_send_json_success();
}

/**
 * Persists the admin's dismissal of the "Default is Manual but other methods are enabled"
 * mismatch hint on the Settings → Payouts page. Stored as a per-user JSON snapshot of the
 * configuration at dismissal time. When the admin changes the Default or toggles a method,
 * the stored snapshot no longer matches the live configuration and the hint reappears.
 *
 * @since 2.33.0
 */
function affwp_ajax_dismiss_default_payout_mismatch_hint() {

	check_ajax_referer( 'affwp_dismiss_default_payout_mismatch_hint' );

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

	$snapshot = isset( $_POST['snapshot'] ) ? wp_unslash( $_POST['snapshot'] ) : '';

	if ( ! is_string( $snapshot ) || '' === $snapshot ) {
		wp_send_json_error( array( 'message' => __( 'Missing snapshot.', 'affiliate-wp' ) ), 400 );
	}

	// Hard cap before json_decode so a multi-MB blob can't burn parser time.
	if ( strlen( $snapshot ) > 4096 ) {
		wp_send_json_error( array( 'message' => __( 'Snapshot too large.', 'affiliate-wp' ) ), 400 );
	}

	// Snapshot must be a JSON object with the expected shape; anything else is a malformed request.
	$decoded = json_decode( $snapshot, true );
	if ( ! is_array( $decoded ) || ! isset( $decoded['default'] ) || ! isset( $decoded['enabled'] ) ) {
		wp_send_json_error( array( 'message' => __( 'Invalid snapshot shape.', 'affiliate-wp' ) ), 400 );
	}

	/*
	 * Store only the two recognized fields, re-encoded here. The original $_POST string is
	 * discarded so a CSRF can't persist arbitrary JSON into user_meta.
	 */
	$clean = array(
		'default' => is_string( $decoded['default'] ) ? sanitize_key( $decoded['default'] ) : '',
		'enabled' => is_array( $decoded['enabled'] ) ? array_values( array_map( 'sanitize_key', $decoded['enabled'] ) ) : array(),
	);

	update_user_meta( get_current_user_id(), 'affwp_default_payout_mismatch_dismissed', wp_json_encode( $clean ) );

	wp_send_json_success();
}

/**
 * Builds the shape of data the payout drawer renders.
 *
 * Runs on initial open and after actions that change payout state.
 *
 * @since 2.33.0
 */
function affwp_ajax_get_payout_drawer_data() {

	check_ajax_referer( 'affwp_get_payout_drawer_data' );

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

	$payout_id = isset( $_POST['payout_id'] ) ? absint( $_POST['payout_id'] ) : 0;
	$payout    = $payout_id ? affwp_get_payout( $payout_id ) : false;

	if ( ! $payout ) {
		wp_send_json_error( [ 'message' => __( 'Payout not found.', 'affiliate-wp' ) ], 404 );
	}

	wp_send_json_success( affwp_build_payout_drawer_payload( $payout ) );
}

/**
 * Composes the payout + referrals + paypal data + details_extras_html payload.
 *
 * Extracted so check_status and retry can reuse the same shape when they
 * return fresh state to the drawer.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout Payout object.
 * @return array
 */
function affwp_build_payout_drawer_payload( $payout ) {

	$affiliate_id    = (int) $payout->affiliate_id;
	$affiliate_name  = affiliate_wp()->affiliates->get_affiliate_name( $affiliate_id );
	$affiliate_email = affwp_get_affiliate_email( $affiliate_id );

	/*
	 * The payment email funds route to (falls back to the account email when unset). Shown
	 * in the drawer's Email row as the payout destination; the avatar stays on the account email.
	 */
	$payout_email = affwp_get_affiliate_payment_email( $affiliate_id );

	/*
	 * Avatar: request a 404 (not a generic identicon) when no Gravatar exists,
	 * so the <img onerror> handler can reveal initials instead. Matches the
	 * initials-fallback pattern from PR #5752's affwp_build_avatar_stack().
	 */
	$affiliate_avatar_url = '';
	if ( $affiliate_email ) {
		$affiliate_avatar_url = get_avatar_url( $affiliate_email, [ 'size' => 56, 'default' => '404' ] );
	}

	$affiliate_initials = affwp_get_initials_from_name( $affiliate_name );

	$owner_label = '';
	if ( (int) $payout->owner > 0 ) {
		$owner = get_user_by( 'id', $payout->owner );
		if ( $owner ) {
			/* translators: 1: user display name, 2: user ID */
			$owner_label = sprintf( __( '%1$s (User ID: %2$d)', 'affiliate-wp' ), $owner->data->display_name, $payout->owner );
		} else {
			$owner_label = __( '(user deleted)', 'affiliate-wp' );
		}
	}
	/*
	 * Owner = 0 means no WordPress user was attributed (scheduled / cron
	 * auto-pay jobs, WP-CLI commands without --user, REST API calls
	 * without an authenticated context, seeded fixture data). Render an
	 * empty owner_label so the consumer view hides the Generated-by row
	 * entirely. The batch-drawer Summary does the same — both surfaces
	 * drop the "by X" line rather than rendering a "by System" or
	 * "by (unknown)" placeholder that reads as missing data.
	 */

	/*
	 * Split the description into its original portion and any appended
	 * audit log. The audit log (method switches, future audit events)
	 * lives in its own structured payload field rather than mixed into
	 * the Error display. Parsers below operate only on the original
	 * portion so PayPal JSON and Stripe plain-text descriptions both
	 * resolve cleanly post-audit.
	 */
	$description_original = affwp_payout_description_without_audit( (string) $payout->description );
	$audit_log_raw        = affwp_payout_description_audit_log( (string) $payout->description );

	// Decode PayPal batch JSON when the original description carries it.
	$paypal_data = null;
	if ( ! empty( $description_original ) ) {
		$decoded = json_decode( $description_original, true );
		if ( is_array( $decoded ) && ! empty( $decoded['paypal_batch_id'] ) ) {
			$paypal_data = $decoded;

			// Human-friendly last-checked timestamp.
			if ( ! empty( $paypal_data['last_checked'] ) ) {
				$paypal_data['last_checked_human'] = human_time_diff( strtotime( $paypal_data['last_checked'] ), current_time( 'timestamp', true ) ) . ' ' . __( 'ago', 'affiliate-wp' );
			}

			// Compose a dashboard URL the admin can click straight to PayPal.
			$is_test = affiliate_wp()->settings->get( 'paypal_test_mode' );
			$paypal_data['dashboard_url'] = $is_test
				? 'https://www.sandbox.paypal.com/listpayments-transactions/payouts'
				: 'https://www.paypal.com/listpayments-transactions/payouts';
		}
	}

	/*
	 * Plain-text failure reason for non-PayPal methods. Stripe writes the
	 * human error string to the description; PayPal stores JSON and is
	 * already surfaced via $paypal_data above. Skip when status isn't
	 * failed (description doubles as a notes field on successful payouts)
	 * and when the description parses as PayPal JSON.
	 */
	$error_message = '';
	if ( 'failed' === $payout->status && ! empty( $description_original ) && null === $paypal_data ) {
		$error_message = $description_original;
	}

	/*
	 * Format the PayPal failure error into a human-readable headline +
	 * disclosable raw blob. The drawer's red "This payout failed"
	 * card and the Details panel's "Error" row both consume these so the
	 * admin sees a real sentence by default, with the raw provider
	 * payload tucked behind a collapsible for support investigations.
	 *
	 * Two sources feed into this:
	 *
	 *   - $paypal_data['error']: present when the payout has a valid
	 *     paypal_batch_id (batch was created, then failed downstream).
	 *
	 *   - $description_original parsed via the helper: present when the
	 *     payout failed BEFORE the batch was created (e.g. PayPal
	 *     returned an "Unprocessable Entity" 422 on the create call).
	 *     In that case fail_generated_payout() writes JSON with
	 *     paypal_batch_id="" + the error, so the gate above leaves
	 *     $paypal_data null and the raw JSON would otherwise leak into
	 *     $error_message verbatim — that's the overflow the QA report
	 *     flagged.
	 */
	if ( function_exists( 'affwp_paypal_format_failure_error' ) ) {

		if ( is_array( $paypal_data ) && ! empty( $paypal_data['error'] ) ) {
			/*
			 * Active-batch failure: $paypal_data is already decoded, so
			 * skip the helper's JSON round-trip and just pretty-print
			 * the structured payload here for the disclosure.
			 */
			$paypal_data['error_headline'] = (string) $paypal_data['error'];
			$paypal_data['error_raw']      = wp_json_encode(
				$paypal_data,
				JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
			);
		} elseif ( '' !== $error_message ) {
			/*
			 * Detect a PayPal-shaped JSON blob that the gate above
			 * passed through to $error_message and rewrite it as a
			 * parsed paypal_data shape with just the error fields. The
			 * batch-info blocks remain hidden (no paypal_batch_id) but
			 * the failure card gets the human sentence + disclosure.
			 */
			$looks_like_paypal_json = (
				'paypal' === strtolower( (string) $payout->payout_method )
				&& 0 === strpos( ltrim( $description_original ), '{' )
			);
			if ( $looks_like_paypal_json ) {
				$formatted = affwp_paypal_format_failure_error( $description_original );
				if ( ! empty( $formatted['headline'] ) ) {
					$paypal_data = array(
						'error_headline' => $formatted['headline'],
						'error_raw'      => $formatted['raw'],
					);
					// Avoid double-rendering: views fall back to error_message only when paypal_data is null.
					$error_message = '';
				}
			}
		}
	}

	/*
	 * Normalise audit events for the view. Each entry gets a human-readable
	 * timestamp computed server-side (locale-aware) so the view stays dumb.
	 * Latest event sits last in the array (chronological order is preserved
	 * from the storage layer).
	 */
	$audit_log = array();
	foreach ( (array) $audit_log_raw as $event ) {
		if ( ! is_array( $event ) ) {
			continue;
		}
		$at = isset( $event['at'] ) ? (string) $event['at'] : '';
		$event['at_human'] = $at ? date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $at ) ) : '';
		$audit_log[] = $event;
	}

	/*
	 * Pluck the most recent method-switch event for the Supersession card.
	 * Walks the audit log backwards so a payout switched multiple times
	 * shows the latest pivot. NULL when no method-switch event exists.
	 */
	$last_method_switch_event = null;
	foreach ( array_reverse( $audit_log ) as $event ) {
		if ( isset( $event['event'] ) && 'method_switched' === $event['event'] ) {
			$last_method_switch_event = $event;
			break;
		}
	}

	$status_labels = [
		'paid'       => __( 'Paid', 'affiliate-wp' ),
		'processing' => __( 'Processing', 'affiliate-wp' ),
		'failed'     => __( 'Failed', 'affiliate-wp' ),
		'pending'    => __( 'Pending', 'affiliate-wp' ),
	];

	$payout_payload = [
		'payout_id'            => (int) $payout->ID,
		'affiliate_id'         => $affiliate_id,
		'affiliate_name'       => $affiliate_name,
		'affiliate_email'      => $affiliate_email,
		'payout_email'         => $payout_email,
		'affiliate_avatar_url' => $affiliate_avatar_url,
		'affiliate_initials'   => $affiliate_initials,
		'affiliate_url'        => affwp_admin_url( 'affiliates', [ 'action' => 'view_affiliate', 'affiliate_id' => $affiliate_id ] ),
		'amount'               => (float) $payout->amount,
		'amount_formatted'     => html_entity_decode( affwp_currency_filter( affwp_format_amount( $payout->amount ) ), ENT_COMPAT, 'UTF-8' ),
		'status'               => $payout->status,
		/*
		 * Status label override for superseded payouts. The underlying
		 * row stays `status=failed` in the database (that's how the
		 * original outcome was recorded), but operationally the row is
		 * closed audit data, not an active failure. Rendering the
		 * status pill as a red "Failed" keeps drawing the eye to a
		 * resolved problem; the supersession card carries the right
		 * messaging. Override to "Superseded" + neutral variant
		 * (handled by the JS badgeVariantForPayoutStatus getter) so the
		 * pill calibrates to operational state, not literal DB status.
		 */
		'status_label'         => affwp_get_payout_superseded_by( $payout )
			? __( 'Superseded', 'affiliate-wp' )
			: ( $status_labels[ $payout->status ] ?? ucfirst( $payout->status ) ),
		'payout_method'        => $payout->payout_method,
		/*
		 * Properly-cased display label for the method. Lower-case slugs like
		 * "paypal" render as "Paypal" under Tailwind's `capitalize` class
		 * because CSS only handles per-word casing, not the brand-specific
		 * "PayPal" mid-word capitalisation. The server-side registry
		 * already knows the right label; expose it so the views don't have
		 * to guess.
		 */
		'payout_method_label'  => affwp_get_payout_method_display_label( $payout->payout_method ) ?: ucfirst( str_replace( array( '_', '-' ), ' ', (string) $payout->payout_method ) ),
		'service_account'      => $payout->service_account,
		'service_invoice_link' => $payout->service_invoice_link,
		'batch_id'             => (int) $payout->batch_id,
		'batch_url'            => $payout->batch_id ? affwp_admin_url( 'payouts', [ 'batch_id' => (int) $payout->batch_id ] ) : '',
		'owner'                => (int) $payout->owner,
		'owner_label'          => $owner_label,
		'date'                 => $payout->date,
		'date_formatted'       => $payout->date_i18n( 'datetime' ),
		// Short form for the header ribbon. Full form stays in Details.
		'date_short'           => $payout->date_i18n( 'date' ),
		'error_message'        => $error_message,
		/*
		 * Audit log array (chronological, oldest first). Currently populated
		 * by Switch-payout-method events; consumed by the Supersession
		 * card to surface method-switch specifics ("From PayPal to Store
		 * Credit by admin on May 16"). Future audit events (auto-retry
		 * fired, status manually overridden, etc.) can append here using
		 * the same structured-marker storage pattern.
		 */
		'audit_log'                 => $audit_log,
		'last_method_switch_event'  => $last_method_switch_event,
		/*
		 * Machine-readable failure category. NULL on successful payouts;
		 * one of the AffWP\Payouts\Failure_Class constants on failed ones.
		 * The drawer's "Next Steps" card + footer dispatch + Retry gate
		 * all read this. See class-failure-classifier.php for the enum.
		 */
		'failure_class'        => $payout->failure_class ?: '',
		'failure_class_label'  => affwp_get_payout_failure_class_label( $payout->failure_class ?: '' ),
		/*
		 * Next-steps payload — a self-describing recommendation card for
		 * failed payouts: per-class headline + description + primary
		 * action descriptor. NULL when not applicable (successful payout
		 * or unclassified failure). The drawer JS dispatches on
		 * `primary_action.kind` to route the click correctly.
		 */
		'next_steps'           => affwp_build_payout_next_steps( $payout, $affiliate_name ),
		/*
		 * Whether the drawer's Retry button is enabled. Reads the failure
		 * class via the Failure_Class::is_retryable() default, then runs
		 * through the affwp_payout_retry_enabled filter. Drawers display
		 * an explanatory tooltip on the disabled state.
		 */
		'is_retryable'         => affwp_is_payout_retry_enabled( $payout ),
		/*
		 * Last-contacted timestamp surfaced to the drawer so the Next
		 * Steps card can render "Last contacted 2 days ago" beneath its
		 * primary button. Empty when the affiliate has never received a
		 * failure email (or the meta key was cleared on a successful
		 * retry). Format mirrors paypal_data['last_checked_human'] —
		 * unix-stamp + a pre-rendered human-readable diff so the JS
		 * doesn't need its own time-ago utility.
		 */
		'last_failure_email_sent_at'    => affwp_failure_email_last_sent_at( $affiliate_id ),
		'last_failure_email_sent_human' => affwp_failure_email_last_sent_human( $affiliate_id ),
		/*
		 * Cooldown state flag. True when the affiliate received an outreach
		 * within the cooldown window (default 7 days). The Next Steps card
		 * branches on this: no flag → primary "Send" button; flagged →
		 * status copy + quieter "Resend reminder" link gated by confirmation.
		 */
		'failure_email_cooldown_active' => affwp_failure_email_cooldown_active( $affiliate_id ),
		/*
		 * Method-switch dropdown options. Map of slug => label, filtered
		 * to methods that are (a) enabled site-wide, (b) ready for this
		 * affiliate (per the readiness check chain), and (c) not the
		 * current method. Empty array when no alternatives exist; drawer
		 * hides the Switch action in that case.
		 */
		'switchable_methods'   => 'failed' === $payout->status ? affwp_get_payout_methods_for_switch( $payout ) : array(),
		/*
		 * Supersession lookup. When a failed payout was switched to a
		 * different method via affwp_switch_payout_method(), its referrals
		 * are attached to a NEW payout under the new method while this
		 * old payout stays as a read-only audit record. We surface that
		 * relationship so the drawer can render a "Replaced by #N" banner
		 * with a click-through, instead of leaving the admin confused
		 * about why this row says "stripe failed" but the audit note
		 * mentions a switch.
		 *
		 * Zero / empty when this payout wasn't superseded.
		 */
		'superseded_by_payout_id' => (int) affwp_get_payout_superseded_by( $payout ),
		'superseded_by_url'    => affwp_get_payout_superseded_by( $payout )
			? affwp_admin_url( 'payouts', array( 'payout_id' => (int) affwp_get_payout_superseded_by( $payout ) ) )
			: '',
		/*
		 * The prior attempt this payout replaced (inverse of superseded_by).
		 * Lets the current payout's drawer link back to the collapsed previous
		 * attempt, since superseded rows are hidden from the list.
		 */
		'previous_payout_id'   => (int) affwp_get_payout_predecessor( $payout ),
		'details_extras_html'  => affwp_capture_payout_details_extras( $payout ),
		/*
		 * Total fees across all referrals in this payout. Per-referral
		 * fees were dropped from the referral cards as repetitive noise
		 * (each row showed the same provider fee, which the admin doesn't
		 * act on row-by-row). Surfacing the sum in the Details panel
		 * preserves the accounting data for reconciliation without
		 * cluttering the operational read above. PayPal sums per-item
		 * `_paypal_payout_fee` meta; Stripe sums `_stripe_transfer_fee`.
		 * Empty string when there are no fees recorded.
		 */
		'total_fees_formatted' => html_entity_decode( affwp_get_payout_total_fees_formatted( $payout ), ENT_COMPAT, 'UTF-8' ),
		/*
		 * Referral IDs that the payout's `referrals` column points to but
		 * no longer exist in the referrals table (deleted out from under
		 * the payout). Lets the drawer's empty-state copy say "Referral
		 * #N no longer exists" instead of the misleading "No referrals
		 * are attached to this payout" — the latter implies the payout
		 * never had referrals, when really one or more were deleted
		 * after the payout was processed. Most payouts return [].
		 */
		'deleted_referral_ids' => affwp_get_payout_deleted_referral_ids( $payout ),
	];

	return [
		'payout'      => $payout_payload,
		'referrals'   => affwp_build_payout_drawer_referrals( $payout ),
		'paypal_data' => $paypal_data,
	];
}

/**
 * Builds the referrals array attached to a payout.
 *
 * Includes PayPal per-item meta when present so the Referrals section can
 * render inline status badges, transaction IDs, fees, and UNCLAIMED warnings.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout Payout object.
 * @return array
 */
function affwp_build_payout_drawer_referrals( $payout ) {

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

	if ( empty( $referral_ids ) ) {
		return [];
	}

	$out = [];
	foreach ( $referral_ids as $rid ) {
		$referral = affwp_get_referral( $rid );
		if ( ! $referral ) {
			continue;
		}

		$item_status = affwp_get_referral_meta( $rid, '_paypal_transaction_status', true );
		$txn_id      = affwp_get_referral_meta( $rid, '_paypal_transaction_id', true );
		$fee         = affwp_get_referral_meta( $rid, '_paypal_payout_fee', true );
		$recipient   = affwp_get_referral_meta( $rid, '_paypal_receiver_email', true );

		/*
		 * Link transaction IDs to PayPal's activity-detail URL so admins can
		 * open the authoritative record in one click (same pattern as the
		 * PayPal batch ID link in the Summary card). Sandbox vs. live is
		 * picked from the PayPal Payouts test-mode setting.
		 */
		$txn_url = '';
		if ( $txn_id ) {
			$paypal_host = affiliate_wp()->settings->get( 'paypal_test_mode' )
				? 'https://www.sandbox.paypal.com'
				: 'https://www.paypal.com';
			$txn_url = $paypal_host . '/activity/payment/' . rawurlencode( $txn_id );
		}

		$warning = '';
		switch ( $item_status ) {
			case 'UNCLAIMED':
				/*
				 * Days-remaining countdown: PayPal returns unclaimed funds
				 * 30 days after the payout was created. Surface the actual
				 * deadline rather than a generic "after 30 days" line so
				 * urgency is calibrated. A payout with 28 days left feels
				 * routine; a payout with 3 days left needs attention now.
				 */
				$days_left = affwp_get_payout_unclaimed_days_remaining( $payout );
				if ( $days_left > 0 ) {
					$warning = sprintf(
						/* translators: %d: number of days remaining before PayPal returns the funds */
						_n(
							'Recipient does not have a PayPal account. Funds will be returned in %d day if unclaimed.',
							'Recipient does not have a PayPal account. Funds will be returned in %d days if unclaimed.',
							$days_left,
							'affiliate-wp'
						),
						$days_left
					);
				} else {
					$warning = __( 'Recipient does not have a PayPal account. Funds will be returned imminently if unclaimed.', 'affiliate-wp' );
				}
				break;
			case 'RETURNED':
				$warning = __( 'Funds were returned by PayPal (unclaimed after 30 days).', 'affiliate-wp' );
				break;
			case 'BLOCKED':
				$warning = __( 'PayPal blocked this transaction.', 'affiliate-wp' );
				break;
			case 'REFUNDED':
				$warning = __( 'This transaction was refunded.', 'affiliate-wp' );
				break;
			case 'REVERSED':
				$warning = __( 'This transaction was reversed.', 'affiliate-wp' );
				break;
			case 'FAILED':
				$warning = __( 'PayPal reports this transaction failed.', 'affiliate-wp' );
				break;
			case 'DENIED':
				$warning = __( 'PayPal denied this transaction.', 'affiliate-wp' );
				break;
			case 'PENDING':
			case 'ONHOLD':
				$warning = __( 'PayPal is still reviewing this transaction.', 'affiliate-wp' );

				// Prefer PayPal's own reason when captured, so the admin sees why it's held rather than a generic line.
				$item_error = affwp_get_referral_meta( $rid, '_paypal_error', true );
				if ( $item_error ) {
					$decoded_error = json_decode( $item_error, true );
					if ( is_array( $decoded_error ) && ! empty( $decoded_error['message'] ) ) {
						$warning = (string) $decoded_error['message'];
					}
				}
				break;
		}

		/*
		 * Stripe — per-referral state. Current gap vs PayPal: no
		 * per-item status code, no fee capture, no receiver email.
		 * Surfaced what we have: the Transfer ID, optional reversal /
		 * failure markers (set by the webhook). Test-mode picks the
		 * dashboard URL's sandbox variant the same way PayPal does.
		 */
		$stripe_transfer_id       = affwp_get_referral_meta( $rid, 'stripe_transfer_id', true );
		$stripe_transfer_reversed = affwp_get_referral_meta( $rid, 'stripe_transfer_reversed', true );
		$stripe_transfer_failed   = affwp_get_referral_meta( $rid, 'stripe_transfer_failed', true );
		$stripe_fee               = affwp_get_referral_meta( $rid, '_stripe_fee', true );
		$stripe_error             = affwp_get_referral_meta( $rid, '_stripe_transfer_error', true );

		$stripe_transfer_url = '';
		if ( $stripe_transfer_id ) {
			$stripe_test_mode    = (bool) affiliate_wp()->settings->get( 'stripe_test_mode', false );
			$stripe_transfer_url = sprintf(
				'https://dashboard.stripe.com/%sconnect/transfers/%s',
				$stripe_test_mode ? 'test/' : '',
				rawurlencode( $stripe_transfer_id )
			);
		}

		/*
		 * Stripe per-item status derived from the webhook-stamped meta.
		 * Surfaces as the same "item_status" shape the drawer JS expects,
		 * so the hoisted warning + per-row badge rendering flows through
		 * unchanged. 'reversed' maps to UNCLAIMED-ish severity, 'failed'
		 * maps to FAILED.
		 */
		$stripe_item_status = '';
		$stripe_warning     = '';
		if ( $stripe_transfer_failed ) {
			$stripe_item_status = 'FAILED';
			$stripe_warning     = $stripe_error
				? sprintf(
					/* translators: %s: Stripe error reason. */
					__( 'Stripe reports this transfer failed: %s', 'affiliate-wp' ),
					$stripe_error
				)
				: __( 'Stripe reports this transfer failed.', 'affiliate-wp' );
		} elseif ( $stripe_transfer_reversed ) {
			$stripe_item_status = 'REVERSED';
			$stripe_warning     = __( 'Stripe reversed this transfer. Referral has been set back to unpaid.', 'affiliate-wp' );
		}

		/*
		 * Method-neutral fields the drawer JS / template can consume
		 * without branching on method. Prefer the method's own data
		 * when present, fall back empty. New methods added later just
		 * populate these same keys.
		 */
		$item_status_neutral      = $item_status ?: $stripe_item_status;
		$item_warning_neutral     = $warning ?: $stripe_warning;
		$item_transaction_id      = $txn_id ?: $stripe_transfer_id;
		$item_transaction_url     = $txn_url ?: $stripe_transfer_url;
		$item_transaction_label   = $txn_id
			? __( 'Transaction', 'affiliate-wp' )
			: ( $stripe_transfer_id ? __( 'Transfer', 'affiliate-wp' ) : '' );
		$item_fee_formatted       = '';
		if ( $fee !== '' && $fee !== null ) {
			$item_fee_formatted = html_entity_decode( affwp_currency_filter( affwp_format_amount( (float) $fee ), $referral->currency ), ENT_COMPAT, 'UTF-8' );
		} elseif ( $stripe_fee !== '' && $stripe_fee !== null ) {
			$item_fee_formatted = html_entity_decode( affwp_currency_filter( affwp_format_amount( (float) $stripe_fee ), $referral->currency ), ENT_COMPAT, 'UTF-8' );
		}

		$out[] = [
			'referral_id'              => (int) $referral->referral_id,
			'status'                   => $referral->status,
			'amount'                   => (float) $referral->amount,
			'amount_formatted'         => html_entity_decode( affwp_currency_filter( affwp_format_amount( $referral->amount ), $referral->currency ), ENT_COMPAT, 'UTF-8' ),
			'affiliate_id'             => (int) $referral->affiliate_id,
			'affiliate_name'           => affiliate_wp()->affiliates->get_affiliate_name( $referral->affiliate_id ),
			'edit_url'                 => affwp_admin_url( 'referrals', [ 'action' => 'edit_referral', 'referral_id' => $referral->referral_id ] ),

			// Method-neutral shape (preferred — new code should use these).
			'item_status'              => $item_status_neutral,
			'item_warning'             => $item_warning_neutral,
			'item_transaction_id'      => $item_transaction_id ?: '',
			'item_transaction_url'     => $item_transaction_url,
			'item_transaction_label'   => $item_transaction_label,
			'item_fee_formatted'       => $item_fee_formatted,
			'item_recipient'           => $recipient ?: '',

			/*
			 * PayPal-specific keys retained for backwards compat with any
			 * third-party code / integrator reading the payload. Can be
			 * removed in 2.36 if no external subscribers remain.
			 */
			'paypal_item_status'       => $item_status ?: '',
			'paypal_transaction_id'    => $txn_id ?: '',
			'paypal_transaction_url'   => $txn_url,
			'paypal_fee_formatted'     => $fee !== '' ? html_entity_decode( affwp_currency_filter( affwp_format_amount( (float) $fee ), $referral->currency ), ENT_COMPAT, 'UTF-8' ) : '',
			'paypal_receiver_email'    => $recipient ?: '',
			'paypal_warning'           => $warning,

			// Stripe-specific keys — same rationale.
			'stripe_transfer_id'       => $stripe_transfer_id ?: '',
			'stripe_transfer_url'      => $stripe_transfer_url,
			'stripe_item_status'       => $stripe_item_status,
			'stripe_warning'           => $stripe_warning,
			'stripe_fee_formatted'     => $stripe_fee !== '' ? html_entity_decode( affwp_currency_filter( affwp_format_amount( (float) $stripe_fee ), $referral->currency ), ENT_COMPAT, 'UTF-8' ) : '',
		];
	}

	return $out;
}

/**
 * Captures the output of the classic payout-view integrator hooks.
 *
 * Each hook is buffered separately so context-aware wrapping can be applied.
 * In the legacy view, `_end` fired inside a `<table class="form-table">` and
 * many integrators emit `<tr>` rows directly — orphaned outside a `<table>`
 * those rows are silently dropped by the browser. When the `_end` buffer
 * looks like legacy row markup, wrap it in a matching table so the rows
 * render. `_top` and `_bottom` always fired in block context, so their
 * buffers pass through untouched.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout $payout Payout object.
 * @return string HTML, possibly empty.
 */
function affwp_capture_payout_details_extras( $payout ) {

	ob_start();
	do_action( 'affwp_edit_payout_top', $payout );
	$top = (string) ob_get_clean();

	ob_start();
	do_action( 'affwp_edit_payout_end', $payout );
	$end = (string) ob_get_clean();

	ob_start();
	do_action( 'affwp_edit_payout_bottom', $payout );
	$bottom = (string) ob_get_clean();

	$end = affwp_wrap_legacy_payout_end_rows( $end );

	$html = trim( $top . $end . $bottom );

	if ( '' === $html ) {
		return '';
	}

	/*
	 * Defensive sanitization. The captured buffer flows to the drawer template
	 * via Alpine `x-html` (views/payout-drawer-details.php), so any integrator
	 * that echoes attacker-controllable strings (PayPal/Stripe webhook tokens
	 * land in $payout->description) without escaping would XSS the admin.
	 * `wp_kses_post` is the standard "admin-trusted rich HTML" filter — same
	 * allow-list WP uses for post_content rendered to admins.
	 */
	return wp_kses_post( $html );
}

/**
 * Wraps `_end`-hook output in a `<table class="form-table">` when it looks
 * like legacy row markup (a `<tr>` not already inside a `<table>`).
 *
 * @since 2.33.0
 *
 * @param string $end Raw `_end` buffer.
 * @return string Wrapped if needed, otherwise unchanged.
 */
function affwp_wrap_legacy_payout_end_rows( $end ) {

	if ( '' === trim( $end ) ) {
		return $end;
	}

	$tr_pos    = stripos( $end, '<tr' );
	$table_pos = stripos( $end, '<table' );

	if ( false === $tr_pos ) {
		return $end;
	}

	if ( false !== $table_pos && $table_pos < $tr_pos ) {
		return $end;
	}

	return '<table class="form-table">' . $end . '</table>';
}

/**
 * Triggers a PayPal verification check for a processing payout.
 *
 * @since 2.33.0
 */
function affwp_ajax_payout_drawer_check_status() {

	check_ajax_referer( 'affwp_payout_drawer_check_status' );

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

	$payout_id = isset( $_POST['payout_id'] ) ? absint( $_POST['payout_id'] ) : 0;
	$payout    = $payout_id ? affwp_get_payout( $payout_id ) : false;

	if ( ! $payout ) {
		wp_send_json_error( [ 'message' => __( 'Payout not found.', 'affiliate-wp' ) ], 404 );
	}

	// Only PayPal processing payouts have a status check.
	if ( 'paypal' !== strtolower( (string) $payout->payout_method ) ) {
		wp_send_json_error( [ 'message' => __( 'Status checks are only available for PayPal payouts.', 'affiliate-wp' ) ], 400 );
	}

	/*
	 * Reuse the existing PayPal Payouts admin handler. It updates the payout
	 * description JSON, transitions status if PayPal returned a terminal
	 * batch/item status, and recounts the batch.
	 */
	if ( class_exists( 'AffiliateWP_PayPal_Payouts_Admin' ) ) {
		$paypal_admin = new AffiliateWP_PayPal_Payouts_Admin();
		$paypal_admin->check_payout_status( $payout_id );
	}

	$fresh = affwp_get_payout( $payout_id );
	if ( ! $fresh ) {
		wp_send_json_error( [ 'message' => __( 'Payout disappeared during status check.', 'affiliate-wp' ) ], 500 );
	}

	wp_send_json_success( affwp_build_payout_drawer_payload( $fresh ) );
}

/**
 * Retries a failed payout via the drawer.
 *
 * Thin wrapper around `affwp_retry_payout()` — the same shared core used by
 * the classic list-table row action and the bulk-retry path. Keeps all three
 * entry points creating identical audit trails (new batch with
 * `retry_of_batch_id`, referrals detached-then-reattached).
 *
 * @since 2.33.0
 */
function affwp_ajax_payout_drawer_retry() {

	check_ajax_referer( 'affwp_payout_drawer_retry' );

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

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

	if ( is_wp_error( $result ) ) {
		/*
		 * 404/400 are reasonable for "not found" / "not failed" / "no referrals";
		 * anything else is a server-side problem. Shared core returns opaque
		 * WP_Error codes — map a few for clearer HTTP semantics.
		 */
		$status_map = [
			'affwp_retry_not_found'     => 404,
			'affwp_retry_not_failed'    => 400,
			'affwp_retry_no_referrals'  => 400,
		];
		$status = $status_map[ $result->get_error_code() ] ?? 500;

		wp_send_json_error( [ 'message' => $result->get_error_message() ], $status );
	}

	wp_send_json_success( [
		'new_payout_id' => $result['payout_id'],
		'new_batch_id'  => $result['batch_id'],
	] );
}

/**
 * Manually triggers the failure-email send for a failed payout.
 *
 * Backs the "Send connection request" button on the Next Steps card.
 * Bypasses the per-affiliate cooldown (the admin is explicitly asking
 * for a send), but still respects the setting toggle, the registered
 * template, and the affiliate_action_required class gate inside
 * affwp_maybe_send_failure_email().
 *
 * @since 2.33.0
 */
function affwp_ajax_payout_drawer_send_failure_email() {

	check_ajax_referer( 'affwp_payout_drawer_send_failure_email' );

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

	$payout_id = isset( $_POST['payout_id'] ) ? absint( $_POST['payout_id'] ) : 0;
	if ( ! $payout_id ) {
		wp_send_json_error( array( 'message' => __( 'Missing payout ID.', 'affiliate-wp' ) ), 400 );
	}

	$sent = affwp_maybe_send_failure_email( $payout_id, true );

	if ( ! $sent ) {
		/*
		 * Failed for one of several reasons (setting off, wrong class,
		 * no template, no affiliate email). Don't leak the specifics —
		 * just tell the admin it didn't go. The site setting copy is
		 * the right place to explain the gates.
		 */
		wp_send_json_error( array(
			'message' => __( "Couldn't send the email. Check that the affiliate has a valid email, that the auto-email setting is enabled, and that the payout is classified as affiliate-action-required.", 'affiliate-wp' ),
		), 400 );
	}

	$payout = affwp_get_payout( $payout_id );

	wp_send_json_success( array(
		'message'                       => __( 'Email sent.', 'affiliate-wp' ),
		/*
		 * Fresh "last contacted" timestamp + human-readable diff so the
		 * drawer can update its Next Steps subtitle without re-fetching
		 * the full payload.
		 */
		'last_failure_email_sent_at'    => time(),
		'last_failure_email_sent_human' => human_time_diff( time(), current_time( 'timestamp', true ) ) . ' ' . __( 'ago', 'affiliate-wp' ),
		/*
		 * Cooldown is unambiguously active immediately after a manual send.
		 * Returned so the drawer can transition the Next Steps card into
		 * its "system handled it" state without waiting for a payload refresh.
		 */
		'failure_email_cooldown_active' => true,
	) );
}

/**
 * Switches a failed payout to a different method.
 *
 * Body params: `payout_id` (int), `new_method` (string slug).
 * The handler defers all validation to `affwp_switch_payout_method()`.
 *
 * On success: returns `{ payout_id, batch_id }` for the newly-created
 * payout so the drawer can navigate the user to it (or close + show a
 * toast). On failure: returns the WP_Error message with an appropriate
 * HTTP status.
 *
 * @since 2.33.0
 */
function affwp_ajax_payout_drawer_switch_method() {

	check_ajax_referer( 'affwp_payout_drawer_switch_method' );

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

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

	if ( ! $payout_id || ! $new_method ) {
		wp_send_json_error( array( 'message' => __( 'Missing payout ID or method.', 'affiliate-wp' ) ), 400 );
	}

	$result = affwp_switch_payout_method( $payout_id, $new_method );

	if ( is_wp_error( $result ) ) {
		$status_map = array(
			'affwp_switch_not_found'      => 404,
			'affwp_switch_not_failed'     => 400,
			'affwp_switch_invalid_method' => 400,
		);
		$status = $status_map[ $result->get_error_code() ] ?? 500;
		wp_send_json_error( array( 'message' => $result->get_error_message() ), $status );
	}

	wp_send_json_success(
		array(
			'message'       => __( 'Method switched. The payout is being retried.', 'affiliate-wp' ),
			'new_payout_id' => (int) $result['payout_id'],
			'new_batch_id'  => (int) $result['batch_id'],
		)
	);
}

/**
 * Deletes a payout and detaches its referrals.
 *
 * Referrals go back to 'unpaid' with `payout_id = 0` so they can be re-paid.
 *
 * @since 2.33.0
 */
function affwp_ajax_payout_drawer_delete() {

	check_ajax_referer( 'affwp_payout_drawer_delete' );

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

	$payout_id = isset( $_POST['payout_id'] ) ? absint( $_POST['payout_id'] ) : 0;
	$payout    = $payout_id ? affwp_get_payout( $payout_id ) : false;

	if ( ! $payout ) {
		wp_send_json_error( [ 'message' => __( 'Payout not found.', 'affiliate-wp' ) ], 404 );
	}

	/*
	 * Two-step delete: detach + delete.
	 *
	 * 1. Manual loop here clears each referral's `payout_id` foreign key to 0,
	 *    so the referrals stay reachable in the Referrals list after the
	 *    payout row is gone (they'd otherwise still point at a deleted ID,
	 *    which is confusing in joins/filters).
	 *
	 * 2. `affwp_delete_payout()` then handles the status revert
	 *    (paid/processing → unpaid for status='paid' referrals — see
	 *    payout-functions.php:167-175) and deletes the payout row itself.
	 *
	 * These steps are complementary, not duplicate: affwp_delete_payout()
	 * deliberately does NOT touch the `payout_id` column (its loop only
	 * touches status). Removing the manual loop below would leave orphan
	 * foreign keys pointing at a deleted row. If a future refactor moves
	 * the payout_id reset into the core function, drop this loop.
	 */
	if ( ! empty( $payout->referrals ) ) {
		$referral_ids = array_filter( array_map( 'absint', explode( ',', $payout->referrals ) ) );
		foreach ( $referral_ids as $rid ) {
			affiliate_wp()->referrals->update( $rid, [ 'payout_id' => 0 ], '', 'referral' );
		}
	}

	$deleted = affwp_delete_payout( $payout_id );

	if ( ! $deleted ) {
		wp_send_json_error( [ 'message' => __( 'Failed to delete payout.', 'affiliate-wp' ) ], 500 );
	}

	// If the payout belonged to a batch, re-roll the batch totals.
	if ( ! empty( $payout->batch_id ) && isset( affiliate_wp()->affiliates->payout_batches ) ) {
		affiliate_wp()->affiliates->payout_batches->recount( (int) $payout->batch_id );
	}

	wp_send_json_success();
}

/**
 * Builds the batch drawer payload (summary + child payouts).
 *
 * Runs on initial drawer open and after actions that change batch state
 * (e.g. the retry-failed flow reloads into the new batch). Keeps the shape
 * flat so the Alpine template doesn't need method calls per field.
 *
 * @since 2.33.0
 */
function affwp_ajax_get_batch_drawer_data() {

	check_ajax_referer( 'affwp_get_batch_drawer_data' );

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

	$batch_id = isset( $_POST['batch_id'] ) ? absint( $_POST['batch_id'] ) : 0;
	$batch    = $batch_id ? affwp_get_payout_batch( $batch_id ) : false;

	if ( ! $batch ) {
		wp_send_json_error( [ 'message' => __( 'Batch not found.', 'affiliate-wp' ) ], 404 );
	}

	/*
	 * Inline queue drain — on hosts where WP-Cron never fires and the AS
	 * loopback runner is blocked, the batch's actions would otherwise sit
	 * pending forever (batch stuck in Processing, no CSV — see #5834).
	 * Each drawer poll claims and runs a slice of this batch's own queue,
	 * so the batch progresses as long as the admin is watching it. On
	 * healthy sites cron usually wins the claim race first and this is a
	 * cheap no-op.
	 */
	if ( 'processing' === strtolower( (string) $batch->status ) || affwp_payout_batch_has_pending_actions( $batch_id ) ) {

		affwp_drain_payout_batch_queue( $batch_id, 5 );

		// Re-fetch: the drained workers recount the batch row as they run.
		$refreshed = affwp_get_payout_batch( $batch_id );
		if ( $refreshed ) {
			$batch = $refreshed;
		}

		/*
		 * Premature-terminal guard: recount() derives batch status purely
		 * from existing payout rows, so a manual batch's row reads
		 * `completed` after the first worker even while sibling actions
		 * (and the finalize checker, scheduled ~15s out) are still queued.
		 * Override the in-memory status — display only, never written —
		 * so the drawer keeps polling until the group truly drains. The
		 * staleness cap stops the override for a wedged queue with zero
		 * progress for over 10 minutes, letting the real status through.
		 */
		if ( 'processing' !== strtolower( (string) $batch->status )
			&& affwp_payout_batch_has_pending_actions( $batch_id )
			&& ! affwp_payout_batch_drain_is_stale( $batch_id )
		) {
			$batch->status = 'processing';
		}
	}

	wp_send_json_success( affwp_build_batch_drawer_payload( $batch ) );
}

/**
 * Composes the batch + payouts drawer payload.
 *
 * Extracted as a helper so the retry endpoint can ship the fresh state back
 * in the same shape when the admin reloads into a newly-created batch.
 *
 * @since 2.33.0
 *
 * @param \AffWP\Affiliate\Payout_Batch $batch Batch object.
 * @return array
 */
function affwp_build_batch_drawer_payload( $batch ) {

	/*
	 * Unified status-badge class recipe — mirrors the Alpine helper in
	 * affwp-payout-drawer.js so both drawers render visually identical pills.
	 * `completed` is the batch-level "all children paid" status; maps to
	 * the same green variant as `paid` so Batches and Payouts lists read
	 * consistently — anything terminal-good reads green regardless of
	 * object type.
	 */
	$badge_class_for_status = function ( $status ) {
		$variants = [
			'paid'       => 'text-green-700 bg-green-50 border-green-200',
			'completed'  => 'text-green-700 bg-green-50 border-green-200',
			'processing' => 'text-blue-700 bg-blue-50 border-blue-200',
			'partial'    => 'text-orange-800 bg-orange-50 border-orange-200',
			'failed'     => 'text-red-700 bg-red-50 border-red-200',
		];
		$tone = $variants[ $status ] ?? 'text-gray-700 bg-gray-50 border-gray-200';
		return 'inline-flex items-center font-medium rounded-md border px-2 py-0.5 text-xs ' . $tone;
	};

	$status_labels = [
		'processing' => __( 'Processing', 'affiliate-wp' ),
		'paid'       => __( 'Paid', 'affiliate-wp' ),
		'completed'  => __( 'Completed', 'affiliate-wp' ),
		'partial'    => __( 'Partial', 'affiliate-wp' ),
		'failed'     => __( 'Failed', 'affiliate-wp' ),
	];

	$method_label = 'mixed' === $batch->payout_method
		? __( 'Mixed (per-affiliate routing)', 'affiliate-wp' )
		: affwp_get_payout_method_label( $batch->payout_method );

	$date_short = $batch->date_created
		? date_i18n( get_option( 'date_format' ), strtotime( $batch->date_created ) )
		: '';

	/*
	 * Generated label. Render `<date> by <creator>` when there's a real
	 * logged-in user behind the batch; render just `<date>` when the
	 * record was system-created (created_by=0 from a cron job, fixture
	 * seed, or background AS action). Earlier "by (unknown)" reads as
	 * a missing-data field rather than as intentional system metadata.
	 */
	$creator         = (int) $batch->created_by > 0 ? get_user_by( 'id', (int) $batch->created_by ) : false;
	$date_part       = $batch->date_created ? date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $batch->date_created ) ) : '—';
	$generated_label = $creator
		? sprintf(
			/* translators: 1: datetime the batch was created, 2: creator display name */
			__( '%1$s by %2$s', 'affiliate-wp' ),
			$date_part,
			$creator->display_name
		)
		: $date_part;

	$total = (int) $batch->total_count;
	$done  = (int) $batch->processed_count;
	$fail  = (int) $batch->failed_count;
	$left  = max( 0, $total - $done - $fail );

	$progress_parts = [];
	$progress_parts[] = sprintf(
		/* translators: 1: processed count, 2: total count */
		__( '%1$d of %2$d processed', 'affiliate-wp' ),
		$done,
		$total
	);
	if ( $fail > 0 ) {
		$progress_parts[] = sprintf(
			/* translators: %d: count of failed payouts */
			_n( '%d failed', '%d failed', $fail, 'affiliate-wp' ),
			$fail
		);
	}
	if ( $left > 0 ) {
		$progress_parts[] = sprintf(
			/* translators: %d: count of payouts still awaiting provider confirmation */
			_n( '%d pending', '%d pending', $left, 'affiliate-wp' ),
			$left
		);
	}

	/*
	 * Resolve the retry-of parent link, but only render it as clickable when
	 * the parent batch still exists. Deleted-parent references would otherwise
	 * point at a phantom batch ("Batch not found." view), which reads as a
	 * broken link to admins. Empty URL = consumer renders plain text.
	 */
	$retry_of_url    = '';
	$retry_of_exists = false;
	if ( (int) $batch->retry_of_batch_id > 0 ) {
		$retry_of_exists = (bool) affwp_get_payout_batch( (int) $batch->retry_of_batch_id );
		if ( $retry_of_exists ) {
			$retry_of_url = affwp_admin_url(
				'payouts',
				[ 'tab' => 'batches', 'batch_id' => (int) $batch->retry_of_batch_id ]
			);
		}
	}

	$provider_dashboard_url = '';
	if ( ! empty( $batch->provider_batch_id ) ) {
		$provider_dashboard_url = affiliate_wp()->settings->get( 'paypal_test_mode' )
			? 'https://www.sandbox.paypal.com/listpayments-transactions/payouts'
			: 'https://www.paypal.com/listpayments-transactions/payouts';
	}

	/*
	 * Child payouts — fetch all (batch sizes are small in practice; even a
	 * 200-affiliate batch is fine), then format for display. Explicit status
	 * list mirrors recount(); without it, get_payouts() defaults to status='paid'
	 * and the drawer hides every failed or in-flight child.
	 */
	$child_payouts = affiliate_wp()->affiliates->payouts->get_payouts( [
		'batch_id' => (int) $batch->batch_id,
		'status'   => array( 'paid', 'failed', 'processing' ),
		'number'   => -1,
		'orderby'  => 'payout_id',
		'order'    => 'ASC',
	] );

	/*
	 * Sort failed rows to the top so the admin sees actionable items
	 * first when scanning a multi-status batch (e.g. a 27-payout batch
	 * with 1 failure no longer requires scrolling to find what needs
	 * attention). Within each status group, preserve payout_id ASC so
	 * the existing chronological order holds. Status order from top to
	 * bottom: failed > processing > paid.
	 */
	if ( is_array( $child_payouts ) && count( $child_payouts ) > 1 ) {
		$status_priority = [ 'failed' => 0, 'processing' => 1, 'paid' => 2 ];
		usort( $child_payouts, function ( $a, $b ) use ( $status_priority ) {
			$a_rank = $status_priority[ strtolower( (string) $a->status ) ] ?? 3;
			$b_rank = $status_priority[ strtolower( (string) $b->status ) ] ?? 3;
			if ( $a_rank !== $b_rank ) {
				return $a_rank <=> $b_rank;
			}
			return (int) $a->ID <=> (int) $b->ID;
		} );
	}

	$payouts_payload    = [];
	$method_counts      = [];
	$failure_breakdown  = [];
	foreach ( (array) $child_payouts as $p ) {
		$p_status = strtolower( (string) $p->status );
		$p_method = strtolower( (string) $p->payout_method );

		/*
		 * Surface failure reason on failed children so the admin sees *why*
		 * inline, without opening the per-payout drawer. PayPal stores JSON
		 * in description (skipped here, the per-payout drawer handles it);
		 * Stripe and other methods store a plain human string.
		 */
		$p_error_message = '';
		if ( 'failed' === $p_status && ! empty( $p->description ) ) {
			$decoded = json_decode( $p->description, true );
			if ( ! is_array( $decoded ) || empty( $decoded['paypal_batch_id'] ) ) {
				$p_error_message = (string) $p->description;
			}
		}

		$payouts_payload[] = [
			'payout_id'          => (int) $p->ID,
			'affiliate_id'       => (int) $p->affiliate_id,
			'affiliate_name'     => affiliate_wp()->affiliates->get_affiliate_name( $p->affiliate_id ),
			'amount_formatted'   => html_entity_decode( affwp_currency_filter( affwp_format_amount( $p->amount ) ), ENT_COMPAT, 'UTF-8' ),
			'method'             => $p_method,
			'method_label'       => affwp_get_payout_method_label( $p_method ) ?: ucfirst( $p_method ),
			'status'             => $p_status,
			'status_label'       => $status_labels[ $p_status ] ?? ucfirst( $p_status ),
			'status_badge_class' => $badge_class_for_status( $p_status ),
			'error_message'      => $p_error_message,
			/*
			 * Same machine-readable failure category as the single-payout
			 * payload. Batch drawer's child cards use it to render a small
			 * failure-class label beside the red error sub-line.
			 */
			'failure_class'       => $p->failure_class ?: '',
			'failure_class_label' => affwp_get_payout_failure_class_label( $p->failure_class ?: '' ),
		];

		if ( ! isset( $method_counts[ $p_method ] ) ) {
			$method_counts[ $p_method ] = 0;
		}
		$method_counts[ $p_method ]++;

		/*
		 * Aggregate failure classes for the footer's status-surface logic.
		 * Bucket NULL/empty as 'unclassified' so legacy pre-triage failures
		 * are visible rather than silently dropped.
		 */
		if ( 'failed' === $p_status ) {
			$class = (string) $p->failure_class;
			if ( '' === $class ) {
				$class = 'unclassified';
			}
			if ( ! isset( $failure_breakdown[ $class ] ) ) {
				$failure_breakdown[ $class ] = 0;
			}
			$failure_breakdown[ $class ]++;
		}
	}

	/*
	 * Per-method breakdown for the Summary card — useful only when the batch
	 * actually mixes methods (the "Mixed" label is otherwise opaque). Single-
	 * method batches already carry the method in the header ribbon.
	 */
	$method_breakdown = '';
	if ( 'mixed' === $batch->payout_method && count( $method_counts ) > 0 ) {
		arsort( $method_counts );
		$parts = [];
		foreach ( $method_counts as $method => $count ) {
			$label = affwp_get_payout_method_label( $method ) ?: ucfirst( $method );
			$parts[] = sprintf( '%d %s', $count, $label );
		}
		$method_breakdown = implode( ', ', $parts );
	}

	$batch_status = strtolower( (string) $batch->status );

	/*
	 * Footer "status surface" — derives the right footer copy + CTA from
	 * the failure-class breakdown. The batch drawer footer is a *state*
	 * report, not an action surface: most failures self-resolve via the
	 * daily auto-retry sweep + affiliate notification email, so the
	 * footer's job is to communicate that calmly rather than dispatch
	 * doomed Retry buttons.
	 *
	 * Modes:
	 *   - 'processing'        : batch in flight, no CTA, safe-to-close hint
	 *   - 'done'              : terminal + no failures, no caption, no CTA
	 *   - 'self_healing'      : terminal with failures the system handles
	 *                           (affiliate_action_required, transient).
	 *                           Caption explains; no admin CTA.
	 *   - 'needs_review'      : terminal with failures only the admin can
	 *                           resolve (admin_action_required, data_error,
	 *                           unknown, unclassified). Caption + Review CTA.
	 *   - 'mixed'             : terminal with both self-healing + admin
	 *                           classes. Caption surfaces both, CTA gates
	 *                           on the admin-required count.
	 */
	/*
	 * What counts as "self-healing" depends on whether the site has the
	 * automated paths enabled. If auto-email is off, an affiliate hasn't
	 * been notified, so we can't claim the system is handling it. Same
	 * for auto-retry. Sites upgrading from 2.33 have both forced OFF
	 * during the 2.33.0 upgrade, so for those sites the entire concept
	 * of self-healing collapses to "needs your review" until the admin
	 * opts in. Fresh installs keep both ON by default.
	 */
	$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 );

	$self_healing_classes = [];
	if ( $auto_email_on || $auto_retry_on ) {
		$self_healing_classes[] = 'affiliate_action_required';
	}
	if ( $auto_retry_on ) {
		$self_healing_classes[] = 'transient';
	}

	$self_healing_count = 0;
	$needs_review_count = 0;
	foreach ( $failure_breakdown as $class => $count ) {
		if ( in_array( $class, $self_healing_classes, true ) ) {
			$self_healing_count += $count;
		} else {
			$needs_review_count += $count;
		}
	}

	$footer_mode    = 'done';
	$footer_caption = '';

	if ( 'processing' === $batch_status ) {
		/*
		 * Processing mode but no caption — the Processing status badge
		 * in the header already communicates state, and a "Safe to close"
		 * reassurance is industry-non-standard for side panels. Drawer
		 * semantics (X closes UI, work continues server-side) carry the
		 * implicit guarantee once and admins internalize it after the
		 * first close. footer_x_show gates on caption being non-empty,
		 * so the whole band suppresses here.
		 */
		$footer_mode = 'processing';
	} elseif ( $fail === 0 ) {
		$footer_mode = 'done';
	} elseif ( $needs_review_count === 0 && $self_healing_count > 0 ) {
		$footer_mode = 'self_healing';
		$is_affiliate_action_only = isset( $failure_breakdown['affiliate_action_required'] ) && count( $failure_breakdown ) === 1;

		if ( $is_affiliate_action_only && $auto_email_on && $auto_retry_on ) {
			$footer_caption = sprintf(
				/* translators: %d: count of affiliates notified by email */
				_n(
					'%d affiliate notified. Will retry when they complete setup.',
					'%d affiliates notified. Will retry when they complete setup.',
					$self_healing_count,
					'affiliate-wp'
				),
				$self_healing_count
			);
		} elseif ( $is_affiliate_action_only && $auto_email_on ) {
			$footer_caption = sprintf(
				/* translators: %d: count of affiliates notified by email */
				_n(
					'%d affiliate notified.',
					'%d affiliates notified.',
					$self_healing_count,
					'affiliate-wp'
				),
				$self_healing_count
			);
		} else {
			$footer_caption = sprintf(
				/* translators: %d: count of failed payouts the system will retry automatically */
				_n(
					'%d failure will retry automatically.',
					'%d failures will retry automatically.',
					$self_healing_count,
					'affiliate-wp'
				),
				$self_healing_count
			);
		}
	} elseif ( $self_healing_count === 0 ) {
		/*
		 * needs_review: admin-required failures only. No caption — the
		 * status badge, progress row, and per-row Failed badges already
		 * communicate "these need attention" three times over, and the
		 * status filter tabs in the drawer body are the navigation
		 * affordance. Footer band hides entirely (footer_x_show gates
		 * on footer_caption being non-empty).
		 */
		$footer_mode = 'needs_review';
	} else {
		$footer_mode    = 'mixed';
		$footer_caption = sprintf(
			/* translators: 1: count of self-resolving failures, 2: count of failures needing admin attention */
			__( '%1$d will retry automatically · %2$d need your attention.', 'affiliate-wp' ),
			$self_healing_count,
			$needs_review_count
		);
	}

	/*
	 * Manual-payout CSV download. Regenerated on demand from the batch's paid
	 * manual payouts (see affwp_download_payout_batch_csv), so the drawer can
	 * offer a re-downloadable link for any batch with manual rows — past or
	 * just-submitted. Empty URL = no manual rows, drawer hides the action.
	 */
	$csv_payouts      = affwp_get_payout_batch_csv_payouts( (int) $batch->batch_id );
	$csv_payout_count = count( $csv_payouts );
	$csv_download_url = $csv_payout_count > 0 ? affwp_payout_batch_csv_download_url( (int) $batch->batch_id ) : '';

	/*
	 * Scope line carries both count AND the manual subtotal, because in a mixed
	 * batch the CSV (manual rows only) is a subset of the batch Total shown
	 * above it — naming the amount stops "Total NZD 66.00 · Download CSV" from
	 * reading as "the file covers all 66." For a manual-only batch the subtotal
	 * equals the Total, so it reads as harmless reinforcement.
	 */
	$csv_total       = array_sum( array_map( static function ( $p ) { return (float) $p->amount; }, $csv_payouts ) );
	$csv_scope_label = $csv_payout_count > 0
		? sprintf(
			/* translators: 1: number of manual payouts, 2: formatted total amount of those payouts */
			_n(
				'%1$s manual payout · %2$s to send outside AffiliateWP.',
				'%1$s manual payouts · %2$s to send outside AffiliateWP.',
				$csv_payout_count,
				'affiliate-wp'
			),
			number_format_i18n( $csv_payout_count ),
			html_entity_decode( affwp_currency_filter( affwp_format_amount( $csv_total ) ), ENT_COMPAT, 'UTF-8' )
		)
		: '';

	$batch_payload = [
		'batch_id'                => (int) $batch->batch_id,
		'status'                  => $batch_status,
		'status_label'            => $status_labels[ $batch_status ] ?? ucfirst( $batch_status ),
		'status_badge_class'      => $badge_class_for_status( $batch_status ),
		'payout_method'           => $batch->payout_method,
		'payout_method_label'     => $method_label,
		'method_breakdown'        => $method_breakdown,
		'total_count'             => $total,
		'processed_count'         => $done,
		'failed_count'            => $fail,
		'total_amount_formatted'  => html_entity_decode( affwp_currency_filter( affwp_format_amount( $batch->total_amount ) ), ENT_COMPAT, 'UTF-8' ),
		'date_short'              => $date_short,
		'generated_label'         => $generated_label,
		'progress_label'          => implode( ' · ', $progress_parts ),
		'provider_batch_id'       => $batch->provider_batch_id ?: '',
		'provider_dashboard_url'  => $provider_dashboard_url,
		'retry_of_batch_id'       => (int) $batch->retry_of_batch_id,
		'retry_of_batch_url'      => $retry_of_url,
		'payouts_heading'         => sprintf(
			/* translators: %d: number of payouts in the batch */
			_n( '%d payout in this batch', '%d payouts in this batch', max( count( $payouts_payload ), 1 ), 'affiliate-wp' ),
			count( $payouts_payload )
		),
		'failure_breakdown'       => $failure_breakdown,
		'self_healing_count'      => $self_healing_count,
		'needs_review_count'      => $needs_review_count,
		'footer_mode'             => $footer_mode,
		'footer_caption'          => $footer_caption,
		'csv_download_url'        => $csv_download_url,
		'csv_payout_count'        => $csv_payout_count,
		'csv_scope_label'         => $csv_scope_label,
	];

	return [
		'batch'   => $batch_payload,
		'payouts' => $payouts_payload,
	];
}

/**
 * Retries every failed child of a batch via the drawer.
 *
 * AJAX equivalent of the `retry_batch_failed` admin-action handler — same
 * semantics, different entry point. Returns the new batch ID so the drawer
 * can reload into the retry's in-flight state.
 *
 * @since 2.33.0
 */
function affwp_ajax_batch_drawer_retry_failed() {

	check_ajax_referer( 'affwp_batch_drawer_retry_failed' );

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

	$batch_id = isset( $_POST['batch_id'] ) ? absint( $_POST['batch_id'] ) : 0;
	$batch    = $batch_id ? affwp_get_payout_batch( $batch_id ) : false;

	if ( ! $batch ) {
		wp_send_json_error( [ 'message' => __( 'Batch not found.', 'affiliate-wp' ) ], 404 );
	}

	$failed_payouts = affiliate_wp()->affiliates->payouts->get_payouts( [
		'batch_id' => $batch_id,
		'status'   => 'failed',
		'number'   => -1,
		'fields'   => 'ids',
	] );

	if ( empty( $failed_payouts ) ) {
		wp_send_json_error( [ 'message' => __( 'Nothing to retry — all payouts in this batch have already been processed.', 'affiliate-wp' ) ], 400 );
	}

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

	if ( ! $new_batch_id ) {
		wp_send_json_error( [ 'message' => __( 'Failed to create retry batch.', 'affiliate-wp' ) ], 500 );
	}

	$succeeded = 0;
	$failed    = 0;

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

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

	wp_send_json_success( [
		'new_batch_id' => (int) $new_batch_id,
		'retried'      => $succeeded,
		'failed'       => $failed,
	] );
}
