/**
 * AffiliateWP — Automatic Payout schedule drawer store.
 *
 * Backs the create/edit slide-over on the Automatic Payouts tab. The form lives
 * in the page (server-rendered, see admin/payouts/schedule-drawer.php) and binds
 * to `form.*` via x-model; this store just populates it on open and toggles
 * visibility. Saving posts the form via AJAX (affwp_schedule_save) so it runs in
 * place — no reload; the native POST (affwp_save_payout_schedule, which redirects
 * back) is the no-JS fallback.
 *
 * Opens from: the "Add Schedule" button (openNew), a whole-row click or an
 * "Edit" link (openById → reads the row's data-schedule JSON), or a deep link
 * (?tab=schedules&edit_schedule=N, or &new_schedule=1 with seed params from
 * "Save as schedule").
 *
 * @package affiliate-wp
 * @since   2.33.0
 */

( function () {
	'use strict';

	const data = window.affwpScheduleDrawer || {};
	const L    = data.labels || {};

	/*
	 * Parse a JSON data-attribute (the selected affiliates/groups the preview seeds
	 * onto the "Save as schedule" link) into an array of {id, name} objects the
	 * picker can render as chips. Tolerant of missing or malformed values.
	 */
	function parseJsonAttr( raw ) {
		if ( ! raw ) {
			return [];
		}
		try {
			const parsed = JSON.parse( raw );
			return Array.isArray( parsed ) ? parsed : [];
		} catch ( e ) {
			return [];
		}
	}

	/* Format a whole-number amount with the store currency symbol on the configured side. */
	function money( amount ) {
		const sign = data.currencySign || '';
		return 'after' === data.currencyPos ? amount + sign : sign + amount;
	}

	/*
	 * Substitute one placeholder token. Uses a function replacement so a value
	 * containing "$" (e.g. an affiliate name) isn't mangled by String.replace's
	 * special "$" patterns.
	 */
	function sub( tpl, token, value ) {
		return String( tpl ).replace( token, function () { return value; } );
	}

	/*
	 * Label a group so it reads as a group, not an individual affiliate ("VIP" →
	 * "VIP group"). Skips the suffix when the group's own name already ends in
	 * "group(s)", so "Founders Group" doesn't become "Founders Group group".
	 */
	function groupLabel( name ) {
		const n = String( name || '' ).trim();
		if ( ! n ) {
			return '';
		}
		return /\bgroups?$/i.test( n ) ? n : sub( ( data.summary || {} ).groupOne || '%s group', '%s', n );
	}

	function defaultsForm() {
		return {
			id: 0,
			name: '',
			frequency: 'monthly',
			frequency_day_week: '5',
			frequency_day_month: '1',
			minimum: '', // Empty shows the "0.00" placeholder (grey), matching the Pay Affiliates field.
			bypass: false,
			status: 'active',
		};
	}

	function register() {
		if ( ! window.Alpine || Alpine.store( 'scheduleDrawer' ) ) {
			return;
		}

		Alpine.store( 'scheduleDrawer', {
			isOpen: false,
			isNew: true,
			title: '',
			subtitle: '',
			drawerWidth: '',
			errors: {}, // Drawer trait binds an error banner to errors.general.
			_dirty: false,
			// Once the admin edits the Name field, stop auto-filling it from the selection.
			_nameTouched: false,
			// Drives the submit button's spinner + disabled state during the AJAX save.
			saving: false,
			form: defaultsForm(),

			get isDirty() {
				return this._dirty;
			},

			/* Plain-English recap of what this schedule will do; drives the drawer's Summary card and updates live from the fields + picker. */
			get summary() {
				const S    = data.summary || {};
				const pk   = window.Alpine && Alpine.store( 'schedulePicker' );
				const affs = ( pk && pk.selectedAffiliates ) || [];
				const grps = ( pk && pk.selectedGroups ) || [];

				// Who (the subject) — same phrase as the auto-name, with an article for a single group so it reads as a sentence.
				const parts = this._audienceParts( affs, grps );
				let who;
				if ( ! parts.length ) {
					who = S.audienceAll || 'All eligible affiliates';
				} else {
					who = parts.join( S.join || ' and ' );
					if ( 1 === grps.length ) {
						who = sub( S.leadArticle || 'The %s', '%s', who );
					}
				}

				// When (the cadence).
				let when;
				if ( 'weekly' === this.form.frequency ) {
					const day = ( data.weekdays || {} )[ this.form.frequency_day_week ] || '';
					when = sub( S.cadenceWeekly || 'every %s', '%s', day );
				} else if ( '99' === String( this.form.frequency_day_month ) ) {
					when = S.cadenceMonthlyLast || 'on the last day of each month';
				} else {
					const ord = ( data.monthdays || {} )[ this.form.frequency_day_month ] || '';
					when = sub( S.cadenceMonthly || 'on the %s of each month', '%s', ord );
				}

				let sentence = sub( sub( S.base || '%1$s will be paid %2$s', '%1$s', who ), '%2$s', when );

				// Conditions the admin set (omitted when they don't apply).
				const min = parseFloat( this.form.minimum );
				if ( min > 0 ) {
					sentence += sub( S.minClause || ', if they\'ve earned at least %s', '%s', money( min ) );
				}
				if ( this.form.bypass ) {
					sentence += S.holdingClause || ', including commissions still within the holding period';
				}

				return sentence + '.';
			},

			_markDirty() {
				this._dirty = true;
			},

			/* The admin edited the Name field — stop auto-filling it from the selection. */
			markNameTouched() {
				this._nameTouched = true;
			},

			/*
			 * Re-derive the auto Name from the current picker selection. No-op once
			 * the admin has typed their own name, or when editing an existing
			 * schedule (a saved name is never overwritten).
			 */
			refreshName() {
				if ( ! this.isNew || this._nameTouched ) {
					return;
				}
				const pk = window.Alpine && Alpine.store( 'schedulePicker' );
				this.form.name = this._seedName(
					pk ? pk.selectedAffiliates : [],
					pk ? pk.selectedGroups : []
				);
			},

			/*
			 * Shared "who gets paid" phrase, used by BOTH the auto-name and the
			 * Summary sentence so they can never disagree. Names a lone group or
			 * affiliate; counts multiples; combines the two ("VIP and 5 affiliates").
			 * Returns an array of parts (empty = everyone); the caller supplies the
			 * empty-case wording.
			 */
			_audienceParts( affiliates, groups, opts ) {
				const S    = data.summary || {};
				opts       = opts || {};
				affiliates = affiliates || [];
				groups     = groups || [];
				const parts = [];
				if ( 1 === groups.length && groups[ 0 ].name ) {
					// The schedule name wants the bare group ("VIP"); the summary
					// sentence wants "VIP group" so "The VIP group will be paid…" reads
					// as English. Same audience, different grammatical job.
					parts.push( opts.bareGroup ? String( groups[ 0 ].name ).trim() : groupLabel( groups[ 0 ].name ) );
				} else if ( groups.length > 1 ) {
					parts.push( sub( S.groupsN || '%d groups', '%d', groups.length ) );
				}
				if ( 1 === affiliates.length && affiliates[ 0 ].name ) {
					parts.push( affiliates[ 0 ].name );
				} else if ( affiliates.length > 1 ) {
					parts.push( sub( S.affiliatesN || '%d affiliates', '%d', affiliates.length ) );
				}
				return parts;
			},

			/* Smart default schedule name from the current selection (overridable by the admin). The name is a label ("VIP"), and the list carries cadence + next-run in their own columns, so it uses the bare group name rather than restating "group". */
			_seedName( affiliates, groups ) {
				const parts = this._audienceParts( affiliates, groups, { bareGroup: true } );
				return parts.length ? parts.join( ( data.summary || {} ).join || ' and ' ) : ( L.nameAll || 'All affiliates' );
			},

			/* Seed the shared affiliate/group picker store's selection (create or edit). */
			_seedPicker( affiliates, groups ) {
				const pk = window.Alpine && Alpine.store( 'schedulePicker' );
				if ( pk && pk.seed ) {
					pk.seed( Array.isArray( affiliates ) ? affiliates : [], Array.isArray( groups ) ? groups : [] );
				}
			},

			/**
			 * Open the drawer to create a new schedule, optionally seeded.
			 *
			 * @param {Object} seed { scope, group_id, minimum, affiliate_ids }.
			 */
			openNew( seed ) {
				seed = seed || {};
				const f          = defaultsForm();
				const affiliates = Array.isArray( seed.affiliates ) ? seed.affiliates : [];
				const groups     = Array.isArray( seed.groups ) ? seed.groups : [];

				if ( seed.minimum !== undefined && seed.minimum !== null && seed.minimum !== '' ) {
					f.minimum = String( seed.minimum );
				}
				if ( seed.bypass !== undefined && seed.bypass !== null && seed.bypass !== '' ) {
					f.bypass = '1' === String( seed.bypass ) || true === seed.bypass;
				}

				// Smart default name from the seeded selection (the admin can override).
				f.name = this._seedName( affiliates, groups );

				this.form         = f;
				this.isNew        = true;
				this._nameTouched = false; // Fresh schedule: let the name track the selection until edited.
				this.title        = L.addTitle || 'New payout schedule';
				// Describes what the drawer does, attached to the title (the behavioral caveat lives by the Affiliates field, where it's actionable).
				this.subtitle     = L.addSubtitle || 'Pays each affiliate through their own method.';
				this._seedPicker( affiliates, groups );
				this._open();
			},

			/**
			 * Open the drawer to edit an existing schedule from its row JSON.
			 *
			 * @param {Object} d Schedule data.
			 */
			openFor( d ) {
				d = d || {};
				this.form = {
					id: parseInt( d.id, 10 ) || 0,
					name: d.name || '',
					frequency: 'weekly' === d.frequency ? 'weekly' : 'monthly',
					frequency_day_week: 'weekly' === d.frequency ? String( d.frequency_day ) : '5',
					frequency_day_month: 'monthly' === d.frequency ? String( d.frequency_day ) : '1',
					minimum: parseFloat( d.minimum ) > 0 ? String( d.minimum ) : '', // 0/empty shows the placeholder, like a new schedule.
					bypass: !! d.bypass,
					status: d.status || 'active',
				};
				this.isNew        = false;
				this._nameTouched = true; // Existing schedule: never overwrite its saved name.
				this.title        = d.name || L.editTitle || 'Edit payout schedule';
				// The title is the schedule's own name; the subtitle says what kind of thing it is, so the edit context is clear.
				this.subtitle     = L.editSubtitle || 'Payout schedule';
				// Seed the picker from the saved schedule's affiliates + groups (with names, from row JSON).
				this._seedPicker( d.affiliates, d.groups );
				this._open();
			},

			/**
			 * Look up a schedule row by ID and open it for editing.
			 *
			 * @param {number} id Schedule ID.
			 */
			openById( id ) {
				const row = document.querySelector( 'tr[data-schedule-id="' + parseInt( id, 10 ) + '"]' );
				if ( ! row || ! row.dataset.schedule ) {
					return;
				}
				try {
					this.openFor( JSON.parse( row.dataset.schedule ) );
				} catch ( e ) {
					/* Malformed payload — ignore. */
				}
			},

			_open() {
				/*
				 * Close any open row-action kebab popover first — it lives in the
				 * top layer and would otherwise float above the drawer.
				 */
				document.querySelectorAll( '[popover]' ).forEach( function ( p ) {
					try {
						if ( p.matches( ':popover-open' ) ) {
							p.hidePopover();
						}
					} catch ( e ) { /* popover unsupported — nothing to close */ }
				} );

				/*
				 * The payout preview's fixed action bar (z-index 100) sits at the
				 * viewport bottom and would overlap this drawer's footer, hiding the
				 * Create/Save button. Hide it while the drawer is open (and it also
				 * shouldn't compete with the drawer). No-op on the Schedules tab,
				 * which has no action bar.
				 */
				this._toggleActionBar( false );

				this._dirty = false;
				this.saving = false; // Never reopen showing a stale spinner.
				this.isOpen = true;
				this._syncUrl();
			},

			close() {
				this.isOpen = false;
				this._dirty = false;
				this._toggleActionBar( true );
				this._syncUrl();
			},

			_toggleActionBar( restore ) {
				/*
				 * Drop the payout action bar behind the drawer backdrop (z-40) rather
				 * than hiding it, so it dims in place like the rest of the page instead
				 * of vanishing — the admin still sees "Pay affiliates" is there, just
				 * paused behind the drawer. Restored on close.
				 */
				const bar = document.querySelector( '.affwp-payout-action-bar' );
				if ( bar ) {
					bar.style.zIndex = restore ? '' : '30';
				}
			},

			confirmClose() {
				if ( ! this._dirty || window.confirm( L.discard || 'You have unsaved changes. Discard them?' ) ) {
					this.close();
				}
			},

			/*
			 * Keep the URL refresh-safe by managing ONLY the drawer's own trigger
			 * params (new_schedule / edit_schedule). Never touch the host page's
			 * query: the payout preview carries include_affiliates, minimum,
			 * bypass_holding, etc. as its live filters in this same URL, so
			 * scrubbing them here would break the preview on refresh.
			 */
			_syncUrl() {
				try {
					const url = new URL( window.location );
					url.searchParams.delete( 'new_schedule' );
					if ( this.isOpen && ! this.isNew && this.form.id ) {
						url.searchParams.set( 'tab', 'schedules' );
						url.searchParams.set( 'edit_schedule', this.form.id );
					} else {
						url.searchParams.delete( 'edit_schedule' );
					}
					window.history.replaceState( {}, '', url );
				} catch ( e ) { /* History API unavailable. */ }
			},

			init() {
				const params = new URLSearchParams( window.location.search );
				if ( 'schedules' !== params.get( 'tab' ) ) {
					return;
				}
				const editId   = parseInt( params.get( 'edit_schedule' ), 10 );
				const deepOpen = () => {
					if ( editId > 0 ) {
						this.openById( editId );
						return;
					}
					if ( '1' === params.get( 'new_schedule' ) ) {
						/* Deep-link / no-JS fallback carries only minimum + holding; a direct URL landing
						   opens with an empty (= all) selection. Names arrive via the click interceptor's data. */
						this.openNew( {
							minimum: params.get( 'minimum' ),
							bypass: params.get( 'bypass_holding' ),
						} );
					}
				};
				/*
				 * This store registers (and Alpine runs this init) as soon as the file
				 * executes, which can be before Alpine.start() — but the schedulePicker
				 * store only registers on alpine:init (inline in the drawer markup).
				 * Opening a deep-linked edit at that point would seed a picker that
				 * doesn't exist yet, silently dropping the schedule's affiliate scope
				 * (and a save from that state would widen it to "all affiliates").
				 * Wait for the picker before opening; alpine:initialized fires after
				 * every alpine:init listener has registered its stores.
				 */
				if ( window.Alpine && Alpine.store( 'schedulePicker' ) ) {
					deepOpen();
				} else {
					document.addEventListener( 'alpine:initialized', deepOpen, { once: true } );
				}
			},
		} );
		// Alpine automatically runs a store's init() after registration.
	}

	if ( window.Alpine ) {
		register();
	} else {
		document.addEventListener( 'alpine:init', register );
	}

	/*
	 * The affiliate/group picker mutates its own store — no native input events —
	 * so the drawer trait's @input/@change dirty tracking never sees a selection
	 * change. Wrap its selection mutators to mark the form dirty. seed() stays
	 * unwrapped: seeding on open isn't an edit (and open() resets _dirty anyway).
	 */
	document.addEventListener( 'alpine:initialized', function () {
		const picker = Alpine.store( 'schedulePicker' );
		if ( ! picker ) {
			return;
		}
		[ 'addAffiliate', 'removeAffiliate', 'addGroup', 'removeGroup' ].forEach( function ( method ) {
			if ( 'function' !== typeof picker[ method ] ) {
				return;
			}
			const original = picker[ method ];
			picker[ method ] = function () {
				// Mutate the selection first, then react to the new state.
				const result = original.apply( this, arguments );
				const drawer = Alpine.store( 'scheduleDrawer' );
				if ( drawer && drawer.isOpen ) {
					drawer._markDirty();
					// Selection changed — refresh the auto-filled name (no-op once the admin typed one).
					if ( drawer.refreshName ) {
						drawer.refreshName();
					}
				}
				return result;
			};
		} );
	} );

	/*
	 * Click handling: "Edit" links and whole-row clicks open the drawer instead
	 * of navigating. Action links carrying affwp_action (pause/resume/run/delete)
	 * are left alone so their handlers run.
	 */
	document.addEventListener( 'click', function ( event ) {

		// A drag to select a name/amount shouldn't open the drawer — only a plain click should.
		if ( window.affwpRowClickWasSelection && window.affwpRowClickWasSelection() ) return;

		// Open the drawer (seeded) from either trigger: the preview's <button
		// data-new-schedule> or the Schedules tab's deep-linkable <a ?new_schedule=1>.
		const newTrigger = event.target.closest( '[data-new-schedule], a[href*="new_schedule=1"]' );
		if ( newTrigger ) {
			// Stop the anchor variant from navigating; harmless no-op on the button.
			event.preventDefault();
			const openNewSchedule = () => {
				const store = window.Alpine && Alpine.store( 'scheduleDrawer' );
				if ( ! store ) {
					return;
				}
				// Seed minimum/holding from data-attrs (button) or the href query (anchor).
				let minimum = newTrigger.dataset.minimum || null;
				let bypass  = newTrigger.dataset.bypassHolding || null;
				if ( ( null === minimum || null === bypass ) && newTrigger.href ) {
					try {
						const url = new URL( newTrigger.href, window.location.origin );
						minimum = null === minimum ? url.searchParams.get( 'minimum' ) : minimum;
						bypass  = null === bypass ? url.searchParams.get( 'bypass_holding' ) : bypass;
					} catch ( e ) { /* seed from data-attrs only */ }
				}
				// Selected affiliates/groups ride as JSON data attributes ({id,name}) so the picker chips pre-fill.
				store.openNew( {
					affiliates: parseJsonAttr( newTrigger.dataset.pickerAffiliates ),
					groups: parseJsonAttr( newTrigger.dataset.pickerGroups ),
					minimum: minimum,
					bypass: bypass,
				} );
			};
			if ( window.Alpine && Alpine.store( 'scheduleDrawer' ) ) {
				openNewSchedule();
			} else {
				document.addEventListener( 'alpine:initialized', openNewSchedule, { once: true } );
			}
			return;
		}

		const editLink = event.target.closest( 'a[href*="edit_schedule="]' );
		if ( editLink ) {
			try {
				const url = new URL( editLink.href, window.location.origin );
				const id  = parseInt( url.searchParams.get( 'edit_schedule' ), 10 );
				/*
				 * Only intercept when the schedule's row JSON is in the DOM (the
				 * Schedules tab) — openById() reads it from there. Elsewhere
				 * (batch caption, batch drawer) swallowing the click strands the
				 * user; let the link navigate and init()'s ?edit_schedule
				 * deep-link open the drawer on arrival.
				 */
				if ( id && window.Alpine && Alpine.store( 'scheduleDrawer' ) && document.querySelector( 'tr[data-schedule-id="' + id + '"]' ) ) {
					event.preventDefault();
					Alpine.store( 'scheduleDrawer' ).openById( id );
				}
			} catch ( e ) { /* let the browser handle it */ }
			return;
		}

		// Whole-row click — skip interactive controls and the actions column.
		if ( event.target.closest( 'a, button, input, label, select, textarea, .column-actions, [popover]' ) ) {
			return;
		}
		const row = event.target.closest( 'tr[data-schedule-id]' );
		if ( ! row ) {
			return;
		}
		const id = parseInt( row.dataset.scheduleId, 10 );
		if ( id && window.Alpine && Alpine.store( 'scheduleDrawer' ) ) {
			Alpine.store( 'scheduleDrawer' ).openById( id );
		}
	} );

	/*
	 * Drawer save runs in place — no full-page reload. The form is a normal POST
	 * (the no-JS fallback, affwp_save_payout_schedule, redirects back), but with JS
	 * we post it via AJAX so creating from the payout preview doesn't reload the
	 * page out from under the admin. On success we close the drawer and toast; on
	 * the preview we stay put so the admin can finish paying, elsewhere (the
	 * Schedules tab) we reload so the created/updated row renders.
	 */
	document.addEventListener( 'submit', function ( event ) {
		const form = event.target;
		if ( ! form || 'affwp-schedule-form' !== form.id ) {
			return;
		}
		// No localized ajax URL — let the native POST proceed (fallback).
		if ( ! data.ajaxUrl ) {
			return;
		}
		event.preventDefault();

		const store  = ( window.Alpine && Alpine.store( 'scheduleDrawer' ) ) || null;
		const submit = document.querySelector( 'button[type="submit"][form="affwp-schedule-form"]' );
		const body = new FormData( form );
		body.set( 'action', 'affwp_schedule_save' ); // admin-ajax dispatch key; the form's own _wpnonce rides along.
		/* Drop the native affwp_action (the no-JS fallback): AffiliateWP's init-time dispatcher
		   would otherwise run the redirecting handler during this AJAX request, shadowing the JSON one. */
		body.delete( 'affwp_action' );

		if ( submit ) {
			submit.disabled = true;
		}
		if ( store ) {
			store.saving = true; // Show the button spinner for the duration of the request.
		}

		fetch( data.ajaxUrl, {
			method: 'POST',
			credentials: 'same-origin',
			body: body,
		} ).then( function ( r ) {
			return r.json();
		} ).then( function ( res ) {
			if ( ! res || ! res.success || ! res.data ) {
				fireToast( ( res && res.data && res.data.message ) || L.error, 'error' );
				if ( submit ) {
					submit.disabled = false;
				}
				if ( store ) {
					store.saving = false;
				}
				return;
			}

			if ( store ) {
				store.close();
			}

			// On the payout preview (its action bar is present) stay put and toast so
			// the admin keeps their preview; anywhere else reload to render the row.
			if ( document.querySelector( '.affwp-payout-action-bar' ) ) {
				fireToast( res.data.message, 'success' );
				// Stay-put path: re-enable so a second "Save as schedule" on the same
				// page works (the reload branch below doesn't need this).
				if ( submit ) {
					submit.disabled = false;
				}
				if ( store ) {
					store.saving = false;
				}
			} else {
				fireToast( res.data.message, 'success', true );
				window.location.reload();
			}
		} ).catch( function () {
			fireToast( L.error, 'error' );
			if ( submit ) {
				submit.disabled = false;
			}
			if ( store ) {
				store.saving = false;
			}
		} );
	} );

	/*
	 * Row actions (Pause/Resume, Run now, Delete) run in place — no page reload.
	 * The kebab items are nonced links carrying data-schedule-action; without JS
	 * (or a localized nonce) they fall back to full-navigation. On success we
	 * refresh the row's status + next-run cells and toast. Pause/Resume also flips
	 * the toggle item and shows/hides "Run now"; Delete removes the row (reloading
	 * only when the last one goes, so the server can render the empty state).
	 */
	const ROW_ACTION_AJAX = {
		pause:  'affwp_schedule_toggle_status',
		resume: 'affwp_schedule_toggle_status',
		run:    'affwp_schedule_run_now',
		delete: 'affwp_schedule_delete',
	};

	function fireToast( message, type, persist ) {
		if ( ! message ) {
			return;
		}
		if ( typeof window.affwpToast === 'function' ) {
			window.affwpToast( { message: message, type: type || 'success', persist: !! persist } );
		} else {
			window.dispatchEvent( new CustomEvent( 'affwp-toast', { detail: { message: message, type: type || 'success' } } ) );
		}
	}

	function postRowAction( action, id ) {
		const body = new URLSearchParams();
		body.set( 'action', action );
		body.set( 'nonce', data.nonce || '' );
		body.set( 'schedule_id', id );
		return fetch( data.ajaxUrl, {
			method: 'POST',
			credentials: 'same-origin',
			headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
			body: body.toString(),
		} ).then( function ( r ) { return r.json(); } );
	}

	function refreshCells( row, cells ) {
		if ( ! row || ! cells ) {
			return;
		}
		const statusCell  = row.querySelector( '.column-status' );
		const nextRunCell = row.querySelector( '.column-next_run' );
		const lastRunCell = row.querySelector( '.column-last_run' );
		if ( statusCell && undefined !== cells.status ) {
			statusCell.innerHTML = cells.status;
		}
		if ( nextRunCell && undefined !== cells.next_run ) {
			nextRunCell.innerHTML = cells.next_run;
		}
		if ( lastRunCell && undefined !== cells.last_run ) {
			lastRunCell.innerHTML = cells.last_run;
		}
		// The swapped cells may carry [data-tooltip-html] badges; the Tippy scan
		// only runs on full loads, so re-attach here.
		if ( window.affwpTooltips && 'function' === typeof window.affwpTooltips.refresh ) {
			window.affwpTooltips.refresh();
		}
	}

	document.addEventListener( 'click', function ( event ) {
		const link = event.target.closest && event.target.closest( 'a[data-schedule-action]' );
		if ( ! link ) {
			return;
		}

		const action     = link.dataset.scheduleAction;
		const id         = parseInt( link.dataset.scheduleId, 10 );
		const ajaxAction = ROW_ACTION_AJAX[ action ];

		// Unknown action or no localized nonce/URL — let the link navigate (fallback).
		if ( ! ajaxAction || ! id || ! data.ajaxUrl || ! data.nonce ) {
			return;
		}

		event.preventDefault();

		if ( 'delete' === action && ! window.confirm( link.dataset.affwpConfirm || 'Delete this schedule?' ) ) {
			return;
		}

		// Close the kebab (it lives in the top layer and would otherwise linger).
		const pop = link.closest( '[popover]' );
		try {
			if ( pop && pop.matches( ':popover-open' ) ) {
				pop.hidePopover();
			}
		} catch ( e ) { /* popover unsupported */ }

		const row = link.closest( 'tr[data-schedule-id]' );

		// Run is synchronous server-side and can take a moment — acknowledge immediately.
		if ( 'run' === action && L.runningNow ) {
			fireToast( L.runningNow );
		}

		postRowAction( ajaxAction, id ).then( function ( res ) {
			if ( ! res || ! res.success || ! res.data ) {
				fireToast( ( res && res.data && res.data.message ) || L.error, 'error' );
				return;
			}
			const d = res.data;

			if ( 'delete' === action ) {
				if ( ! row ) {
					fireToast( d.message, 'success' );
					return;
				}
				row.style.transition = 'opacity .18s ease';
				row.style.opacity = '0';
				window.setTimeout( function () {
					row.remove();
					if ( 0 === parseInt( d.remaining, 10 ) ) {
						// Last row gone — reload so the server renders the empty state; carry the toast across.
						fireToast( d.message, 'success', true );
						window.location.reload();
					} else {
						fireToast( d.message, 'success' );
					}
				}, 180 );
				return;
			}

			// Pause / Resume / Run — refresh the status + next-run cells.
			refreshCells( row, d.cells );

			if ( row && ( 'pause' === action || 'resume' === action ) ) {
				const nowActive = !! d.is_active;

				// Keep the cached row JSON in sync: the edit drawer reads it on open, and a
				// stale "active" here would let a later save silently re-arm a paused schedule.
				if ( row.dataset.schedule ) {
					try {
						const cached  = JSON.parse( row.dataset.schedule );
						cached.status = nowActive ? 'active' : 'paused';
						row.dataset.schedule = JSON.stringify( cached );
					} catch ( e ) { /* malformed cache — the cells already reflect the change */ }
				}

				// The row renders each action twice (desktop kebab + mobile list), so update
				// every match — not just the first — or the mobile pill keeps its stale label.
				row.querySelectorAll( '[data-schedule-toggle-item]' ).forEach( function ( toggle ) {
					toggle.dataset.scheduleAction = nowActive ? 'pause' : 'resume';
					toggle.textContent            = nowActive ? ( L.pause || 'Pause' ) : ( L.resume || 'Resume' );
					try {
						const u = new URL( toggle.href, window.location.origin );
						u.searchParams.set( 'affwp_action', nowActive ? 'pause_payout_schedule' : 'resume_payout_schedule' );
						toggle.href = u.toString();
					} catch ( e ) { /* leave href as-is */ }
				} );
				row.querySelectorAll( '[data-schedule-run-item]' ).forEach( function ( runItem ) {
					runItem.classList.toggle( 'hidden', ! nowActive );
				} );
			}

			// Run results persist (they carry a paid/failed count worth reading); pause/resume are trivial confirmations that can auto-dismiss.
			fireToast( d.message, d.type || 'success', 'run' === action );
		} ).catch( function () {
			fireToast( L.error, 'error' );
		} );
	} );

} )();
