<?php
/**
 * Automatic Payout Processor
 *
 * Handles the execution of automatic payouts via Action Scheduler.
 * Processes payouts without admin context by calling payment APIs directly.
 *
 * @package     AffiliateWP
 * @subpackage  Core
 * @copyright   Copyright (c) 2014, Sandhills Development, LLC
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       2.35.0
 */

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

/**
 * Automatic Payout Processor class.
 *
 * @since 2.35.0
 */
class Affiliate_WP_Automatic_Payout_Processor {

	/**
	 * Action Scheduler hook name.
	 *
	 * @since 2.35.0
	 * @var string
	 */
	const HOOK = 'affwp_run_automatic_payout';

	/**
	 * Action Scheduler group.
	 *
	 * @since 2.35.0
	 * @var string
	 */
	const GROUP = 'affiliatewp';

	/**
	 * MySQL advisory lock name for serializing automatic payout runs.
	 *
	 * @since 2.35.0
	 * @var string
	 */
	const LOCK_NAME = 'affwp_automatic_payout_processing';

	/**
	 * Timeout in seconds to wait for the advisory lock.
	 *
	 * @since 2.35.0
	 * @var int
	 */
	const LOCK_TIMEOUT = 5;

	/**
	 * Seconds to wait before retrying a run that lost the global lock.
	 *
	 * Short (not a full pay period) so a schedule that collides with another
	 * run this period still pays this period, moments after the holder releases.
	 *
	 * @since 2.35.0
	 * @var int
	 */
	const LOCK_RETRY_DELAY = 300;

	/**
	 * Constructor.
	 *
	 * @since 2.35.0
	 */
	public function __construct() {
		add_action( self::HOOK, array( $this, 'process' ) );

		// Self-heal: re-arm active schedules whose single AS action was lost
		// (fatal before reschedule(), AS purge, manual deletion). Throttled to
		// once/hour and independent of Action Scheduler's own persistence.
		add_action( 'admin_init', array( __CLASS__, 'maybe_reregister_schedules' ) );

		// Clear scheduled runs on plugin deactivation.
		if ( defined( 'AFFILIATEWP_PLUGIN_FILE' ) ) {
			register_deactivation_hook( AFFILIATEWP_PLUGIN_FILE, array( __CLASS__, 'unregister_all_schedules' ) );
		}
	}

	/**
	 * Processes a automatic payout.
	 *
	 * This is the Action Scheduler callback. It loads the schedule,
	 * queries eligible referrals, processes payouts via the appropriate
	 * payment method, and logs the results.
	 *
	 * @since 2.35.0
	 *
	 * @param int $schedule_id The payout schedule ID.
	 */
	public function process( $schedule_id ) {

		$schedule = affwp_get_payout_schedule( $schedule_id );

		if ( ! $schedule ) {
			affiliate_wp()->utils->log( sprintf( 'Automatic Payout: Schedule #%d not found. Skipping.', $schedule_id ) );
			return;
		}

		/*
		 * Automatic payouts are a Pro feature. If the site no longer qualifies (e.g.
		 * a Pro license lapsed or downgraded to Plus), do NOT execute — a scheduled
		 * run would pay affiliates money on a plan that isn't entitled to it. Skip
		 * and reschedule (not delete/pause) so runs resume automatically if the
		 * license is restored; the schedules screen shows a notice meanwhile.
		 */
		if ( ! affwp_can_access_pro_features() ) {
			affiliate_wp()->utils->log( sprintf( 'Automatic Payout: Schedule #%d skipped — automatic payouts require a Pro license (current plan does not qualify).', $schedule_id ) );
			$this->reschedule( $schedule );
			return;
		}

		// Only process active schedules.
		if ( 'active' !== $schedule->status ) {
			affiliate_wp()->utils->log( sprintf( 'Automatic Payout: Schedule #%d is not active (status: %s). Skipping.', $schedule_id, $schedule->status ) );
			$this->reschedule( $schedule );
			return;
		}

		// Check for concurrent run - skip if a log entry exists with 'processing' status for this schedule.
		$recent_logs = affiliate_wp()->affiliates->payout_schedule_logs->get_logs( array(
			'schedule_id' => $schedule_id,
			'status'      => 'processing',
			'number'      => 1,
		) );

		if ( ! empty( $recent_logs ) ) {
			$stale_log = $recent_logs[0];
			$log_time  = strtotime( $stale_log->run_at );

			// If the "processing" log is older than 30 minutes, the previous run likely crashed.
			if ( $log_time && ( time() - $log_time ) > 30 * MINUTE_IN_SECONDS ) {
				affiliate_wp()->utils->log( sprintf( 'Automatic Payout: Schedule #%d has a stale processing log (#%d, started %s). Marking as failed.', $schedule_id, $stale_log->log_id, $stale_log->run_at ) );

				affiliate_wp()->affiliates->payout_schedule_logs->update( $stale_log->log_id, array(
					'status' => 'failed',
					'errors' => wp_json_encode( array( 'error' => 'Marked as failed: processing log was stale (older than 30 minutes). Previous run likely crashed.' ) ),
				), '', 'log' );
			} else {
				affiliate_wp()->utils->log( sprintf( 'Automatic Payout: Schedule #%d is already processing. Skipping concurrent run.', $schedule_id ) );
				return;
			}
		}

		// Acquire global advisory lock to serialize all automatic payout runs.
		// This prevents different schedules from processing overlapping affiliates simultaneously.
		if ( ! $this->acquire_lock() ) {
			/*
			 * Another schedule holds the global run lock. Don't advance next_run a
			 * full period (that would skip this pay period for this schedule) — retry
			 * shortly, once the holder releases. GET_LOCK auto-releases when its DB
			 * session ends, so this can't spin forever; next_run is left untouched so
			 * the successful retry advances the cadence normally.
			 */
			affiliate_wp()->utils->log( sprintf( 'Automatic Payout: Schedule #%d could not acquire the processing lock; another run holds it. Retrying in %d seconds.', $schedule_id, self::LOCK_RETRY_DELAY ) );
			affwp_schedule_payout_action( $schedule_id, time() + self::LOCK_RETRY_DELAY );
			return;
		}

		try {

			// Create a processing log entry.
			$log_id = affiliate_wp()->affiliates->payout_schedule_logs->add( array(
				'schedule_id' => $schedule_id,
				'status'      => 'processing',
			) );

			// Get eligible referrals grouped by affiliate.
			$eligibility       = $this->get_eligible_payouts( $schedule );
			$affiliate_payouts = $eligibility['payouts'];

			if ( empty( $affiliate_payouts ) ) {

				$errors = array( 'message' => 'No eligible referrals found.' );

				/*
				 * Affiliates held back by a previous failed payout mean money is stuck
				 * until an admin retries/resolves it — record them so the Last-run cell
				 * and the Run-now toast can say so instead of a bare "nothing to pay"
				 * that reads as healthy.
				 */
				if ( ! empty( $eligibility['blocked'] ) ) {
					$errors['blocked_affiliates'] = $eligibility['blocked'];
					$errors['blocking_batch_id']  = $this->blocking_batch_id( $eligibility['blocked'] );
				}

				affiliate_wp()->utils->log( sprintf(
					'Automatic Payout: No eligible referrals found for schedule #%d.%s',
					$schedule_id,
					empty( $eligibility['blocked'] ) ? '' : sprintf( ' %d affiliate(s) blocked by a previous failed payout.', count( $eligibility['blocked'] ) )
				) );

				$this->update_log( $log_id, 'skipped', $errors );
				$this->update_schedule_after_run( $schedule );
				$this->reschedule( $schedule );
				return;
			}

			// Process payouts.
			$results = $this->process_payouts( $affiliate_payouts, $schedule );

			// Determine overall status.
			$status = 'success';
			if ( $results['failed'] > 0 && $results['paid'] > 0 ) {
				$status = 'partial';
			} elseif ( $results['failed'] > 0 && 0 === $results['paid'] ) {
				$status = 'failed';
			}

			// Update log entry (with the batch it produced, so the run links to its payouts).
			$this->update_log(
				$log_id,
				$status,
				$results['errors'],
				isset( $results['batch_id'] ) ? (int) $results['batch_id'] : 0
			);

			// Update schedule.
			$this->update_schedule_after_run( $schedule );

			// Reschedule next run.
			$this->reschedule( $schedule );

			affiliate_wp()->utils->log( sprintf(
				'Automatic Payout: Schedule #%d completed. Status: %s. Paid: %d. Failed: %d. Total: %s.',
				$schedule_id,
				$status,
				$results['paid'],
				$results['failed'],
				affwp_currency_filter( affwp_format_amount( $results['total_amount'] ) )
			) );

		} finally {
			$this->release_lock();
		}
	}

	/**
	 * Gets eligible payouts grouped by affiliate, plus the affiliates held back
	 * by a previous failed payout.
	 *
	 * @since 2.35.0
	 *
	 * @param object $schedule The payout schedule object.
	 * @return array {
	 *     @type array $payouts Payouts grouped by affiliate ID.
	 *     @type int[] $blocked Affiliate IDs excluded solely because a previous
	 *                          payout failed and awaits retry/resolution.
	 * }
	 */
	private function get_eligible_payouts( $schedule ) {

		/*
		 * Scope is a union of explicitly-included affiliates and the members of any
		 * included groups (mirrors the Pay Affiliates "Include" filter). When both
		 * are empty, the schedule targets every eligible affiliate.
		 */
		$include_affiliates = $this->decode_id_list( $schedule->affiliate_ids );
		$include_groups     = $this->decode_id_list( $schedule->affiliate_group_ids );

		$affiliate_id = false;

		if ( ! empty( $include_affiliates ) || ! empty( $include_groups ) ) {
			$scoped = $include_affiliates;

			foreach ( $include_groups as $group_id ) {
				$group_affiliates = affiliate_wp()->connections->get_connected( 'affiliate', 'group', $group_id, 'list' );
				if ( ! empty( $group_affiliates ) && ! is_wp_error( $group_affiliates ) ) {
					$scoped = array_merge( $scoped, array_map( 'absint', $group_affiliates ) );
				}
			}

			$scoped = array_values( array_unique( array_filter( $scoped ) ) );

			// A scope was set but matched nobody → nothing to pay this run.
			if ( empty( $scoped ) ) {
				return array(
					'payouts' => array(),
					'blocked' => array(),
				);
			}

			$affiliate_id = $scoped;
		}

		/*
		 * Delegate to the single source of truth shared with the manual batch, the
		 * preview, and the eligibility chip, mirroring the batch processor's args, so a
		 * scheduled run pays exactly what running the payout manually would: bounded-memory
		 * pagination, the live payout-status dedup that retries failed payouts, and the
		 * affwp_generate_payout_get_referrals_args addons. Re-implementing the pipeline
		 * here is what drifted into bugs (see affwp_compute_eligible_payouts()'s docblock).
		 */
		$result = affwp_compute_eligible_payouts( array(
			'affiliate_id'        => $affiliate_id,
			'minimum'             => (float) $schedule->minimum_amount,
			'bypass_holding'      => ! empty( $schedule->bypass_holding_period ),
			'referral_shape'      => 'id',
			'args_filter'         => 'generate',
			'args_filter_context' => $this,
			// Also report who was excluded and why, so a skipped run can say
			// "N affiliates blocked by a failed payout" instead of a bare
			// "nothing eligible" that reads as healthy.
			'track_invalid'       => true,
		) );

		$invalid = isset( $result['invalid_affiliates'] ) && is_array( $result['invalid_affiliates'] )
			? $result['invalid_affiliates']
			: array();

		// Only failed-payout holds are surfaced: they mean money is stuck until an
		// admin acts. Other reasons (e.g. below the minimum) are expected behavior.
		$blocked = array_keys( $invalid, 'previous_payout_failed', true );

		return array(
			'payouts' => isset( $result['payouts'] ) ? $result['payouts'] : array(),
			'blocked' => array_map( 'absint', $blocked ),
		);
	}

	/**
	 * Finds the batch holding the failed payouts that block these affiliates.
	 *
	 * @since 2.35.0
	 *
	 * @param int[] $affiliate_ids Blocked affiliate IDs.
	 * @return int The single blocking batch ID, or 0 when the failed payouts span
	 *             multiple batches (or none could be resolved).
	 */
	private function blocking_batch_id( array $affiliate_ids ) {

		if ( empty( $affiliate_ids ) ) {
			return 0;
		}

		$payouts = affiliate_wp()->affiliates->payouts->get_payouts( array(
			'affiliate_id' => $affiliate_ids,
			'status'       => 'failed',
			'number'       => -1,
		) );

		$batch_ids = array_values( array_unique( array_filter( array_map( 'absint', wp_list_pluck( $payouts, 'batch_id' ) ) ) ) );

		return 1 === count( $batch_ids ) ? $batch_ids[0] : 0;
	}

	/**
	 * Decode a stored JSON id list (affiliate_ids / affiliate_group_ids) into a clean
	 * array of positive integers. Tolerates null, empty, an already-decoded array, or junk.
	 *
	 * @since 2.35.0
	 *
	 * @param mixed $value Stored value (JSON string, array, or empty).
	 * @return int[] Unique positive integer IDs.
	 */
	private function decode_id_list( $value ) {
		if ( empty( $value ) ) {
			return array();
		}
		$parsed = is_array( $value ) ? $value : json_decode( (string) $value, true );
		if ( ! is_array( $parsed ) ) {
			return array();
		}
		return array_values( array_unique( array_filter( array_map( 'absint', $parsed ) ) ) );
	}

	/**
	 * Processes payouts for all eligible affiliates.
	 *
	 * Each affiliate is routed through their own configured payout method
	 * ({@see affwp_get_affiliate_payout_method()}) — the schedule no longer
	 * carries a single method. A run therefore spans methods, so the whole run
	 * is wrapped in one `'mixed'` batch (mirroring the manual bulk-pay flow):
	 * every payout is attached to that batch, the batch is recounted, and the
	 * shared `affwp_batch_generate_payouts_completed` lifecycle fires once. This
	 * makes a scheduled run appear in the Batches tab exactly like a manual one.
	 *
	 * @since 2.35.0
	 *
	 * @param array  $affiliate_payouts Payouts grouped by affiliate.
	 * @param object $schedule          The schedule object.
	 * @return array Results with paid, failed counts, total_amount, errors, and batch_id.
	 */
	private function process_payouts( $affiliate_payouts, $schedule ) {

		$results = array(
			'paid'         => 0,
			'failed'       => 0,
			'total_amount' => 0,
			'errors'       => array(),
			'batch_id'     => 0,
		);

		/*
		 * One batch per run. Per-affiliate routing means the run can span
		 * methods, so the batch method is resolved from the affiliates it will
		 * pay: a single method's slug when they all route the same way, or
		 * 'mixed' when they differ (the same helper the manual bulk-pay flow
		 * uses). Corrected against the methods actually paid once the loop ends.
		 */
		$eligible_method = affwp_resolve_batch_payout_method( array_keys( $affiliate_payouts ) );

		$batch_id = affwp_add_payout_batch( array(
			'created_by'    => (int) $schedule->created_by,
			'payout_method' => $eligible_method,
			'status'        => 'processing',
			'schedule_id'   => (int) $schedule->schedule_id, // Provenance: marks the batch as automated and by which schedule.
		) );

		if ( ! $batch_id ) {
			// Couldn't open a batch — fail the run cleanly rather than create orphaned payouts.
			foreach ( $affiliate_payouts as $affiliate_id => $payout ) {
				$results['failed']++;
				$results['errors'][ $affiliate_id ] = __( 'Could not create a payout batch for this run.', 'affiliate-wp' );
			}
			return $results;
		}

		$results['batch_id'] = (int) $batch_id;

		// Distinct methods that actually produced a payout — used to re-label the batch below.
		$methods_used = array();

		foreach ( $affiliate_payouts as $affiliate_id => $payout ) {

			// Each affiliate's usable method (their pick → enabled-method check → site
			// default), resolved exactly like the manual Pay Affiliates batch.
			$method = affwp_get_affiliate_usable_payout_method( $affiliate_id );

			/*
			 * A schedule can't perform a "Manual" payout — that method only records a
			 * payout for the admin to pay by hand, which is meaningless in an
			 * unattended run. Skip those affiliates and surface the reason in the run
			 * history rather than silently marking them paid.
			 */
			if ( 'manual' === str_replace( '-', '_', (string) $method ) ) {
				$results['failed']++;
				$results['errors'][ $affiliate_id ] = __( 'Skipped: set to Manual, which can\'t be paid automatically.', 'affiliate-wp' );
				continue;
			}

			/**
			 * Initial payout status: sync methods (store credit) settle as 'paid';
			 * async methods (PayPal, Stripe) return 'processing' until the provider
			 * confirms. This is the same filter the manual batch processor applies.
			 *
			 * This filter is documented in includes/payouts/class-batch-payout-processor.php
			 */
			$initial_status = apply_filters( 'affwp_batch_payout_initial_status', 'paid', $method, $affiliate_id, $batch_id );

			/*
			 * Store Credit credits the balance from inside affwp_add_payout(); only
			 * allow it when this payout actually routes to store credit (mirrors the
			 * batch processor's guard, so an affiliate who picked store credit but is
			 * being paid another way here isn't double-credited).
			 */
			$paying_store_credit = 'store_credit' === str_replace( '-', '_', (string) $method );
			$force_store_credit  = static function () use ( $paying_store_credit ) {
				return $paying_store_credit;
			};
			add_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

			$payout_id = affwp_add_payout( array(
				'affiliate_id'  => $affiliate_id,
				'referrals'     => $payout['referrals'],
				'payout_method' => $method,
				'batch_id'      => $batch_id,
				'status'        => $initial_status,
			) );

			remove_filter( 'affwp_store_credit_can_receive_store_credit', $force_store_credit, 99 );

			if ( $payout_id ) {
				$results['paid']++;
				$results['total_amount'] += (float) $payout['amount'];
				$methods_used[ $method ] = true;
			} else {
				$results['failed']++;
				$results['errors'][ $affiliate_id ] = __( 'Could not create a payout record for this affiliate.', 'affiliate-wp' );
			}
		}

		/*
		 * Re-label the batch from the methods that actually produced a payout.
		 * $eligible_method (above) counts every eligible affiliate, including the
		 * Manual ones this run skips — so a run that skipped a Manual affiliate and
		 * paid only PayPal would read 'mixed'. The label should describe what's in
		 * the batch: a single method's slug, or 'mixed' when it genuinely spans them.
		 */
		if ( ! empty( $methods_used ) ) {
			$batch_method = 1 === count( $methods_used ) ? (string) array_key_first( $methods_used ) : 'mixed';
			if ( $batch_method !== $eligible_method ) {
				affiliate_wp()->affiliates->payout_batches->update( (int) $batch_id, array( 'payout_method' => $batch_method ), '', 'payout_batch' );
			}
		}

		/*
		 * Roll up the batch, then fire the shared completion hook. THIS is where the
		 * money actually moves: each payout-method integration (PayPal, Stripe, Store
		 * Credit) hooks affwp_batch_generate_payouts_completed and submits its
		 * method-matched payouts — so a scheduled run reuses the exact same payment
		 * path as the manual Pay Affiliates flow (confirmation-based status, failure
		 * classification, and retry all handled by core). We only create the records.
		 *
		 * This action is documented in includes/payouts/class-batch-payout-processor.php
		 */
		if ( isset( affiliate_wp()->affiliates->payout_batches ) ) {
			affiliate_wp()->affiliates->payout_batches->recount( (int) $batch_id );
		}
		do_action( 'affwp_batch_generate_payouts_completed', (int) $batch_id );

		return $results;
	}

	/**
	 * Updates a log entry with the run's dispatch result.
	 *
	 * Stores only what can't go stale: the dispatch status, the batch this run
	 * produced, and any dispatch errors. Paid/failed/amount are read live from
	 * the batch, never frozen here.
	 *
	 * @since 2.35.0
	 *
	 * @param int    $log_id   The log ID.
	 * @param string $status   The dispatch status.
	 * @param array  $errors   Error details.
	 * @param int    $batch_id The batch this run produced. 0 for skipped/no-batch runs.
	 */
	private function update_log( $log_id, $status, $errors, $batch_id = 0 ) {

		affiliate_wp()->affiliates->payout_schedule_logs->update( $log_id, array(
			'status'   => $status,
			'batch_id' => (int) $batch_id, // Links the run to the batch it produced so the Last-run outcome can drill in and read live counts. 0 for skipped/no-batch runs.
			'errors'   => ! empty( $errors ) ? wp_json_encode( $errors ) : '',
		), '', 'log' );
	}

	/**
	 * Updates the schedule after a run.
	 *
	 * @since 2.35.0
	 *
	 * @param object $schedule The schedule object.
	 */
	private function update_schedule_after_run( $schedule ) {

		$next_run = affwp_calculate_next_run( $schedule->frequency, $schedule->frequency_day );

		affwp_update_payout_schedule( $schedule->schedule_id, array(
			'last_run' => gmdate( 'Y-m-d H:i:s' ),
			'next_run' => $next_run,
		) );
	}

	/**
	 * Acquires a MySQL advisory lock to serialize automatic payout processing.
	 *
	 * Prevents multiple schedules from processing overlapping affiliates
	 * simultaneously. The lock is connection-scoped and auto-releases on
	 * PHP crash, DB disconnect, or process death.
	 *
	 * @since 2.35.0
	 *
	 * @return bool True if the lock was acquired, false otherwise.
	 */
	private function acquire_lock() {
		global $wpdb;

		$result = $wpdb->get_var(
			$wpdb->prepare( "SELECT GET_LOCK(%s, %d)", self::LOCK_NAME, self::LOCK_TIMEOUT )
		);

		return 1 === (int) $result;
	}

	/**
	 * Releases the MySQL advisory lock.
	 *
	 * @since 2.35.0
	 */
	private function release_lock() {
		global $wpdb;

		$wpdb->query(
			$wpdb->prepare( "SELECT RELEASE_LOCK(%s)", self::LOCK_NAME )
		);
	}

	/**
	 * Reschedules the next run via Action Scheduler and persists the
	 * next_run timestamp on the schedule record.
	 *
	 * Uses single actions (not recurring) for better control over timing.
	 * Keeping the DB in sync with Action Scheduler prevents the admin UI
	 * from displaying a stale next_run when a run is skipped (e.g., lock
	 * acquisition failure, inactive method, or paused status).
	 *
	 * @since 2.35.0
	 *
	 * @param object $schedule The schedule object.
	 */
	private function reschedule( $schedule ) {

		if ( 'active' !== $schedule->status ) {
			return;
		}

		$next_run = affwp_calculate_next_run( $schedule->frequency, $schedule->frequency_day );

		if ( ! $next_run ) {
			return;
		}

		$timestamp = strtotime( $next_run );

		if ( ! $timestamp || $timestamp <= time() ) {
			return;
		}

		// Persist next_run on the schedule so the admin UI stays in sync with Action Scheduler.
		affwp_update_payout_schedule( $schedule->schedule_id, array(
			'next_run' => $next_run,
		) );

		affwp_schedule_payout_action( $schedule->schedule_id, $timestamp );
	}

	/**
	 * Registers all active schedules with Action Scheduler.
	 *
	 * Called on plugin activation or when ensuring schedules are registered.
	 *
	 * @since 2.35.0
	 */
	public static function register_active_schedules() {

		$schedules = affwp_get_payout_schedules( array(
			'status' => 'active',
			'number' => -1,
		) );

		if ( empty( $schedules ) ) {
			return;
		}

		$can_check = function_exists( 'as_next_scheduled_action' );

		foreach ( $schedules as $schedule ) {

			$schedule_id = (int) $schedule->schedule_id;

			// Self-heal only touches MISSING actions — leave already-armed schedules alone.
			if ( $can_check && false !== as_next_scheduled_action( self::HOOK, array( $schedule_id ), self::GROUP ) ) {
				continue;
			}

			$next_run  = $schedule->next_run;
			$timestamp = ( ! empty( $next_run ) && '0000-00-00 00:00:00' !== $next_run ) ? strtotime( $next_run ) : false;

			// No stored next_run, or it's already past: recompute and persist so the UI matches what's armed.
			if ( ! $timestamp || $timestamp <= time() ) {
				$next_run  = affwp_calculate_next_run( $schedule->frequency, $schedule->frequency_day );
				$timestamp = $next_run ? strtotime( $next_run ) : false;
				if ( $timestamp && $timestamp > time() ) {
					affwp_update_payout_schedule( $schedule_id, array( 'next_run' => $next_run ) );
				}
			}

			if ( $timestamp && $timestamp > time() ) {
				affwp_schedule_payout_action( $schedule_id, $timestamp );
			}
		}
	}

	/**
	 * Throttled self-heal entry point (hooked to admin_init).
	 *
	 * Re-arms active schedules whose single Action Scheduler run action went
	 * missing. Runs at most once/hour and — unlike a recurring AS action —
	 * survives an Action Scheduler purge, since it rides admin_init.
	 *
	 * @since 2.35.0
	 */
	public static function maybe_reregister_schedules() {

		if ( get_transient( 'affwp_payout_schedules_rearm_check' ) ) {
			return;
		}

		// Cheap guard so this admin_init hook doesn't query Action Scheduler every request.
		set_transient( 'affwp_payout_schedules_rearm_check', 1, HOUR_IN_SECONDS );

		self::register_active_schedules();
	}

	/**
	 * Unregisters all automatic payout actions.
	 *
	 * Called on plugin deactivation.
	 *
	 * @since 2.35.0
	 */
	public static function unregister_all_schedules() {

		if ( function_exists( 'as_unschedule_all_actions' ) ) {
			as_unschedule_all_actions( self::HOOK );
		}
	}

}
