/**
 * Payout Detail Drawer — Alpine store + row-click intercept.
 *
 * Expects `affwpPayoutDrawerData` global (via wp_localize_script) containing:
 *   - nonce   — AJAX nonce (affwp_payout_drawer action)
 *   - ajaxUrl — admin-ajax.php URL
 *   - listUrl — Payouts list admin URL (used after delete)
 *
 * @since 2.33.0
 */
( function () {
	'use strict';

	const DATA_KEY = 'affwpPayoutDrawerData';
	const data     = window[ DATA_KEY ] || {};
	const labels   = data.labels || {};
	const paypalItemStatus = data.paypalItemStatus || {};

	if ( ! data.ajaxUrl ) {
		return;
	}

	/**
	 * Substitute a single sprintf-style token (%d / %s) in a localized string.
	 *
	 * Keeps the substitution explicit so we don't pull in a sprintf shim for
	 * the handful of single-token replacements drawer copy needs.
	 *
	 * @param {string} template Localized template (may contain `%d` or `%s`).
	 * @param {*}      value    Replacement value (coerced to string).
	 * @return {string}
	 */
	function fmt( template, value ) {
		return String( template || '' ).replace( /%[ds]/, String( value ) );
	}

	const registerStore = function () {

		Alpine.store( 'payoutDrawer', {

			// Drawer shell state (see trait-drawers.php for the contract).
			isOpen: false,
			isNew: false,
			isLoading: false,
			errors: {},
			title: '',
			subtitle: '',

			// Payout state — populated when the drawer opens.
			payoutId: 0,
			payout: null,
			referrals: [],
			paypalData: null,

			// Element that had focus when the drawer was opened — restored on close.
			_returnFocusTo: null,

			/*
			 * Origin breadcrumb — `{ type: 'batch', id: N }` when this drawer
			 * was opened by a drawer-to-drawer swap from the batch drawer.
			 * Null when admin landed here via the list table (nowhere to go
			 * "back" to). Cleared on close + on keyboard navigation.
			 */
			returnTo: null,

			// Action state.
			isChecking: false,
			isRetrying: false,
			isDeleting: false,
			/*
			 * True while a Next Steps card action is in flight. Drives the
			 * spinner on the card's primary button (see the
			 * payout-drawer-next-steps.php view). Reset after the action
			 * resolves regardless of outcome.
			 */
			isNextStepsRunning: false,

			/*
			 * Method-switch picker state. `pickerOpen` toggles the inline
			 * dropdown in the drawer footer. `isSwitching` blocks
			 * double-submits while the AJAX request is in flight.
			 */
			pickerOpen: false,
			isSwitching: false,

			/*
			 * True while stepping payout-to-payout via keyboard / chevrons.
			 * Drives a subtle body dim during the fetch so the content swap
			 * feels like a crossfade rather than a hard flash. First-open
			 * (no payout yet) still uses the full loading overlay.
			 */
			isNavigating: false,

			/*
			 * Ordered list of payout IDs in the underlying list-table.
			 * Scraped from DOM at init so keyboard nav honors the admin's
			 * current filter/sort/page without needing a server roundtrip.
			 */
			payoutIds: [],

			/*
			 * ─────────────────────────────────────────────────────────────────
			 * Derived getters — drive state-aware card rendering.
			 * ─────────────────────────────────────────────────────────────────
			 */

			get method() {
				return this.payout ? String( this.payout.payout_method || '' ).toLowerCase() : '';
			},

			get status() {
				return this.payout ? String( this.payout.status || '' ).toLowerCase() : '';
			},

			get isProcessing() { return this.status === 'processing'; },
			get isPaid()       { return this.status === 'paid'; },
			get isFailed()     { return this.status === 'failed'; },

			/*
			 * Whether the Retry button is enabled. Server tells us via
			 * `payout.is_retryable` (derived from failure_class via the
			 * `affwp_payout_retry_enabled` filter). Default true when the
			 * payout isn't failed; the footer renders Close in that case
			 * regardless, so the value is only meaningful for failed
			 * payouts.
			 */
			get isRetryable() {
				if ( ! this.payout ) {
					return true;
				}
				if ( ! this.isFailed ) {
					return true;
				}
				return !! this.payout.is_retryable;
			},

			/*
			 * Tooltip explaining why Retry is disabled. Reads the failure
			 * class label so the operator knows what to fix before retry
			 * becomes available. Falls back to a generic message if the
			 * server didn't populate a label.
			 */
			get retryDisabledReason() {
				if ( this.isRetryable ) {
					return '';
				}
				const label = this.payout && this.payout.failure_class_label;
				if ( label ) {
					return fmt( labels.retryDisabledWithLabel, label );
				}
				return labels.retryDisabledGeneric || '';
			},

			/*
			 * Sorted [ {slug, label, description} ] of methods the admin
			 * can switch this payout to. Reads from the server-built
			 * `switchable_methods` map. Description is the consequence-
			 * focused one-liner shown beneath each radio option so the
			 * admin understands what the affiliate's experience will be
			 * before committing.
			 */
			get switchableMethods() {
				const map = this.payout && this.payout.switchable_methods;
				if ( ! map || typeof map !== 'object' ) {
					return [];
				}
				return Object.keys( map ).map( ( slug ) => {
					const entry = map[ slug ];
					/*
					 * Pre-2.33.0 payloads may still send the flat `slug =>
					 * label` shape if a third-party filter is short-circuiting
					 * the structured form. Handle both gracefully.
					 */
					if ( typeof entry === 'string' ) {
						return { slug, label: entry, description: '' };
					}
					return {
						slug,
						label: ( entry && entry.label ) || slug,
						description: ( entry && entry.description ) || '',
					};
				} );
			},

			/*
			 * True when this payout was replaced via a method switch
			 * (its referrals now belong to a different payout). The
			 * drawer treats superseded payouts as read-only audit
			 * records: actions (Retry, Switch method, Send connection
			 * request, Next Steps) all hide so the admin's attention
			 * goes to the replacement instead of producing duplicate
			 * action attempts against history.
			 */
			get isSuperseded() {
				return !! ( this.payout && this.payout.superseded_by_payout_id );
			},

			get canSwitchMethod() {
				return this.isFailed && ! this.isSuperseded && this.switchableMethods.length > 0;
			},

			/*
			 * Summary card shows method-specific state (PayPal batch ID, Stripe
			 * transfer ID, etc.). Simple methods (manual, store_credit) have nothing
			 * worth showing there — the header already carries amount + status, and
			 * duplicating it in a near-empty card is noise.
			 * Render the Summary card only when we have method-specific
			 * state to show. PayPal: show when there's a batch ID on the
			 * payout. Stripe: show when at least one referral carries a
			 * transfer ID (populated synchronously on Transfer::create).
			 * Silent-skip for manual / store_credit — the header already
			 * carries amount + status + affiliate, so a near-empty card
			 * there would be noise.
			 * Failed state: always show when a reason is available, regardless
			 * of method. The error_message field carries plain-string reasons
			 * (Stripe, manual, store_credit); paypalData.error carries the
			 * structured PayPal failure. Without this branch, a Stripe payout
			 * that failed before creating any transfer (e.g. unconnected
			 * affiliate) would hide the card entirely and the admin would
			 * have no surfaced "why" — even though the reason is in the DB.
			 */
			get showSummaryCard() {
				/*
				 * Summary card is suppressed in three cases:
				 *
				 *   1. A Next Steps card is active. Operational story + bookkeeping
				 *      already live in Next Steps + Details. Summary is redundant.
				 *
				 *   2. The payout is superseded. The supersession banner moved
				 *      out into its own protagonist card (payout-drawer-
				 *      supersession.php), and the red failure block doesn't
				 *      fire on superseded payouts (closed-book audit state).
				 *      With both pieces of the old summary-card body gone,
				 *      the wrapping card has nothing left to render.
				 *
				 *   3. Method-specific content + failure reason are both
				 *      empty. Fall-through to the unclassified-failure
				 *      safety-rail branch below.
				 */
				const hasNextSteps = !! ( this.payout && this.payout.next_steps );
				if ( hasNextSteps ) {
					return false;
				}
				if ( this.isSuperseded ) {
					return false;
				}

				/*
				 * Method-specific content is the load-bearing case: PayPal
				 * batch ID, Stripe transfer count + dashboard deep-link.
				 * Show the card whenever any of that is available, regardless
				 * of payout status (assuming no Next Steps overrides above).
				 */
				const hasMethodContent = ( 'paypal' === this.method && !! ( this.paypalData && this.paypalData.paypal_batch_id ) ) ||
					( 'stripe' === this.method && this.hasStripeTransfers );

				if ( hasMethodContent ) {
					return true;
				}

				/*
				 * Unclassified-failure safety rail. Reaches here only when the
				 * early hasNextSteps return above didn't fire (so the payload
				 * builder produced null — failure_class empty or 'unknown'),
				 * AND no method-specific bookkeeping is present. The raw
				 * error string is the only signal we have, so the summary
				 * card stays visible to surface it.
				 */
				if ( this.isFailed ) {
					const hasReason = !! ( this.payout && this.payout.error_message ) ||
						!! ( this.paypalData && this.paypalData.error );
					if ( hasReason ) {
						return true;
					}
				}

				return false;
			},

			/*
			 * True when any referral in this payout has a Stripe transfer
			 * ID. Drives `showSummaryCard` for the Stripe branch (whether
			 * the top-of-drawer summary surfaces Stripe transfer info).
			 * Cheap scan — payouts rarely have > a few dozen referrals.
			 */
			get hasStripeTransfers() {
				return ( this.referrals || [] ).some( ( r ) => !! r.stripe_transfer_id );
			},

			/*
			 * Count of child referrals that created a Stripe transfer,
			 * rendered as "3 transfers" / "1 transfer". Used on the
			 * Summary card. Stripe doesn't have a batch object the way
			 * PayPal does — transfers are per-referral — so we surface
			 * the aggregate count rather than a single batch ID.
			 */
			get stripeTransferCountLabel() {
				const count    = ( this.referrals || [] ).filter( ( r ) => !! r.stripe_transfer_id ).length;
				const template = count === 1 ? labels.stripeTransferSingular : labels.stripeTransferPlural;
				return fmt( template, count );
			},

			/*
			 * Dashboard deep-link for the Summary card. Picks the sandbox
			 * URL when any referral carries a test-mode transfer URL
			 * (test-mode is routed server-side into the transfer URL
			 * prefix — easier to read it back than re-derive here).
			 */
			get stripeDashboardUrl() {
				const firstWithUrl = ( this.referrals || [] ).find( ( r ) => !! r.stripe_transfer_url );
				if ( ! firstWithUrl ) { return ''; }
				const isTestMode = firstWithUrl.stripe_transfer_url.indexOf( '/test/' ) !== -1;
				return isTestMode
					? 'https://dashboard.stripe.com/test/connect/transfers'
					: 'https://dashboard.stripe.com/connect/transfers';
			},

			get referralsHeading() {
				const n = this.referrals.length;
				if ( this.isPaid ) {
					return n === 1
						? ( labels.referralsHeadingPaidSingular || '' )
						: fmt( labels.referralsHeadingPaidPlural, n );
				}
				/*
				 * Use "in this Payout" for everything else (failed +
				 * processing + new). Earlier the processing branch said
				 * "N Pending referrals" but `pending` is an actual
				 * referral status (referrals awaiting admin approval) so
				 * the heading falsely implied a status. The referrals on
				 * a processing payout are typically unpaid or paid-but-
				 * pending-claim depending on the per-item state, so a
				 * neutral container heading is honest.
				 */
				return n === 1
					? ( labels.referralsHeadingSingular || '' )
					: fmt( labels.referralsHeadingPlural, n );
			},

			/*
			 * Referrals with non-healthy method state grouped by reason.
			 * Hoisted into one section-level callout above the cards so
			 * admins see "2 unclaimed" once, not a wall of yellow banners.
			 * Keys off the method-neutral `item_warning` / `item_status`
			 * fields — PayPal UNCLAIMED/FAILED/etc. and Stripe REVERSED/FAILED
			 * flow through the same path.
			 */
			get warningGroups() {
				if ( ! this.referrals || ! this.referrals.length ) { return []; }

				const buckets = {};

				this.referrals.forEach( ( r ) => {
					const warning = r.item_warning || r.paypal_warning || r.stripe_warning;
					if ( ! warning ) { return; }
					const key = r.item_status || r.paypal_item_status || r.stripe_item_status || 'other';
					if ( ! buckets[ key ] ) {
						buckets[ key ] = {
							status: key,
							message: warning,
							count: 0,
							referralIds: [],
						};
					}
					buckets[ key ].count++;
					buckets[ key ].referralIds.push( r.referral_id );
				} );

				return Object.values( buckets );
			},

			get hasWarnings() {
				return this.warningGroups.length > 0;
			},

			/*
			 * Map PayPal per-item statuses to our badge component's variant
			 * ('success'/'warning'/'danger'/'neutral'). Keeps the drawer's
			 * status pills visually aligned with the list-table badges —
			 * same component, same Tailwind classes.
			 * Humanize PayPal's ALL-CAPS status codes for customer-facing copy
			 * (warning summary headings). Sentences built from these read
			 * better lowercase and spaced.
			 */
			humanizeItemStatus( status ) {
				const map = paypalItemStatus.humanize || {};
				return map[ status ] || String( status || '' ).toLowerCase();
			},

			/*
			 * Title Case labels for badges. PayPal sends ALL-CAPS codes, but
			 * our own payout status pills ("Paid", "Processing", "Failed")
			 * are Title Case — matching them here keeps the drawer badges
			 * consistent with the list-table badges.
			 */
			formatStatusLabel( status ) {
				const map = paypalItemStatus.format || {};
				if ( map[ status ] ) { return map[ status ]; }
				const s = String( status || '' );
				return s.charAt( 0 ).toUpperCase() + s.slice( 1 ).toLowerCase();
			},

			badgeVariantForItemStatus( status ) {
				if ( status === 'SUCCESS' )                                                             { return 'success'; }
				if ( [ 'PENDING', 'UNCLAIMED', 'ONHOLD' ].indexOf( status ) !== -1 )                    { return 'warning'; }
				if ( [ 'FAILED', 'RETURNED', 'BLOCKED', 'REFUNDED', 'REVERSED', 'DENIED' ].indexOf( status ) !== -1 ) { return 'danger'; }
				return 'neutral';
			},

			badgeVariantForPayoutStatus( status ) {
				/*
				 * Superseded override: the row is closed audit data
				 * regardless of the underlying DB status. Render as
				 * neutral grey so the pill doesn't keep flagging a
				 * resolved problem. Paired with the server-side
				 * `status_label` override that renames "Failed" →
				 * "Superseded" in this state.
				 */
				if ( this.isSuperseded ) {
					return 'neutral';
				}
				if ( status === 'paid' )                            { return 'success'; }
				if ( status === 'processing' )                      { return 'info'; }
				if ( status === 'failed' || status === 'rejected' ) { return 'danger'; }
				return 'neutral';
			},

			badgeVariantForReferralStatus( status ) {
				if ( status === 'paid' )                            { return 'success'; }
				if ( status === 'failed' || status === 'rejected' ) { return 'danger'; }
				/*
				 * Unpaid is yellow on the Referrals list table (the
				 * authoritative treatment), and reads as "needs attention"
				 * rather than generic neutral. Mirroring it here so the
				 * same referral wears the same badge color across surfaces.
				 */
				if ( status === 'unpaid' )                          { return 'warning'; }
				return 'neutral';
			},

			/*
			 * Tailwind class string that matches the server-side Badges trait
			 * (render_badge, variant 'sm', border on). Single source of truth
			 * is the PHP $badge_variants map; this mirror is here because the
			 * drawer needs to re-render badges reactively when status changes
			 * without a server roundtrip.
			 */
			badgeClass( variant ) {
				const variants = {
					success: 'text-green-700 bg-green-50 border-green-200',
					warning: 'text-orange-800 bg-orange-50 border-orange-200',
					danger:  'text-red-700 bg-red-50 border-red-200',
					info:    'text-blue-700 bg-blue-50 border-blue-200',
					neutral: 'text-gray-700 bg-gray-50 border-gray-200',
				};
				const tone = variants[ variant ] || variants.neutral;
				return 'inline-flex items-center font-medium rounded-md border px-2 py-0.5 text-xs ' + tone;
			},

			/*
			 * Index of the current payout in the scraped list. -1 when the
			 * open payout isn't in the visible list (deep-link to a payout
			 * off-page). Pager hides itself in that case.
			 */
			get listIndex() {
				return this.payoutIds.indexOf( this.payoutId );
			},

			get listPosition() {
				return this.listIndex >= 0 ? this.listIndex + 1 : 0;
			},

			get listTotal() {
				return this.payoutIds.length;
			},

			get hasPrev() {
				return this.listIndex > 0;
			},

			get hasNext() {
				return this.listIndex >= 0 && this.listIndex < this.payoutIds.length - 1;
			},

			/*
			 * Hide the pager entirely when there's no meaningful navigation
			 * (single-item list, or current payout isn't in the visible list).
			 */
			get showPager() {
				return this.listTotal > 1 && this.listIndex >= 0;
			},

			/*
			 * ─────────────────────────────────────────────────────────────────
			 * Lifecycle.
			 * ─────────────────────────────────────────────────────────────────
			 */

			init() {
				/*
				 * Snapshot the list-table's payout IDs so keyboard nav can step
				 * through them. DOM is ready by alpine:init — no extra wait.
				 */
				this._scanList();

				/*
				 * Global keyboard listener. Up/down (and j/k as Gmail/Linear
				 * aliases) step through the list. Scoped to "drawer is open
				 * and no editable element has focus" so we never hijack
				 * typing in search/filter inputs or form fields inside the drawer.
				 */
				document.addEventListener( 'keydown', ( event ) => this._handleKeyNav( event ) );

				const params     = new URLSearchParams( window.location.search );
				const deepLinkId = parseInt( params.get( 'payout_id' ), 10 );

				if ( deepLinkId > 0 ) {
					this.openFor( deepLinkId );
				}
			},

			_scanList() {
				const ids  = [];
				const seen = Object.create( null );

				document.querySelectorAll( 'a[href*="action=view_payout"]' ).forEach( ( link ) => {
					try {
						const url = new URL( link.href, window.location.origin );
						const id  = parseInt( url.searchParams.get( 'payout_id' ), 10 );
						if ( id && ! seen[ id ] ) {
							seen[ id ] = true;
							ids.push( id );
						}
					} catch ( e ) {
						// Malformed href — skip, continue scanning.
					}
				} );

				this.payoutIds = ids;
			},

			goPrev() {
				if ( ! this.hasPrev ) { return; }
				const id = this.payoutIds[ this.listIndex - 1 ];
				if ( id ) { this.openFor( id ); }
			},

			goNext() {
				if ( ! this.hasNext ) { return; }
				const id = this.payoutIds[ this.listIndex + 1 ];
				if ( id ) { this.openFor( id ); }
			},

			_handleKeyNav( event ) {
				if ( ! this.isOpen ) { return; }

				/*
				 * Don't hijack arrow keys when the admin is typing or interacting
				 * with a form control inside the drawer (notes textarea, selects,
				 * etc.). isContentEditable covers rich-text editors.
				 */
				const el = document.activeElement;
				if ( el ) {
					const tag = el.tagName;
					if ( tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable ) {
						return;
					}
				}

				// Ignore modifier combos — those belong to the browser / OS.
				if ( event.metaKey || event.ctrlKey || event.altKey ) { return; }

				if ( event.key === 'ArrowUp' || event.key === 'k' ) {
					if ( this.hasPrev ) {
						event.preventDefault();
						this.goPrev();
					}
				} else if ( event.key === 'ArrowDown' || event.key === 'j' ) {
					if ( this.hasNext ) {
						event.preventDefault();
						this.goNext();
					}
				}
			},

			async openFor( payoutId, options ) {

				const nextId = parseInt( payoutId, 10 );
				const opts   = options || {};

				/*
				 * If a payout is already loaded and this is a different one,
				 * treat it as in-drawer navigation — keep the current content
				 * on screen and dim it briefly instead of flashing the full
				 * loading overlay. First-open (no payout yet) uses the overlay.
				 */
				const isNav = !! this.payout && nextId !== this.payoutId;

				/*
				 * Capture the focused element on first-open so close() can
				 * restore focus to the row that triggered the drawer. WCAG
				 * 2.4.3 / dialog pattern: focus should land on a sensible
				 * place when the modal closes, not on <body>.
				 */
				if ( ! isNav && ! this._returnFocusTo ) {
					const active = window.document.activeElement;
					this._returnFocusTo = ( active && 'function' === typeof active.focus ) ? active : null;
				}

				this.payoutId     = nextId;
				this.isNavigating = isNav;
				this.isLoading    = true;
				this.isOpen       = true;
				this.errors       = {};
				this.title        = fmt( labels.payoutTitle, this.payoutId );
				this.subtitle     = '';
				/*
				 * Reset picker state on every open/nav. Without this,
				 * keyboard nav (↑/↓) between payouts inherits the
				 * previous payout's pickerOpen state mid-stride.
				 */
				this.pickerOpen = false;
				this.isSwitching = false;

				/*
				 * Lifecycle event for third-party listeners. Fires once per
				 * open (not per in-drawer navigation between payouts —
				 * `isNavigating` distinguishes those for anyone who needs
				 * to react differently to nav vs. fresh-open).
				 */
				window.dispatchEvent( new CustomEvent( 'affwp-drawer-opened', {
					detail: { drawer: 'payout', id: this.payoutId, isNavigating: isNav }
				} ) );

				/*
				 * returnTo drives the "← Back to Batch #N" / "← Back to
				 * #N" breadcrumb at the top of the drawer.
				 *
				 * Three rules:
				 *   - Caller passes returnTo explicitly (batch drilldown,
				 *     supersession "View active payout" click): honor it
				 *     regardless of isNav. The admin made an explicit
				 *     navigation choice; the breadcrumb should reflect
				 *     where they came from.
				 *   - Fresh open with no returnTo arg: clear to null.
				 *   - In-drawer arrow-key nav with no returnTo arg:
				 *     preserve the existing returnTo (so back-to-batch
				 *     breadcrumb survives a keyboard step inside the
				 *     batch's child payouts). This is also why we don't
				 *     unconditionally set returnTo on isNav — keyboard
				 *     scrolling shouldn't clear a meaningful origin.
				 */
				if ( typeof opts.returnTo !== 'undefined' ) {
					this.returnTo = opts.returnTo;
				} else if ( ! isNav ) {
					this.returnTo = null;
				}

				this._syncUrl();

				try {
					const body = new URLSearchParams();
					body.append( 'action', 'affwp_get_payout_drawer_data' );
					body.append( 'payout_id', this.payoutId );
					body.append( '_ajax_nonce', data.nonces.affwp_get_payout_drawer_data );

					const res  = await fetch( data.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body } );
					const json = await res.json();

					if ( ! json || ! json.success ) {
						this.errors.general = ( json && json.data && json.data.message ) || labels.loadFailed || '';
						this.payout         = null;
						this.referrals      = [];
						this.paypalData     = null;
						return;
					}

					this.payout     = json.data.payout;
					this.referrals  = json.data.referrals   || [];
					this.paypalData = json.data.paypal_data || null;

					this.title    = fmt( labels.payoutTitle, this.payoutId );
					/*
					 * Subtitle is intentionally blank — the affiliate / amount / status /
					 * date ribbon lives in the header_callback partial for a richer layout
					 * (avatar, linked affiliate name, colored status pill).
					 */
					this.subtitle = '';

				} catch ( err ) {
					console.error( '[payoutDrawer] openFor failed:', err );
					this.errors.general = labels.loadFailedNetwork || '';
				} finally {
					this.isLoading    = false;
					this.isNavigating = false;
				}
			},

			close() {
				const closedId = this.payoutId;
				this.isOpen     = false;
				this.payoutId   = 0;
				this.payout     = null;
				this.referrals  = [];
				this.paypalData = null;
				this.errors     = {};
				this.returnTo   = null;
				/*
				 * Reset transient picker state so a fresh drawer open
				 * always starts with the Switch-method dropdown closed.
				 * Without this, the picker's pickerOpen state on the
				 * store survives close → reopen and shows the dropdown
				 * for the wrong payout.
				 */
				this.pickerOpen = false;
				this.isSwitching = false;
				this._syncUrl();

				/*
				 * Restore focus to the element that opened the drawer (typically
				 * the row link in the Payouts list). Without this, focus lands
				 * on <body> after close — invisible to keyboard users.
				 */
				if ( this._returnFocusTo && this._returnFocusTo.isConnected ) {
					try {
						this._returnFocusTo.focus();
					} catch ( e ) {
						// Element may be unfocusable (removed from DOM, hidden, etc.).
					}
				}
				this._returnFocusTo = null;
				/*
				 * Lifecycle event. Detail carries the id of the payout that
				 * was open, so listeners can correlate close to their
				 * previously-handled open event.
				 */
				window.dispatchEvent( new CustomEvent( 'affwp-drawer-closed', {
					detail: { drawer: 'payout', id: closedId }
				} ) );
			},

			/*
			 * Drawer-to-drawer swap: close the payout drawer, open the batch
			 * drawer. Inverse of `openPayout` on the batch drawer. When this
			 * drawer is rendered on a page without the batch drawer (e.g. the
			 * Payouts list, which only enqueues this one), we fall back to a
			 * normal navigation.
			 */
			openBatch( batchId ) {
				const batchDrawer = ( window.Alpine && Alpine.store ) ? Alpine.store( 'batchDrawer' ) : null;
				if ( ! batchDrawer ) {
					// No batch drawer on this page — let the link navigate.
					return false;
				}
				this.close();
				batchDrawer.openFor( batchId );
				return true;
			},

			/*
			 * Clicked by the "← Back to Batch #N" / "← Back to #N"
			 * breadcrumb at the top of the drawer. Semantically the
			 * inverse of the action that brought the admin here:
			 *
			 *   - type=batch  → batch drawer drilldown swapped into this
			 *                   payout; back goes to that batch.
			 *   - type=payout → supersession "View active payout"
			 *                   jumped from another payout to this one;
			 *                   back goes to the originating payout.
			 *
			 * The switchyard exists so the header template binds to
			 * `goBack` without needing to know what type of origin
			 * it's returning to.
			 */
			goBack() {
				if ( ! this.returnTo ) { return; }
				if ( 'batch' === this.returnTo.type ) {
					this.openBatch( this.returnTo.id );
					return;
				}
				if ( 'bulkPay' === this.returnTo.type ) {
					// Origin was the Referrals bulk-pay drawer; re-open it with the same selection.
					const bulkPayDrawer = window.Alpine && Alpine.store && Alpine.store( 'bulkPayDrawer' );
					const ids = this.returnTo.ids || [];
					this.close();
					if ( bulkPayDrawer && typeof bulkPayDrawer.open === 'function' ) {
						bulkPayDrawer.open( ids );
					}
					return;
				}
				if ( 'payout' === this.returnTo.type ) {
					/*
					 * Explicitly pass returnTo: null so the openFor() call
					 * clears the existing returnTo. Without this the new
					 * openFor logic preserves returnTo on isNav (so
					 * keyboard scrolling inside a batch's children keeps
					 * the "Back to Batch" breadcrumb), which would leave
					 * the origin payout still showing "Back to itself"
					 * after the user returns. Going-back ALWAYS ends the
					 * breadcrumb chain.
					 */
					this.openFor( this.returnTo.id, { returnTo: null } );
				}
			},

			save() {},

			/*
			 * ─────────────────────────────────────────────────────────────────
			 * Actions.
			 * ─────────────────────────────────────────────────────────────────
			 */

			/*
			 * Switches the loaded payout to a different method. Reuses the
			 * retry pipeline server-side (see affwp_switch_payout_method).
			 * On success: a fresh payout is created under the new method
			 * with the same affiliate + referrals; we navigate the drawer
			 * to that new payout so the admin watches the retry land.
			 *
			 * `pickerOpen` is toggled by the footer's "Switch method" link;
			 * this method only handles the chosen-and-submitted state.
			 */
			async switchMethod( newMethod, popoverEl ) {
				if ( this.isSwitching ) {
					return;
				}
				if ( ! newMethod ) {
					return;
				}
				this.isSwitching = true;

				try {
					const body = new URLSearchParams();
					body.append( 'action', 'affwp_payout_drawer_switch_method' );
					body.append( 'payout_id', this.payoutId );
					body.append( 'new_method', newMethod );
					body.append( '_ajax_nonce', data.nonces.affwp_payout_drawer_switch_method );

					const res = await fetch( data.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body } );
					const json = await res.json();

					if ( json && json.success ) {
						/*
						 * Dismiss the native popover before navigating.
						 * The browser auto-dismisses on outside click but
						 * picking an option is technically an "inside"
						 * click, so we trigger hide ourselves.
						 */
						if ( popoverEl && typeof popoverEl.hidePopover === 'function' ) {
							popoverEl.hidePopover();
						}
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: { message: json.data.message, type: 'success' }
						} ) );
						/*
						 * Hand off to the new payout. The retry created a
						 * fresh payout in a new batch; navigating the
						 * drawer there is more useful than closing.
						 */
						if ( json.data.new_payout_id ) {
							this.openFor( json.data.new_payout_id );
						}
					} else {
						const msg = ( json && json.data && json.data.message ) || labels.switchMethodFailed || '';
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: { message: msg, type: 'error' }
						} ) );
					}
				} catch ( err ) {
					console.error( '[payoutDrawer] switch method failed:', err );
					window.dispatchEvent( new CustomEvent( 'affwp-toast', {
						detail: { message: labels.switchMethodNetworkError || '', type: 'error' }
					} ) );
				} finally {
					this.isSwitching = false;
				}
			},

			async primaryAction() {
				if ( this.isProcessing ) {
					return this.checkStatus();
				}
				if ( this.isFailed ) {
					/*
					 * Retry is only safe to call when the failure class is
					 * retryable. Non-retryable classes have their resolution
					 * path in the Next Steps card; the footer Retry button
					 * is disabled in that case (see footer view + tooltip
					 * from retryDisabledReason). This guard is defensive:
					 * a stale click on the button while its disabled-state
					 * is updating shouldn't fire retry().
					 */
					if ( ! this.isRetryable ) {
						return;
					}
					return this.retry();
				}
				this.close();
			},

			/*
			 * Dispatches the Next Steps card's primary action by `kind`.
			 *
			 * Kinds:
			 *   - retry             → fire the existing retry() flow
			 *   - email_affiliate   → Phase C wires the AJAX send-email call;
			 *                         until then, surface a "coming soon" toast
			 *                         so the click isn't silent
			 *   - open_url          → open in a new tab (provider dashboards)
			 *   - edit_affiliate    → same-tab navigation to the affiliate edit
			 *                         screen (admin context, keep history sane)
			 *
			 * The card's primary button is disabled while this runs (see
			 * isNextStepsRunning). For URL-kind actions the spinner is
			 * fleeting — the page navigates immediately — so the flag
			 * is mainly for the AJAX kinds.
			 */
			async runNextStepsAction() {
				if ( this.isNextStepsRunning ) {
					return;
				}
				const next = this.payout && this.payout.next_steps;
				if ( ! next || ! next.primary_action ) {
					return;
				}
				const action = next.primary_action;
				const kind = action.kind || '';

				this.isNextStepsRunning = true;
				try {
					if ( 'retry' === kind ) {
						await this.retry();
					} else if ( 'open_url' === kind ) {
						if ( action.url ) {
							window.open( action.url, '_blank', 'noopener,noreferrer' );
						}
					} else if ( 'edit_affiliate' === kind ) {
						if ( action.url ) {
							window.location.href = action.url;
						}
					} else if ( 'email_affiliate' === kind ) {
						await this.sendFailureEmail();
					}
				} finally {
					this.isNextStepsRunning = false;
				}
			},

			/*
			 * Underlying AJAX dispatch for both the initial Send and the
			 * Resend paths. The server handler always bypasses the
			 * per-affiliate cooldown (the admin explicitly asked) but
			 * still enforces the site setting, the template-registered
			 * gate, and the affiliate_action_required class gate.
			 *
			 * On success the cooldown timestamp + the cooldown_active flag
			 * are refreshed locally so the card transitions into its
			 * "system handled it" state without a full payload reload.
			 */
			async sendFailureEmail() {
				const body = new URLSearchParams();
				body.append( 'action', 'affwp_payout_drawer_send_failure_email' );
				body.append( 'payout_id', this.payoutId );
				body.append( '_ajax_nonce', data.nonces.affwp_payout_drawer_send_failure_email );

				try {
					const res  = await fetch( data.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body } );
					const json = await res.json();
					if ( json && json.success ) {
						if ( this.payout ) {
							this.payout.last_failure_email_sent_at    = json.data.last_failure_email_sent_at;
							this.payout.last_failure_email_sent_human = json.data.last_failure_email_sent_human;
							this.payout.failure_email_cooldown_active = !! json.data.failure_email_cooldown_active;
						}
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: { message: json.data.message || labels.emailSent || '', type: 'success' }
						} ) );
					} else {
						const msg = ( json && json.data && json.data.message ) || labels.emailFailed || '';
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: { message: msg, type: 'error' }
						} ) );
					}
				} catch ( err ) {
					console.error( '[payoutDrawer] send failure email failed:', err );
					window.dispatchEvent( new CustomEvent( 'affwp-toast', {
						detail: { message: labels.emailNetworkError || '', type: 'error' }
					} ) );
				}
			},

			async checkStatus() {
				if ( this.isChecking ) { return; }
				this.isChecking = true;

				try {
					const body = new URLSearchParams();
					body.append( 'action', 'affwp_payout_drawer_check_status' );
					body.append( 'payout_id', this.payoutId );
					body.append( '_ajax_nonce', data.nonces.affwp_payout_drawer_check_status );

					const res  = await fetch( data.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body } );
					const json = await res.json();

					if ( json && json.success ) {
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: { message: labels.statusRefreshed || '', type: 'success' }
						} ) );

						this.payout     = json.data.payout      || this.payout;
						this.referrals  = json.data.referrals   || this.referrals;
						this.paypalData = json.data.paypal_data || this.paypalData;
					} else {
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: {
								message: ( json && json.data && json.data.message ) || labels.statusCheckFailed || '',
								type: 'error',
							}
						} ) );
					}
				} catch ( err ) {
					window.dispatchEvent( new CustomEvent( 'affwp-toast', {
						detail: { message: labels.statusCheckNetworkError || '', type: 'error' }
					} ) );
				} finally {
					this.isChecking = false;
				}
			},

			async retry() {
				if ( this.isRetrying ) { return; }
				if ( ! window.confirm( labels.retryConfirm || '' ) ) { return; }
				this.isRetrying = true;

				try {
					const body = new URLSearchParams();
					body.append( 'action', 'affwp_payout_drawer_retry' );
					body.append( 'payout_id', this.payoutId );
					body.append( '_ajax_nonce', data.nonces.affwp_payout_drawer_retry );

					const res  = await fetch( data.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body } );
					const json = await res.json();

					if ( json && json.success && json.data.new_payout_id ) {
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: { message: labels.retryToastSuccess || '', type: 'success' }
						} ) );
						this.openFor( json.data.new_payout_id );
					} else {
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: {
								message: ( json && json.data && json.data.message ) || labels.retryToastFailed || '',
								type: 'error',
							}
						} ) );
					}
				} catch ( err ) {
					window.dispatchEvent( new CustomEvent( 'affwp-toast', {
						detail: { message: labels.retryToastNetworkError || '', type: 'error' }
					} ) );
				} finally {
					this.isRetrying = false;
				}
			},

			async deletePayout() {
				if ( this.isDeleting ) { return; }
				if ( ! window.confirm( labels.deleteConfirm || '' ) ) { return; }
				this.isDeleting = true;

				try {
					const body = new URLSearchParams();
					body.append( 'action', 'affwp_payout_drawer_delete' );
					body.append( 'payout_id', this.payoutId );
					body.append( '_ajax_nonce', data.nonces.affwp_payout_drawer_delete );

					const res  = await fetch( data.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body } );
					const json = await res.json();

					if ( json && json.success ) {
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: { message: labels.deleteToastSuccess || '', type: 'success' }
						} ) );
						this.close();
						window.location = data.listUrl;
					} else {
						window.dispatchEvent( new CustomEvent( 'affwp-toast', {
							detail: {
								message: ( json && json.data && json.data.message ) || labels.deleteToastFailed || '',
								type: 'error',
							}
						} ) );
					}
				} catch ( err ) {
					window.dispatchEvent( new CustomEvent( 'affwp-toast', {
						detail: { message: labels.deleteToastNetworkError || '', type: 'error' }
					} ) );
				} finally {
					this.isDeleting = false;
				}
			},

			/*
			 * Writes the current drawer state into the browser URL so sharing the URL
			 * reopens the drawer. Uses replaceState so back button walks between pages,
			 * not drawer-open/closed snapshots.
			 */
			_syncUrl() {
				const url = new URL( window.location );
				if ( this.isOpen && this.payoutId ) {
					url.searchParams.set( 'payout_id', this.payoutId );
					url.searchParams.delete( 'action' );
				} else {
					url.searchParams.delete( 'payout_id' );
					url.searchParams.delete( 'action' );
				}
				window.history.replaceState( {}, '', url );
			},
		} );
	};

	/*
	 * Alpine may already be loaded by the time this script runs, or not yet.
	 * Handle both cases.
	 */
	if ( window.Alpine ) {
		registerStore();
	} else {
		document.addEventListener( 'alpine:init', registerStore );
	}

	// Row-click intercept: open the drawer instead of navigating to the classic view.
	document.addEventListener( 'click', function ( event ) {
		const link = event.target.closest( 'a[href*="action=view_payout"]' );
		if ( ! link ) { return; }

		const url = new URL( link.href, window.location.origin );
		const id  = parseInt( url.searchParams.get( 'payout_id' ), 10 );
		if ( ! id ) { return; }

		if ( window.Alpine && Alpine.store( 'payoutDrawer' ) ) {
			event.preventDefault();
			Alpine.store( 'payoutDrawer' ).openFor( id );
		}
		/*
		 * Otherwise let the browser navigate — admin_init redirect catches it
		 * and the drawer auto-opens from the ?payout_id=X URL param.
		 */
	} );

	/*
	 * Whole-row click: anywhere on the row opens the drawer, except clicks
	 * on interactive controls or on the sticky Actions column (which has
	 * its own buttons + links). Mouse-only convenience; keyboard users
	 * still reach the drawer via the visible View link.
	 */
	document.addEventListener( 'click', function ( event ) {
		if ( event.target.closest( 'a, button, input, label, select, textarea, .check-column, .column-actions' ) ) {
			return;
		}

		const row = event.target.closest( 'tr[data-payout-id]' );
		if ( ! row ) { return; }

		const id = parseInt( row.dataset.payoutId, 10 );
		if ( ! id ) { return; }

		if ( window.Alpine && Alpine.store( 'payoutDrawer' ) ) {
			Alpine.store( 'payoutDrawer' ).openFor( id );
		}
	} );

} )();
