<?php
/**
 * Admin: New Payout View
 *
 * @package    AffiliateWP
 * @subpackage Admin/Payouts
 * @copyright  Copyright (c) 2019, Sandhills Development, LLC
 * @license    http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since      2.4
 */
$commission_holding_period = intval( affiliate_wp()->settings->get( 'commission_holding_period', 0 ) );
$today                     = current_time( 'Y-m-d' );
$wp_date_format            = get_option( 'date_format', 'F j, Y' );
$wp_date_placeholder       = date_i18n( $wp_date_format );

$picker_nonce = wp_create_nonce( 'affwp_affiliate_picker' );
$picker       = affwp_affiliate_picker_alpine( $picker_nonce, [ 'include_groups' => true ] );

$eligibility_nonce = wp_create_nonce( 'affwp_payout_eligibility' );

$payout_methods = affwp_get_payout_methods_for_display( 'admin' );

// Installed-but-disabled rails (e.g. Stripe/PayPal switched off), shown as non-selectable discovery chips so admins know they can turn them on.
$disabled_payout_methods = affwp_get_disabled_payout_methods_for_display();

/*
 * Default for the override-mode dropdown: site's configured default, falling back to whatever
 * registered first. Shared between the server-side <option selected> and the Alpine store's
 * initial state so both agree on first paint.
 */
$override_default = affwp_get_default_payout_method();
if ( ! isset( $payout_methods[ $override_default ] ) ) {
	$override_default = array_key_first( $payout_methods );
}

/*
 * Currency for the "Minimum earnings" prefix and the inclusion-breakdown JS label.
 * affwp_currency_filter('') returns the symbol entity-encoded: use the entity in the
 * server-side HTML prefix, but decode for JS x-text bindings since Alpine renders
 * entities as literals in a text node.
 */
$currency_symbol_html = affwp_currency_filter( '' );
$currency_symbol_text = html_entity_decode( $currency_symbol_html, ENT_QUOTES, 'UTF-8' );
$currency_position    = affiliate_wp()->settings->get( 'currency_position', 'before' );

$store = '$store.payAffiliatesForm';

/*
 * Rehydrate from URL params when the user lands here from "Edit filters" or step 1 of
 * the stepper on the Preview screen. Preview already round-trips its filters through the
 * URL (see affwp_process_preview_payout()), so reading them back here makes the round-trip
 * lossless and the Preview URL shareable. When any URL filter is present we suppress the
 * sessionStorage rehydration in init() — the explicit navigation intent wins over the
 * stored last-form-state.
 */
$initial_from           = ! empty( $_GET['from'] ) ? sanitize_text_field( wp_unslash( $_GET['from'] ) ) : '';
$initial_to             = ! empty( $_GET['to'] ) ? sanitize_text_field( wp_unslash( $_GET['to'] ) ) : '';
$initial_minimum        = isset( $_GET['minimum'] ) && '' !== $_GET['minimum'] ? sanitize_text_field( wp_unslash( $_GET['minimum'] ) ) : '';
$initial_bypass_holding = ! empty( $_GET['bypass_holding'] );

$initial_include_affiliates = isset( $_GET['include_affiliates'] ) && is_array( $_GET['include_affiliates'] )
	? array_values( array_filter( array_map( 'absint', wp_unslash( $_GET['include_affiliates'] ) ) ) )
	: [];
$initial_include_groups     = isset( $_GET['include_groups'] ) && is_array( $_GET['include_groups'] )
	? array_values( array_filter( array_map( 'absint', wp_unslash( $_GET['include_groups'] ) ) ) )
	: [];

// Legacy single-affiliate fallback. Only honored when include_affiliates[] is empty.
$initial_user_name = ! empty( $_GET['user_name'] ) ? sanitize_text_field( wp_unslash( $_GET['user_name'] ) ) : '';
if ( $initial_user_name && empty( $initial_include_affiliates ) ) {
	$legacy_affiliate = affwp_get_affiliate( $initial_user_name );
	if ( $legacy_affiliate ) {
		$initial_include_affiliates[] = (int) $legacy_affiliate->ID;
	}
}

$initial_payout_methods_filter = isset( $_GET['payout_methods'] ) && is_array( $_GET['payout_methods'] )
	? array_values( array_filter( array_map( 'sanitize_key', wp_unslash( $_GET['payout_methods'] ) ) ) )
	: [];

// Singular `payout_method` was used on Preview as the override-mode signal; preserve that mapping.
$initial_legacy_method = '';
if ( empty( $initial_payout_methods_filter ) && ! empty( $_GET['payout_method'] ) ) {
	$candidate = strtolower( sanitize_text_field( wp_unslash( $_GET['payout_method'] ) ) );
	if ( array_key_exists( $candidate, affwp_get_payout_methods() ) ) {
		$initial_legacy_method = $candidate;
	}
}

$initial_routing_mode    = '' !== $initial_legacy_method ? 'override' : 'per_affiliate';
$initial_override_method = '' !== $initial_legacy_method ? $initial_legacy_method : $override_default;

// Hydrate affiliate/group IDs into the {id, name, type} shape the picker expects, matching affwp_ajax_search_affiliates() so chips render identically to live search results.
$initial_selected_affiliates = [];
foreach ( $initial_include_affiliates as $aff_id ) {
	$affiliate = affwp_get_affiliate( $aff_id );
	if ( ! $affiliate ) {
		continue;
	}
	$aff_name  = affwp_get_affiliate_name( $aff_id );
	$user_info = get_userdata( $affiliate->user_id );
	$username  = $user_info ? $user_info->user_login : '';
	if ( $aff_name ) {
		$display = sprintf( '%s (#%d)', $aff_name, $aff_id );
	} elseif ( $username ) {
		$display = sprintf( '%s (#%d)', $username, $aff_id );
	} else {
		$display = sprintf( 'Affiliate #%d', $aff_id );
	}
	$initial_selected_affiliates[] = [
		'id'   => (int) $aff_id,
		'name' => $display,
		'type' => 'affiliate',
	];
}

$initial_selected_groups = [];
foreach ( $initial_include_groups as $group_id ) {
	if ( 'affiliate-group' !== affiliate_wp()->groups->get_group_type( $group_id ) ) {
		continue;
	}
	$group = affiliate_wp()->groups->get_group( $group_id );
	if ( ! $group ) {
		continue;
	}
	$affiliate_ids             = affiliate_wp()->connections->get_connected( 'affiliate', 'group', $group_id );
	$initial_selected_groups[] = [
		'id'              => (int) $group_id,
		'name'            => $group->get_title(),
		'type'            => 'group',
		'affiliate_count' => is_array( $affiliate_ids ) ? count( $affiliate_ids ) : 0,
	];
}

// Seed the picker's Alpine state so the chips render on first paint without an AJAX round-trip.
$picker['state']['selectedAffiliates'] = $initial_selected_affiliates;
$picker['state']['selectedGroups']     = $initial_selected_groups;

// 'custom' whenever a date appears in the URL: the original preset key isn't stored, and showing the explicit range matches what the user sent to Preview.
$initial_date_preset = ( $initial_from || $initial_to ) ? 'custom' : 'all-unpaid';

$has_url_filters = (
	'' !== $initial_from
	|| '' !== $initial_to
	|| '' !== $initial_minimum
	|| $initial_bypass_holding
	|| ! empty( $initial_selected_affiliates )
	|| ! empty( $initial_selected_groups )
	|| ! empty( $initial_payout_methods_filter )
	|| '' !== $initial_legacy_method
);
?>

<div class="wrap affwp-payout-page">

	<div class="affwp-ui">

		<?php
		/**
		 * Fires at the top of the 'New Payout' page, just inside the opening div.
		 *
		 * @since 2.4
		 */
		do_action( 'affwp_new_payout_top' );
		?>

		<header class="my-6 max-w-5xl">
			<?php
			/*
			 * Step rail: numbered indicator that previews the two-step flow at a glance.
			 * Step 1 is the current screen (filled brand circle, brand-colored label).
			 * Step 2 is the upcoming Preview screen (gray-outlined circle, gray label).
			 */
			?>
			<nav aria-label="<?php esc_attr_e( 'Payout steps', 'affiliate-wp' ); ?>" class="inline-flex items-center gap-3 text-xs font-semibold uppercase tracking-wide">
				<span class="inline-flex items-center gap-1.5 text-affwp-brand-700">
					<span aria-current="step" class="inline-flex size-5 items-center justify-center rounded-full bg-affwp-brand-500 text-white text-[11px] font-bold leading-5 tabular-nums">1</span>
					<?php esc_html_e( 'Configure', 'affiliate-wp' ); ?>
				</span>
				<span class="text-gray-300" aria-hidden="true">→</span>
				<span class="inline-flex items-center gap-1.5 text-gray-400">
					<span class="inline-flex size-5 items-center justify-center rounded-full ring-1 ring-gray-300 text-[11px] font-bold leading-5 tabular-nums">2</span>
					<?php esc_html_e( 'Preview', 'affiliate-wp' ); ?>
				</span>
			</nav>
			<h1 class="mt-3 text-3xl font-semibold tracking-tight m-0"><?php esc_html_e( 'Pay affiliates', 'affiliate-wp' ); ?></h1>
			<p class="mt-3 max-w-prose text-base/6 text-pretty text-gray-600 m-0">
				<?php esc_html_e( 'Pay your affiliates for all their unpaid referrals. Each affiliate is paid via the method configured on their profile, or the site default.', 'affiliate-wp' ); ?>
			</p>
		</header>

		<form method="post" id="affwp_new_payout" class="max-w-5xl space-y-6 affwp-hydrating [&.affwp-hydrating_*]:transition-none!" x-data x-init="$nextTick(() => $el.classList.remove('affwp-hydrating'))">

			<!-- Period + Filters panel -->
			<div class="rounded-xl bg-white ring-1 ring-gray-200">

				<!-- Section: Period -->
				<div class="px-6 pt-5 pb-2">
					<h2 class="text-xs font-semibold tracking-wide text-gray-500 uppercase m-0"><?php esc_html_e( 'Period', 'affiliate-wp' ); ?></h2>
				</div>
				<div class="grid gap-x-6 gap-y-5 px-6 pb-5 sm:grid-cols-[10rem_1fr] sm:items-start">

					<label for="date-preset" class="text-sm font-medium text-gray-900 sm:pt-2 m-0"><?php esc_html_e( 'Date range', 'affiliate-wp' ); ?></label>
					<div>
						<?php
						affwp_select( [
							'name'       => 'date_preset',
							'id'         => 'date-preset',
							'size'       => 'md',
							'max_width'  => 'sm:max-w-md',
							'selected'   => $initial_date_preset,
							'options'    => [
								'all-unpaid'   => __( 'Quick: all unpaid referrals through today', 'affiliate-wp' ),
								'this-month'   => __( 'This month', 'affiliate-wp' ),
								'last-month'   => __( 'Last month', 'affiliate-wp' ),
								'last-30-days' => __( 'Last 30 days', 'affiliate-wp' ),
								'last-90-days' => __( 'Last 90 days', 'affiliate-wp' ),
								'custom'       => __( 'Custom range', 'affiliate-wp' ),
							],
							'attributes' => [
								'x-model' => "{$store}.datePreset",
								'@change' => "{$store}.applyDatePreset(\$event.target.value)",
							],
						] );
						?>

						<div class="mt-1.5 sm:max-w-md">
							<template x-if="<?php echo esc_attr( "{$store}.datePreset !== 'custom'" ); ?>">
								<p class="text-sm text-gray-600 m-0">
									<span x-text="<?php echo esc_attr( "{$store}.dateSummary" ); ?>" class="tabular-nums"></span>
								</p>
							</template>

							<template x-if="<?php echo esc_attr( "{$store}.datePreset === 'custom'" ); ?>">
								<div class="relative mt-3" x-data="{ pickerOpen: false, editingStart: false, editingEnd: false }" @click.outside="pickerOpen = false" @keydown.escape.window="pickerOpen = false">
									<div class="grid grid-cols-2 gap-3">
										<div>
											<label for="affwp-date-from" class="block text-xs font-medium text-gray-700 mb-1 m-0"><?php esc_html_e( 'Start date', 'affiliate-wp' ); ?></label>
											<input type="text"
												id="affwp-date-from"
												x-effect="<?php echo esc_attr( "const v = {$store}.startDateDisplay; if ( ! editingStart ) \$el.value = v;" ); ?>"
												@focus="pickerOpen = true; editingStart = true"
												@blur="editingStart = false"
												@input="<?php echo esc_attr( "{$store}.sanitizeDateInput(\$event.target)" ); ?>"
												@change="<?php echo esc_attr( "{$store}.commitDateInput('start', \$event.target)" ); ?>"
												@keydown.enter.prevent="<?php echo esc_attr( "{$store}.commitDateInput('start', \$event.target); \$event.target.blur()" ); ?>"
												placeholder="<?php echo esc_attr( $wp_date_placeholder ); ?>"
												autocomplete="off"
												class="w-full rounded-md bg-white py-2 px-3 text-base tabular-nums ring-1 ring-gray-300 transition-colors hover:ring-gray-400 placeholder:text-gray-400 focus:outline-2 focus:outline-affwp-brand-500">
										</div>
										<div>
											<label for="affwp-date-to" class="block text-xs font-medium text-gray-700 mb-1 m-0"><?php esc_html_e( 'End date', 'affiliate-wp' ); ?></label>
											<input type="text"
												id="affwp-date-to"
												x-effect="<?php echo esc_attr( "const v = {$store}.endDateDisplay; if ( ! editingEnd ) \$el.value = v;" ); ?>"
												@focus="pickerOpen = true; editingEnd = true"
												@blur="editingEnd = false"
												@input="<?php echo esc_attr( "{$store}.sanitizeDateInput(\$event.target)" ); ?>"
												@change="<?php echo esc_attr( "{$store}.commitDateInput('end', \$event.target)" ); ?>"
												@keydown.enter.prevent="<?php echo esc_attr( "{$store}.commitDateInput('end', \$event.target); \$event.target.blur()" ); ?>"
												placeholder="<?php echo esc_attr( $wp_date_placeholder ); ?>"
												autocomplete="off"
												class="w-full rounded-md bg-white py-2 px-3 text-base tabular-nums ring-1 ring-gray-300 transition-colors hover:ring-gray-400 placeholder:text-gray-400 focus:outline-2 focus:outline-affwp-brand-500">
										</div>
									</div>

									<div x-show="pickerOpen" x-transition.opacity.duration.100ms class="absolute z-20 left-0 mt-2 rounded-lg bg-white p-3 shadow-lg ring-1 ring-gray-200">
										<calendar-range
											class="affwp-calendar"
											x-effect="<?php echo esc_attr(
												"const v = ({$store}.startDate && {$store}.endDate) "
												. "? ({$store}.startDate + '/' + {$store}.endDate) : ''; "
												. "if (\$el.getAttribute('value') !== v) \$el.setAttribute('value', v);"
											); ?>"
											@change="<?php echo esc_attr( "{$store}.setRangeFromPicker(\$event.target.value || '')" ); ?>">
											<span slot="previous" class="inline-flex text-gray-400 hover:text-gray-900 transition-colors">
												<span class="sr-only"><?php esc_html_e( 'Previous month', 'affiliate-wp' ); ?></span>
												<?php echo \AffiliateWP\Utils\Icons::get( 'chevron-left', [ 'class' => 'size-4' ] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
											</span>
											<span slot="next" class="inline-flex text-gray-400 hover:text-gray-900 transition-colors">
												<span class="sr-only"><?php esc_html_e( 'Next month', 'affiliate-wp' ); ?></span>
												<?php echo \AffiliateWP\Utils\Icons::get( 'chevron-right', [ 'class' => 'size-4' ] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
											</span>
											<calendar-month></calendar-month>
										</calendar-range>
									</div>
								</div>
							</template>
						</div>

						<!-- Hidden inputs mirror Alpine state for form submission -->
						<input type="hidden" name="from" :value="<?php echo esc_attr( "{$store}.startDate" ); ?>">
						<input type="hidden" name="to" :value="<?php echo esc_attr( "{$store}.endDate" ); ?>">
					</div>

					<label for="minimum" class="text-sm font-medium text-gray-900 sm:pt-2 m-0"><?php esc_html_e( 'Minimum earnings', 'affiliate-wp' ); ?></label>
					<div>
						<?php
						affwp_input( array(
							'name'         => 'minimum',
							'id'           => 'minimum',
							'type'         => 'number',
							'min'          => 0,
							// Whole dollars: a minimum-earnings threshold is a round-number floor, not a price. The server still compares cent-precise unpaid totals.
							'step'         => 1,
							'integer'      => true, // Block decimal entry up front (no step-validation bubble).
							'width'        => 'narrow',
							'placeholder'  => '0',
							'prefix'       => $currency_symbol_html,
							'alpine_model' => "{$store}.minimum",
						) );
						?>
						<p class="mt-1.5 text-sm text-gray-500 m-0"><?php esc_html_e( 'Skip affiliates whose unpaid total falls below this. Their referrals stay queued for the next batch.', 'affiliate-wp' ); ?></p>
					</div>

					<?php if ( 0 !== $commission_holding_period ) : ?>
						<label for="bypass-holding" class="text-sm font-medium text-gray-900 sm:pt-1 m-0"><?php esc_html_e( 'Holding period', 'affiliate-wp' ); ?></label>
						<div>
							<label for="bypass-holding" class="flex items-start gap-3 text-sm cursor-pointer">
								<span class="flex h-lh items-center text-sm">
									<?php
									affwp_toggle( [
										'name'         => 'bypass_holding',
										'id'           => 'bypass-holding',
										'label'        => __( 'Include commissions still in the holding period', 'affiliate-wp' ),
										'size'         => 'md',
										'alpine_model' => "{$store}.bypassHolding",
									] );
									?>
								</span>
								<span>
									<span class="font-medium text-gray-900">
										<?php
										printf(
											/* translators: %s: Commission holding period setting (number of days) */
											esc_html( _n( 'Include commissions still in the %s-day holding period', 'Include commissions still in the %s-day holding period', $commission_holding_period, 'affiliate-wp' ) ),
											esc_html( $commission_holding_period )
										);
										?>
									</span>
									<span class="block text-gray-500"><?php esc_html_e( 'Use for expedited or out-of-band payments.', 'affiliate-wp' ); ?></span>
								</span>
							</label>
						</div>
					<?php endif; ?>

				</div>

				<div class="border-t border-gray-200"></div>

				<!-- Section: Filters -->
				<div class="px-6 pt-5 pb-2">
					<h2 class="text-xs font-semibold tracking-wide text-gray-500 uppercase m-0"><?php esc_html_e( 'Filters', 'affiliate-wp' ); ?></h2>
					<p class="mt-1 text-sm text-gray-500 m-0"><?php esc_html_e( 'Optional. Leave blank to include every eligible affiliate.', 'affiliate-wp' ); ?></p>
				</div>
				<div class="grid gap-x-6 gap-y-5 px-6 pb-5 sm:grid-cols-[10rem_1fr] sm:items-start">

					<label for="pay-affiliates-include-search" class="text-sm font-medium text-gray-900 sm:pt-2 m-0 cursor-pointer"><?php esc_html_e( 'Include', 'affiliate-wp' ); ?></label>
					<div class="sm:max-w-md">
						<?php
						affwp_affiliate_picker( [
							'store'              => $store,
							'id'                 => 'pay-affiliates-include',
							'placeholder'        => __( 'Search affiliates or groups...', 'affiliate-wp' ),
							'include_groups'     => true,
							'show_group'         => true,
							'chips_inside_input' => true,
							'size'               => 'md',
						] );
						?>
						<!-- Hidden inputs mirror Alpine state for form submission -->
						<template x-for="aff in <?php echo esc_attr( "{$store}.selectedAffiliates" ); ?>" :key="'aff-' + aff.id">
							<input type="hidden" name="include_affiliates[]" :value="aff.id">
						</template>
						<template x-for="grp in <?php echo esc_attr( "{$store}.selectedGroups" ); ?>" :key="'grp-' + grp.id">
							<input type="hidden" name="include_groups[]" :value="grp.id">
						</template>
					</div>

					<label class="text-sm font-medium text-gray-900 sm:pt-2 m-0"><?php esc_html_e( 'Payout method', 'affiliate-wp' ); ?></label>
					<div>
						<fieldset class="space-y-4">
							<legend class="sr-only"><?php esc_html_e( 'Payout method routing', 'affiliate-wp' ); ?></legend>

								<!-- Option 1: per-affiliate -->
								<label class="block cursor-pointer">
									<span class="flex items-center gap-3">
										<span class="group inline-grid size-5 grid-cols-1 shrink-0">
											<input
												type="radio"
												name="routing_mode"
												value="per_affiliate"
												x-model="<?php echo esc_attr( "{$store}.routingMode" ); ?>"
												class="checked:border-affwp-brand-500 checked:bg-affwp-brand-500 focus-visible:outline-affwp-brand-500 col-start-1 row-start-1 appearance-none rounded-full border border-gray-300 bg-white focus-visible:outline-2 focus-visible:outline-offset-2 cursor-pointer">
											<span class="pointer-events-none col-start-1 row-start-1 size-[round(down,40%,1px)] self-center justify-self-center rounded-full bg-white group-not-has-checked:opacity-0"></span>
										</span>
										<span class="text-base font-medium text-gray-900"><?php esc_html_e( "Use each affiliate's configured method", 'affiliate-wp' ); ?></span>
									</span>
									<span class="mt-1 block pl-8 text-sm text-gray-500"><?php esc_html_e( 'Optionally restrict to specific methods.', 'affiliate-wp' ); ?></span>
									<div class="mt-2 pl-8 flex flex-wrap gap-2" :class="<?php echo esc_attr( "{$store}.routingMode !== 'per_affiliate' && 'opacity-50 pointer-events-none'" ); ?>">
										<?php foreach ( $payout_methods as $method_key => $method_label ) : ?>
											<button
												type="button"
												@click="<?php echo esc_attr( "{$store}.toggleMethod('" . esc_js( $method_key ) . "')" ); ?>"
												class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ring-1 ring-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer"
												:class="<?php echo esc_attr( "{$store}.isMethodSelected('" . esc_js( $method_key ) . "') && '!bg-affwp-brand-500 !text-white !ring-affwp-brand-500 hover:!bg-affwp-brand-600'" ); ?>"><?php
												echo esc_html( $method_label );
											?></button>
										<?php endforeach; ?>
									</div>
								</label>

								<!-- Option 2: override -->
								<label class="block cursor-pointer">
									<span class="flex items-center gap-3 flex-wrap">
										<span class="group inline-grid size-5 grid-cols-1 shrink-0">
											<input
												type="radio"
												name="routing_mode"
												value="override"
												x-model="<?php echo esc_attr( "{$store}.routingMode" ); ?>"
												class="checked:border-affwp-brand-500 checked:bg-affwp-brand-500 focus-visible:outline-affwp-brand-500 col-start-1 row-start-1 appearance-none rounded-full border border-gray-300 bg-white focus-visible:outline-2 focus-visible:outline-offset-2 cursor-pointer">
											<span class="pointer-events-none col-start-1 row-start-1 size-[round(down,40%,1px)] self-center justify-self-center rounded-full bg-white group-not-has-checked:opacity-0"></span>
										</span>
										<span class="text-base font-medium text-gray-900"><?php esc_html_e( 'Pay everyone this batch via', 'affiliate-wp' ); ?></span>
										<?php // Wrap the dropdown so it visibly dims when the override radio isn't selected. The :disabled attribute makes it non-interactive but WP admin's default select styling doesn't render the disabled state distinctly — opacity-50 mirrors the chip-cluster's inactive treatment in the per-affiliate option above. ?>
										<span :class="<?php echo esc_attr( "{$store}.routingMode !== 'override' && 'opacity-50 pointer-events-none'" ); ?>">
											<?php
											affwp_select( [
												'name'       => 'payout_method_override',
												'id'         => 'payout-method-override',
												'size'       => 'md',
												'max_width'  => '',
												'options'    => $payout_methods,
												'selected'   => $initial_override_method,
												'attributes' => [
													'x-model'   => "{$store}.overrideMethod",
													':disabled' => "{$store}.routingMode !== 'override'",
												],
											] );
											?>
										</span>
									</span>
									<span class="mt-1 block pl-8 text-sm text-gray-500">
										<?php esc_html_e( 'Routes the whole batch through one method, ignoring per-affiliate preferences.', 'affiliate-wp' ); ?>
										<span x-show="<?php echo esc_attr( "{$store}.routingMode === 'override' && {$store}.overrideMethod === 'paypal'" ); ?>" x-cloak>
											<?php esc_html_e( 'PayPal sends invitations to affiliates without a PayPal account; unaccepted funds return within 30 days.', 'affiliate-wp' ); ?>
										</span>
									</span>
								</label>

								<?php
								/*
								 * Installed-but-disabled rails, kept outside both radio options so discovery
								 * stays visible in either routing mode. Anchor chips, not buttons: clicking
								 * goes to Settings, Payouts and never adds the method to the filter.
								 */
								if ( ! empty( $disabled_payout_methods ) ) :
									?>
									<div class="border-t border-gray-200 pt-4">
										<span class="block text-sm text-gray-500"><?php esc_html_e( 'Available, not enabled:', 'affiliate-wp' ); ?></span>
										<div class="mt-2 flex flex-wrap gap-2">
											<?php
											foreach ( $disabled_payout_methods as $method_key => $method_label ) :
												$enable_label = sprintf(
													/* translators: %s: payout method name, e.g. "Stripe" */
													__( 'Not enabled. Turn on %s in Settings → Payouts.', 'affiliate-wp' ),
													$method_label
												);
												?>
												<a
													href="<?php echo esc_url( affwp_get_payout_method_settings_url( $method_key ) ); ?>"
													data-tooltip-html="<?php echo esc_attr( affwp_dark_tooltip_html( $enable_label, '220px' ) ); ?>"
													data-tooltip-interactive="false"
													data-tooltip-arrow="false"
													data-tooltip-animation="false"
													class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-medium border border-dashed border-gray-300 bg-white text-gray-400 hover:text-gray-600 hover:border-gray-400 no-underline">
													<?php \AffiliateWP\Utils\Icons::render( 'plus', [ 'class' => 'size-3.5 shrink-0' ] ); ?>
													<span><?php echo esc_html( $method_label ); ?></span>
													<span class="sr-only"><?php echo esc_html( $enable_label ); ?></span>
												</a>
											<?php endforeach; ?>
										</div>
									</div>
								<?php endif; ?>
							</fieldset>

							<!-- Hidden form fields mirror Alpine state for form submission. -->
							<template x-if="<?php echo esc_attr( "{$store}.routingMode === 'per_affiliate'" ); ?>">
								<template x-for="method in <?php echo esc_attr( "{$store}.selectedMethods" ); ?>" :key="'m-' + method">
									<input type="hidden" name="payout_methods[]" :value="method">
								</template>
							</template>
							<template x-if="<?php echo esc_attr( "{$store}.routingMode === 'override' && {$store}.overrideMethod" ); ?>">
								<input type="hidden" name="payout_method" :value="<?php echo esc_attr( "{$store}.overrideMethod" ); ?>">
							</template>
					</div>

				</div>
			</div>

			<?php echo wp_nonce_field( 'affwp_preview_payout_nonce', 'affwp_preview_payout_nonce', true, false ); ?>
			<input type="hidden" name="affwp_action" value="preview_payout" />

			<!--
				Legacy single-selection mirror for user_name. preview.php still reads it as a
				fallback when include_affiliates[] is absent — keeping the mirror means add-ons
				that key off the legacy field name continue to work without a re-plumbing pass.
			-->
			<input
				type="hidden"
				name="user_name"
				:value="<?php echo esc_attr( "({$store}.selectedAffiliates.length === 1 && ({$store}.selectedGroups || []).length === 0) ? {$store}.selectedAffiliates[0].id : ''" ); ?>"
			>

		</form>

		<?php // Empty-state card removed in favor of the action bar's "0 of N · breakdown · inline recovery" treatment. The bar carries every job this card used to do (state, reason, recovery action) without the page-jank of a card materializing/disappearing as filters change. ?>

	</div>

	<?php // Floating dark island archived to content/affiliatewp/features/payouts-redesign/archived-floating-dark-island.php — kept for reference only, never loaded. ?>

	<?php
	/*
	 * Pay Affiliates action bar. The shell (WP-admin gutters, max-w-5xl column,
	 * fixed positioning, upward shadow) lives in partials/payout-action-bar.php
	 * so this screen and Preview share one alignment source. Here we render the
	 * three regions and hand them off: optional alert bands, the status block,
	 * and the action button.
	 *
	 * Single-row bar at sm: status on the left grows vertically as more reasons
	 * surface, button stays vertically centered on the right. Below sm it stacks
	 * (status full-width on top, button full-width below) so the status text
	 * isn't squeezed into a narrow column next to the button.
	 */

	/*
	 * Alert bands: one per condition, each `<template x-if>` shown only when its predicate is
	 * truthy. The row stays clean (summary, action); conditions the admin must resolve elsewhere
	 * live in the bands. Each band fills the bar edge-to-edge, then re-applies the same gutter and
	 * max-w-5xl column as the row so content tracks the form above.
	 */
	ob_start();
	?>
		<template x-if="<?php echo esc_attr( "{$store}.eligibility.stuckReferralsCount > 0" ); ?>">
			<div class="bg-amber-50 border-b border-black/5">
				<div class="pl-[22px] pr-[20px]">
					<div class="max-w-5xl flex items-center gap-2.5 px-6 py-3 text-sm/6 text-amber-900">
						<?php \AffiliateWP\Utils\Icons::render( 'triangle-warning', [ 'style' => 'outline-duo', 'class' => 'size-4 text-amber-600 shrink-0' ] ); ?>
						<p class="m-0">
							<strong x-text="<?php echo esc_attr( "{$store}.stuckReferralsHeadline" ); ?>"></strong>
							<?php esc_html_e( 'Resolve them from the', 'affiliate-wp' ); ?>
							<a :href="<?php echo esc_attr( "{$store}.eligibility.stuckPayoutsUrl" ); ?>" class="font-medium underline underline-offset-2 hover:no-underline"><?php esc_html_e( 'Payouts list', 'affiliate-wp' ); ?></a><?php esc_html_e( ' to free them for the next batch.', 'affiliate-wp' ); ?>
						</p>
					</div>
				</div>
			</div>
		</template>
	<?php
	$action_bar_bands = ob_get_clean();

	ob_start();
	?>
			<div class="text-sm/5 min-w-0">
				<div class="flex items-start gap-2.5 min-w-0">
					<template x-if="<?php echo esc_attr( "{$store}.eligibility.state === 'loading-initial'" ); ?>">
						<span class="inline-flex items-center gap-2" aria-label="<?php esc_attr_e( 'Calculating eligibility', 'affiliate-wp' ); ?>">
							<span class="h-4 w-32 animate-pulse rounded bg-gray-100"></span>
							<span class="text-gray-300">·</span>
							<span class="h-4 w-20 animate-pulse rounded bg-gray-100"></span>
						</span>
					</template>
					<template x-if="<?php echo esc_attr( "{$store}.eligibility.state === 'loading'" ); ?>">
						<span class="inline-flex items-center gap-1.5 text-gray-500" role="status">
							<svg class="size-3.5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
								<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"/>
								<path d="M22 12a10 10 0 0 1-10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
							</svg>
							<?php esc_html_e( 'Updating…', 'affiliate-wp' ); ?>
						</span>
					</template>
					<template x-if="<?php echo esc_attr( "{$store}.eligibility.state === 'idle'" ); ?>">
						<span class="text-gray-500"><?php esc_html_e( 'Tune filters to preview eligibility.', 'affiliate-wp' ); ?></span>
					</template>
					<template x-if="<?php echo esc_attr( "{$store}.eligibility.state === 'error'" ); ?>">
						<span class="text-amber-700"><?php esc_html_e( "Couldn't update, re-check on Preview.", 'affiliate-wp' ); ?></span>
					</template>
					<template x-if="<?php echo esc_attr( "{$store}.eligibility.state === 'ready'" ); ?>">
						<div class="min-w-0">
							<p class="m-0 tabular-nums">
								<?php // "X of Y included" when pool > eligible, otherwise just "X eligible". ?>
								<template x-if="<?php echo esc_attr( "{$store}.eligibility.poolCount > {$store}.eligibility.count" ); ?>">
									<span>
										<span class="font-medium text-gray-900" x-text="<?php echo esc_attr( "{$store}.eligibility.count + ' " . esc_js( __( 'of', 'affiliate-wp' ) ) . " ' + {$store}.eligibility.poolCount + ' ' + ({$store}.eligibility.poolCount === 1 ? '" . esc_js( __( 'affiliate', 'affiliate-wp' ) ) . "' : '" . esc_js( __( 'affiliates', 'affiliate-wp' ) ) . "') + ' " . esc_js( __( 'included', 'affiliate-wp' ) ) . "'" ); ?>"></span>
										<span class="text-gray-400"> · </span>
										<span class="font-medium text-gray-900" x-text="<?php echo esc_attr( "{$store}.eligibility.formatted" ); ?>"></span>
									</span>
								</template>
								<template x-if="<?php echo esc_attr( "{$store}.eligibility.poolCount <= {$store}.eligibility.count" ); ?>">
									<span>
										<span class="font-medium text-gray-900" x-text="<?php echo esc_attr( "{$store}.eligibility.count + ' ' + ({$store}.eligibility.count === 1 ? '" . esc_js( __( 'affiliate', 'affiliate-wp' ) ) . "' : '" . esc_js( __( 'affiliates', 'affiliate-wp' ) ) . "') + ' " . esc_js( __( 'eligible', 'affiliate-wp' ) ) . "'" ); ?>"></span>
										<span class="text-gray-400"> · </span>
										<span class="font-medium text-gray-900" x-text="<?php echo esc_attr( "{$store}.eligibility.formatted" ); ?>"></span>
									</span>
								</template>
							</p>
							<?php // Exclusion breakdown + inline recovery: pair "X below minimum" with "Clear minimum" so the admin can act from the same surface that diagnosed the problem. ?>
							<template x-if="<?php echo esc_attr( "{$store}.inclusionBreakdown" ); ?>">
								<p class="mt-0.5 m-0 text-gray-500">
									<span x-text="<?php echo esc_attr( "{$store}.inclusionBreakdown" ); ?>"></span>
									<template x-if="<?php echo esc_attr( "{$store}.breakdownRecoveryLabel" ); ?>">
										<span>
											<span class="text-gray-400"> · </span>
											<button type="button" @click="<?php echo esc_attr( "{$store}.breakdownRecover()" ); ?>" class="underline underline-offset-2 hover:no-underline text-affwp-link hover:text-affwp-link-hover font-medium cursor-pointer" x-text="<?php echo esc_attr( "{$store}.breakdownRecoveryLabel" ); ?>"></button>
										</span>
									</template>
								</p>
							</template>
						</div>
					</template>
				</div>
			</div>
	<?php
	$action_bar_status = ob_get_clean();

	ob_start();
	?>
			<button type="submit" form="affwp_new_payout" class="inline-flex w-full justify-center sm:w-auto sm:justify-start shrink-0 items-center gap-1.5 rounded-md bg-affwp-brand-500 px-5 py-2.5 text-base font-semibold text-white hover:bg-affwp-brand-600 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" :disabled="<?php echo esc_attr( "{$store}.eligibility.state === 'ready' && {$store}.eligibility.count === 0" ); ?>">
				<?php esc_html_e( 'Preview payout', 'affiliate-wp' ); ?>
				<?php echo \AffiliateWP\Utils\Icons::get( 'chevron-right', [ 'class' => 'size-4' ] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
			</button>
	<?php
	$action_bar_action = ob_get_clean();

	require __DIR__ . '/partials/payout-action-bar.php';
	?>

	<?php
	/**
	 * Fires at the end of the 'New Payout' page, just inside the closing div.
	 *
	 * @since 2.4
	 */
	do_action( 'affwp_new_payout_end' );
	?>

</div>

<script>
/*
 * Keep .affwp-payout-action-bar's left edge anchored to the WP admin sidebar's
 * actual right edge. WP admin's `body.auto-fold` / `body.folded` classes don't
 * reliably mirror sidebar width (auto-fold sticks at wide viewports after a
 * resize-down), so a class-driven CSS rule misaligns the bar by ~124px in the
 * stuck state. Measuring the sidebar directly is the only correct anchor.
 */
( function () {
	var bar = document.querySelector( '.affwp-payout-action-bar' );
	var sidebar = document.getElementById( 'adminmenuwrap' );
	if ( ! bar || ! sidebar ) {
		return;
	}
	var sync = function () {
		var right = Math.max( 0, sidebar.getBoundingClientRect().right );
		bar.style.setProperty( '--affwp-bar-left', right + 'px' );
	};
	sync();
	window.addEventListener( 'resize', sync );
	if ( window.ResizeObserver ) {
		new ResizeObserver( sync ).observe( sidebar );
	}
} )();
</script>

<script>
document.addEventListener('alpine:init', () => {
	Alpine.store('payAffiliatesForm', {
		// Picker state.
		<?php
		foreach ( $picker['state'] as $key => $value ) {
			echo "\t\t" . esc_js( $key ) . ': ' . wp_json_encode( $value ) . ",\n";
		}
		?>

		// Form-only state. Initial values come from URL params on back-nav from Preview, otherwise the standard defaults.
		startDate: '<?php echo esc_js( $initial_from ); ?>',
		endDate: '<?php echo esc_js( $initial_to ? $initial_to : $today ); ?>',
		selectedMethods: <?php echo wp_json_encode( $initial_payout_methods_filter ); ?>,
		datePreset: '<?php echo esc_js( $initial_date_preset ); ?>',
		minimum: '<?php echo esc_js( $initial_minimum ); ?>',
		bypassHolding: <?php echo $initial_bypass_holding ? 'true' : 'false'; ?>,
		wpDateFormat: '<?php echo esc_js( $wp_date_format ); ?>',

		// Routing mode: 'per_affiliate' (default, chip-driven filter) or 'override' (pay everyone via one method, with PayPal inviting non-account-holders).
		routingMode: '<?php echo esc_js( $initial_routing_mode ); ?>',
		overrideMethod: '<?php echo esc_js( $initial_override_method ); ?>',

		// Live eligibility state. AJAX-driven, debounced from filter changes. `loading-initial` marks the first calculation (skeleton) versus refreshes.
		eligibility: {
			state: 'loading-initial', // 'loading-initial' | 'loading' | 'idle' | 'ready' | 'error'
			count: 0,
			total: 0,
			formatted: '',
			poolCount: 0,
			belowMinimumCount: 0,
			noMethodCount: 0,
			hasMethodFilter: false,
			hasOverrideMethod: false,
			stuckReferralsCount: 0,
			stuckPayoutsCount: 0,
			stuckPayoutsUrl: '',
		},
		_eligibilityResolvedOnce: false,
		_eligibilityTimer: null,
		_eligibilityController: null,
		_eligibilityNonce: '<?php echo esc_js( $eligibility_nonce ); ?>',
		_ajaxUrl: '<?php echo esc_js( admin_url( 'admin-ajax.php' ) ); ?>',

		init() {
			const storageKey = 'affwp_pay_affiliates_filters_v1';
			// When URL params seeded the form (back-nav from Preview), skip sessionStorage rehydration so the explicit URL wins over a stale stored snapshot.
			const hasUrlFilters = <?php echo $has_url_filters ? 'true' : 'false'; ?>;
			if (!hasUrlFilters) {
				try {
					const stored = sessionStorage.getItem(storageKey);
					if (stored) {
						const data = JSON.parse(stored);
						if (Array.isArray(data.selectedAffiliates)) this.selectedAffiliates = data.selectedAffiliates;
						if (Array.isArray(data.selectedGroups)) this.selectedGroups = data.selectedGroups;
						if (Array.isArray(data.selectedMethods)) this.selectedMethods = data.selectedMethods;
						if (typeof data.startDate === 'string') this.startDate = data.startDate;
						if (typeof data.endDate === 'string' && data.endDate) this.endDate = data.endDate;
						if (typeof data.datePreset === 'string') this.datePreset = data.datePreset;
						if (typeof data.minimum === 'string') this.minimum = data.minimum;
						if (typeof data.bypassHolding === 'boolean') this.bypassHolding = data.bypassHolding;
						if (data.routingMode === 'per_affiliate' || data.routingMode === 'override') this.routingMode = data.routingMode;
						if (typeof data.overrideMethod === 'string' && data.overrideMethod) this.overrideMethod = data.overrideMethod;
					}
				} catch (e) {}
			}

			Alpine.effect(() => {
				const snapshot = JSON.stringify({
					selectedAffiliates: this.selectedAffiliates,
					selectedGroups: this.selectedGroups,
					selectedMethods: this.selectedMethods,
					startDate: this.startDate,
					endDate: this.endDate,
					datePreset: this.datePreset,
					minimum: this.minimum,
					bypassHolding: this.bypassHolding,
					routingMode: this.routingMode,
					overrideMethod: this.overrideMethod,
				});
				try { sessionStorage.setItem(storageKey, snapshot); } catch (e) {}
			});

			/*
			 * scheduleEligibilityRefresh() reads _eligibilityResolvedOnce, so Alpine tracks it as a
			 * dep of this effect. When refreshEligibility() flips it true the effect re-fires and would
			 * re-schedule with state='loading', flashing the refresh state right after the skeleton
			 * resolves. The snapshot check makes the effect a no-op unless filters changed.
			 */
			let previousFilterSnapshot = null;
			Alpine.effect(() => {
				const snapshot = JSON.stringify({
					a: this.selectedAffiliates,
					g: this.selectedGroups,
					m: this.selectedMethods,
					s: this.startDate,
					e: this.endDate,
					n: this.minimum,
					h: this.bypassHolding,
					r: this.routingMode,
					o: this.overrideMethod,
				});
				if (snapshot === previousFilterSnapshot) return;
				previousFilterSnapshot = snapshot;
				this.scheduleEligibilityRefresh();
			});
		},

		scheduleEligibilityRefresh() {
			if (this._eligibilityTimer) {
				clearTimeout(this._eligibilityTimer);
			}
			this.eligibility.state = this._eligibilityResolvedOnce ? 'loading' : 'loading-initial';
			this._eligibilityTimer = setTimeout(() => this.refreshEligibility(), 350);
		},

		async refreshEligibility() {
			if (this._eligibilityController) {
				this._eligibilityController.abort();
			}
			const controller = new AbortController();
			this._eligibilityController = controller;

			const body = new FormData();
			body.append('action', 'affwp_payout_eligibility');
			body.append('nonce', this._eligibilityNonce);
			body.append('from', this.startDate || '');
			body.append('to', this.endDate || '');
			body.append('minimum', this.minimum || '');
			body.append('bypass_holding', this.bypassHolding ? '1' : '0');
			this.selectedAffiliates.forEach(a => body.append('include_affiliates[]', a.id));
			this.selectedGroups.forEach(g => body.append('include_groups[]', g.id));

			if (this.routingMode === 'override' && this.overrideMethod) {
				// Override: pay everyone via this method. The endpoint counts all eligible regardless of configured method, since the rail's API handles unconfigured cases.
				body.append('payout_method', this.overrideMethod);
			} else {
				// Per-affiliate routing: chips act as a filter against each affiliate's configured method.
				this.selectedMethods.forEach(m => body.append('payout_methods[]', m));
			}

			try {
				const res = await fetch(this._ajaxUrl, { method: 'POST', body, signal: controller.signal, credentials: 'same-origin' });
				const data = await res.json();
				if (data && data.success) {
					this.eligibility.state = 'ready';
					this.eligibility.count = data.data.count;
					this.eligibility.total = data.data.total;
					this.eligibility.formatted = data.data.formatted_total;
					this.eligibility.poolCount = data.data.pool_count || 0;
					this.eligibility.belowMinimumCount = data.data.below_minimum_count || 0;
					this.eligibility.noMethodCount = data.data.no_method_count || 0;
					this.eligibility.hasMethodFilter = !! data.data.has_method_filter;
					this.eligibility.hasOverrideMethod = !! data.data.has_override_method;
					this.eligibility.stuckReferralsCount = data.data.stuck_referrals_count || 0;
					this.eligibility.stuckPayoutsCount = data.data.stuck_payouts_count || 0;
					this.eligibility.stuckPayoutsUrl = data.data.stuck_payouts_url || '';
				} else {
					this.eligibility.state = 'error';
				}
				this._eligibilityResolvedOnce = true;
			} catch (e) {
				if (e.name === 'AbortError') return;
				this.eligibility.state = 'error';
				this._eligibilityResolvedOnce = true;
			}
		},

		// Picker methods.
		<?php echo $picker['methods']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JS fragment built server-side. ?>

		// Form-only methods.
		resetFilters() {
			this.selectedAffiliates = [];
			this.selectedGroups = [];
			this.selectedMethods = [];
			this.minimum = '';
			this.bypassHolding = false;
			this.datePreset = 'all-unpaid';
			this.applyDatePreset('all-unpaid');
		},
		isMethodSelected(method) {
			return this.selectedMethods.includes(method);
		},
		toggleMethod(method) {
			if (this.isMethodSelected(method)) {
				this.selectedMethods = this.selectedMethods.filter(m => m !== method);
			} else {
				this.selectedMethods = [...this.selectedMethods, method];
			}
		},
		applyDatePreset(preset) {
			const today = new Date();
			const fmt = (d) => {
				const y = d.getFullYear();
				const m = String(d.getMonth() + 1).padStart(2, '0');
				const day = String(d.getDate()).padStart(2, '0');
				return `${y}-${m}-${day}`;
			};

			if (preset === 'all-unpaid') {
				this.startDate = '';
				this.endDate = fmt(today);
			} else if (preset === 'this-month') {
				this.startDate = fmt(new Date(today.getFullYear(), today.getMonth(), 1));
				this.endDate = fmt(today);
			} else if (preset === 'last-month') {
				this.startDate = fmt(new Date(today.getFullYear(), today.getMonth() - 1, 1));
				this.endDate = fmt(new Date(today.getFullYear(), today.getMonth(), 0));
			} else if (preset === 'last-30-days') {
				const start = new Date(today);
				start.setDate(start.getDate() - 30);
				this.startDate = fmt(start);
				this.endDate = fmt(today);
			} else if (preset === 'last-90-days') {
				const start = new Date(today);
				start.setDate(start.getDate() - 90);
				this.startDate = fmt(start);
				this.endDate = fmt(today);
			} else if (preset === 'custom') {
				if (!this.endDate) this.endDate = fmt(today);
				if (!this.startDate) {
					const start = new Date(today);
					start.setDate(start.getDate() - 30);
					this.startDate = fmt(start);
				}
			}
		},

		/*
		 * Cally emits `YYYY-MM-DD/YYYY-MM-DD` on a completed range, or a single `YYYY-MM-DD`
		 * mid-selection. Mirror both into the store and switch the preset to Custom range so the
		 * dropdown matches the selection. applyDatePreset() drives the calendar via x-effect
		 * (attribute mutation), which doesn't fire `change`, so this only runs from real user picks.
		 */
		setRangeFromPicker(raw) {
			const [iso1 = '', iso2 = ''] = (raw || '').split('/');
			this.startDate = iso1;
			this.endDate   = iso2 || iso1;
			if (this.datePreset !== 'custom') {
				this.datePreset = 'custom';
			}
		},

		// Inclusion breakdown text for the bar's top row (e.g. "10 below $5.00 · 5 outside selected methods"). Only reason counts greater than 0 appear.
		get inclusionBreakdown() {
			const parts = [];
			const e = this.eligibility;
			if ( e.belowMinimumCount > 0 ) {
				let minLabel;
				if ( this.minimum && parseFloat( this.minimum ) > 0 ) {
					const amount = parseFloat( this.minimum ).toFixed( 2 );
					const sign   = '<?php echo esc_js( $currency_symbol_text ); ?>';
					minLabel     = '<?php echo esc_js( $currency_position ); ?>' === 'after'
						? amount + sign
						: sign + amount;
				} else {
					minLabel = '<?php echo esc_js( __( 'the minimum', 'affiliate-wp' ) ); ?>';
				}
				parts.push( e.belowMinimumCount + ' <?php echo esc_js( __( 'below', 'affiliate-wp' ) ); ?> ' + minLabel );
			}
			if ( e.noMethodCount > 0 ) {
				if ( e.hasMethodFilter ) {
					parts.push( e.noMethodCount + ' <?php echo esc_js( __( 'outside selected methods', 'affiliate-wp' ) ); ?>' );
				} else if ( e.hasOverrideMethod ) {
					parts.push( e.noMethodCount + ' <?php echo esc_js( __( 'not eligible for that method', 'affiliate-wp' ) ); ?>' );
				} else {
					parts.push( e.noMethodCount + ' <?php echo esc_js( __( 'have no payout method configured', 'affiliate-wp' ) ); ?>' );
				}
			}
			return parts.join( ' · ' );
		},

		// Inline recovery action paired with the breakdown. The label adapts to the dominant exclusion reason and the click resets that filter; multiple active reasons collapse to one "Reset filters".
		get breakdownRecoveryLabel() {
			const e = this.eligibility;
			const hasMin = e.belowMinimumCount > 0;
			const hasMethod = e.noMethodCount > 0;
			if ( hasMin && hasMethod ) return '<?php echo esc_js( __( 'Reset filters', 'affiliate-wp' ) ); ?>';
			if ( hasMin ) return '<?php echo esc_js( __( 'Clear minimum', 'affiliate-wp' ) ); ?>';
			if ( hasMethod && e.hasMethodFilter ) return '<?php echo esc_js( __( 'Clear methods', 'affiliate-wp' ) ); ?>';
			if ( hasMethod && e.hasOverrideMethod ) return '<?php echo esc_js( __( 'Switch to per-affiliate', 'affiliate-wp' ) ); ?>';
			return '';
		},
		breakdownRecover() {
			const e = this.eligibility;
			const hasMin = e.belowMinimumCount > 0;
			const hasMethod = e.noMethodCount > 0;
			if ( hasMin && hasMethod ) { this.resetFilters(); return; }
			if ( hasMin ) { this.minimum = ''; return; }
			if ( hasMethod && e.hasMethodFilter ) { this.selectedMethods = []; return; }
			if ( hasMethod && e.hasOverrideMethod ) { this.routingMode = 'per_affiliate'; return; }
		},

		// Headline for the stuck-referrals diagnostic on the empty state. Matches preview.php's _n() phrasing so both steps word the condition the same way.
		get stuckReferralsHeadline() {
			const r = this.eligibility.stuckReferralsCount;
			const p = this.eligibility.stuckPayoutsCount;
			if (r === 1) {
				return `${r} <?php echo esc_js( __( 'referral is stuck in', 'affiliate-wp' ) ); ?> ${p} <?php echo esc_js( __( 'failed or processing payout.', 'affiliate-wp' ) ); ?>`;
			}
			return `${r} <?php echo esc_js( __( 'referrals are stuck in', 'affiliate-wp' ) ); ?> ${p} <?php echo esc_js( __( 'failed or processing payouts.', 'affiliate-wp' ) ); ?>`;
		},

		// Format an ISO YYYY-MM-DD string using the site's WP date_format option. Supports the common PHP date tokens (Y y n m j d F M l D N w); backslash escapes a literal character.
		formatWp(iso) {
			if (!iso) return '';
			const parts = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
			if (!parts) return iso;
			const date = new Date(parseInt(parts[1], 10), parseInt(parts[2], 10) - 1, parseInt(parts[3], 10));
			const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
			const monthsShort = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
			const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
			const daysShort = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
			const map = {
				Y: date.getFullYear(),
				y: String(date.getFullYear()).slice(-2),
				n: date.getMonth() + 1,
				m: String(date.getMonth() + 1).padStart(2, '0'),
				j: date.getDate(),
				d: String(date.getDate()).padStart(2, '0'),
				F: months[date.getMonth()],
				M: monthsShort[date.getMonth()],
				l: days[date.getDay()],
				D: daysShort[date.getDay()],
				N: date.getDay() === 0 ? 7 : date.getDay(),
				w: date.getDay(),
			};
			let out = '';
			for (let i = 0; i < this.wpDateFormat.length; i++) {
				const ch = this.wpDateFormat[i];
				if (ch === '\\' && i + 1 < this.wpDateFormat.length) {
					out += this.wpDateFormat[++i];
				} else if (map.hasOwnProperty(ch)) {
					out += map[ch];
				} else {
					out += ch;
				}
			}
			return out;
		},

		// Parse a free-text date string into ISO YYYY-MM-DD. Accepts ISO, natural English ('Apr 1, 2026'), and slash forms via Date parsing; returns '' if unparseable.
		parseDateInput(raw) {
			if (!raw) return '';
			const trimmed = String(raw).trim();
			const iso = trimmed.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
			if (iso) {
				return `${iso[1]}-${String(iso[2]).padStart(2, '0')}-${String(iso[3]).padStart(2, '0')}`;
			}
			const parsed = new Date(trimmed);
			if (!isNaN(parsed.getTime())) {
				const y = parsed.getFullYear();
				const m = String(parsed.getMonth() + 1).padStart(2, '0');
				const d = String(parsed.getDate()).padStart(2, '0');
				return `${y}-${m}-${d}`;
			}
			return '';
		},

		get startDateDisplay() {
			return this.formatWp(this.startDate);
		},

		get endDateDisplay() {
			return this.formatWp(this.endDate);
		},

		// Strip characters that can never appear in a date (anything besides letters, digits, spaces, and , . / -) as the user types, keeping the cursor in place.
		sanitizeDateInput(el) {
			const clean = el.value.replace(/[^0-9A-Za-z ,./-]/g, '');
			if (clean === el.value) return;
			const cursor = Math.max(0, (el.selectionStart || 0) - (el.value.length - clean.length));
			el.value = clean;
			el.setSelectionRange(cursor, cursor);
		},

		// Commit a user-typed date input. On valid parse, update ISO state and let the calendar follow via x-effect; on unparseable input, snap the field back to the current display value.
		commitDateInput(which, el) {
			// Untouched display text needs no commit — re-parsing it could reinterpret
			// day/month order under non-US date formats (e.g. d/m/Y).
			if (el.value.trim() === (which === 'start' ? this.startDateDisplay : this.endDateDisplay)) {
				return;
			}
			const iso = this.parseDateInput(el.value);
			if (!iso) {
				el.value = which === 'start' ? this.startDateDisplay : this.endDateDisplay;
				return;
			}
			if (which === 'start') {
				this.startDate = iso;
				if (this.endDate && iso > this.endDate) this.endDate = iso;
			} else {
				this.endDate = iso;
				if (this.startDate && iso < this.startDate) this.startDate = iso;
			}
		},

		// Human-readable summary above the picker. Reuses formatWp() so the format honors the site's WP date_format setting instead of always rendering US-short-month.
		get dateSummary() {
			if (!this.startDate && this.endDate) {
				return `<?php echo esc_js( __( 'Through', 'affiliate-wp' ) ); ?> ${this.formatWp(this.endDate)}`;
			}
			if (this.startDate && this.endDate) {
				if (this.startDate === this.endDate) return this.formatWp(this.startDate);
				return `${this.formatWp(this.startDate)} – ${this.formatWp(this.endDate)}`;
			}
			return '';
		},
	});
});
</script>
