<?php
/**
 * Payout Batch: Action Scheduler integration + async submit handler.
 *
 * Turns "Pay Affiliates" into a safe-to-close-tab operation. Previously the
 * submit flow drove an AJAX-stepping loop from the admin's browser — closing
 * the tab mid-run left payouts half-processed. With this file in place, the
 * submit handler compiles the candidate list, creates the batch row, and
 * enqueues one Action Scheduler action per affiliate. AS workers pick them
 * up in the background. Admin lands on the batch drawer where live status is
 * polled; the admin can close the tab at any point and the batch will still
 * drain to completion.
 *
 * Hooks registered here (must be available in cron/admin-ajax contexts, not
 * just in the admin-only load path):
 *
 *   - `affwp_process_payout_async`              — request-action handler, fires on
 *                                                  the preview form's submit.
 *   - `affwp_process_payout_affiliate`          — AS callback, one per affiliate.
 *   - `affwp_maybe_finalize_payout_batch`       — AS callback, self-reschedules
 *                                                  until all siblings drain, then
 *                                                  fires the completed hook.
 *
 * @package    AffiliateWP
 * @subpackage Payouts
 * @since      2.33.0
 */

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

/**
 * Handles the preview form's "Process Payout" submit for per-affiliate
 * routing. Creates the batch row, enqueues AS actions, redirects to the
 * batch drawer so the admin can watch progress (or close the tab — the
 * batch drains either way).
 *
 * @since 2.33.0
 *
 * @param array $data $_REQUEST payload.
 * @return void
 */
function affwp_process_payout_async_submit( $data ) {

	if ( ! is_admin() ) {
		return;
	}

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_die(
			esc_html__( 'You do not have permission to process payouts.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 403 )
		);
	}

	$nonce = isset( $data['affwp_process_payout_async_nonce'] ) ? $data['affwp_process_payout_async_nonce'] : '';
	if ( ! wp_verify_nonce( $nonce, 'affwp_process_payout_async' ) ) {
		wp_die(
			esc_html__( 'Security check failed.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 403 )
		);
	}

	if ( ! function_exists( 'as_enqueue_async_action' ) ) {
		/*
		 * Action Scheduler should always be available (bundled with
		 * AffiliateWP), but fail loudly if something's broken rather than
		 * silently falling back to an unsafe path.
		 */
		wp_die(
			esc_html__( 'Action Scheduler is not available. Cannot process payouts asynchronously.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 500 )
		);
	}

	require_once AFFILIATEWP_PLUGIN_DIR . 'includes/payouts/class-batch-payout-processor.php';

	/*
	 * Filter-signature lock — prevents a rapid double-submit (within the nonce
	 * TTL) from creating two batches racing the same unpaid referrals. The
	 * `affwp_add_payout` referral-link guard already prevents double-paying,
	 * but two parallel batches with the same criteria split the referrals
	 * across both runs and leave a confusing "partial" status on each. The
	 * lock collapses concurrent submits down to one; the second attempt
	 * lands on a warning notice instead of producing a junk batch.
	 *
	 * Keyed off the filter signature so distinct submits (e.g. "Pay all" vs
	 * "Pay affiliate #42") don't block each other.
	 */
	$lock_signature = md5(
		wp_json_encode(
			array(
				'user_name'      => isset( $data['user_name'] ) ? (string) $data['user_name'] : '',
				'minimum'        => isset( $data['minimum'] ) ? (string) $data['minimum'] : '',
				'from'           => isset( $data['from'] ) ? (string) $data['from'] : '',
				'to'             => isset( $data['to'] ) ? (string) $data['to'] : '',
				'bypass_holding' => ! empty( $data['bypass_holding'] ) ? 1 : 0,
			)
		)
	);
	$lock_key = 'affwp_payout_batch_lock_' . $lock_signature;

	if ( false !== get_transient( $lock_key ) ) {
		$redirect = affwp_admin_url(
			'payouts',
			array(
				'affwp_notice' => 'payout_batch_locked',
			)
		);
		wp_safe_redirect( $redirect );
		exit;
	}

	// Five-minute window covers a typical Pay-All enqueue + AS warm-up.
	set_transient( $lock_key, get_current_user_id(), 5 * MINUTE_IN_SECONDS );

	$result = \AffWP\Payouts\Batch_Payout_Processor::enqueue_async_batch( $data );

	if ( is_wp_error( $result ) ) {
		// Release the lock on empty-batch so the admin can fix filters and retry.
		delete_transient( $lock_key );

		$redirect = affwp_admin_url(
			'payouts',
			array(
				'affwp_notice' => 'payout_batch_empty',
			)
		);
		wp_safe_redirect( $redirect );
		exit;
	}

	/*
	 * Land on the Batches tab with the new batch's drawer auto-opening; it renders status,
	 * total, per-method breakdown, and a polling-aware payouts list. The
	 * affwp_notice=batch_submitted arg fires a confirmation toast on the destination.
	 */
	$redirect = affwp_admin_url(
		'payouts',
		array(
			'tab'          => 'batches',
			'batch_id'     => (int) $result,
			'affwp_notice' => 'batch_submitted',
		)
	);

	wp_safe_redirect( $redirect );
	exit;
}
add_action( 'affwp_process_payout_async', 'affwp_process_payout_async_submit' );

/**
 * Process a "Pay everyone this batch via Store Credit" payout.
 *
 * Unlike PayPal, Stripe, and the Payouts Service, Store Credit ships no
 * synchronous per-method payout handler — it pays purely by crediting the
 * affiliate's balance when referrals flip to 'paid', which the unified batch
 * processor already does. The legacy "Pay everyone via X" form fires
 * `affwp_process_payout_{$payout_method}` (here, `affwp_process_payout_store_credit`)
 * via `affwp_process_new_payout()`. Without a listener that action used to no-op,
 * so the page reloaded and nothing was paid.
 *
 * Hand the batch to the same async pipeline manual and per-affiliate routing use,
 * carrying a store_credit override so every included affiliate is paid via Store
 * Credit regardless of their own configured method. The crediting itself happens
 * in Batch_Payout_Processor::process_affiliate(), which lifts the
 * can_receive_store_credit() gate for the duration of the payout.
 *
 * @since 2.33.0
 *
 * @param string   $start          Referrals start date.
 * @param string   $end            Referrals end date.
 * @param string   $minimum        Minimum payout amount.
 * @param int|bool $affiliate_id   Affiliate ID, or false for all eligible affiliates.
 * @param string   $payout_method  Payout method (always 'store_credit' here).
 * @param bool     $bypass_holding Whether to bypass the referral holding period.
 * @return void
 */
function affwp_process_payout_store_credit_batch( $start, $end, $minimum, $affiliate_id, $payout_method, $bypass_holding ) {

	if ( ! is_admin() || ! current_user_can( 'manage_payouts' ) ) {
		return;
	}

	if ( ! function_exists( 'as_enqueue_async_action' ) ) {
		wp_die(
			esc_html__( 'Action Scheduler is not available. Cannot process payouts.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 500 )
		);
	}

	require_once AFFILIATEWP_PLUGIN_DIR . 'includes/payouts/class-batch-payout-processor.php';

	$data = array(
		'payout_method'  => 'store_credit',
		'from'           => $start,
		'to'             => $end,
		'minimum'        => $minimum,
		'bypass_holding' => $bypass_holding ? 1 : 0,
	);

	if ( ! empty( $affiliate_id ) ) {
		$data['include_affiliates'] = array( (int) $affiliate_id );
	}

	$result = \AffWP\Payouts\Batch_Payout_Processor::enqueue_async_batch( $data );

	if ( is_wp_error( $result ) ) {
		wp_safe_redirect( affwp_admin_url( 'payouts', array( 'affwp_notice' => 'payout_batch_empty' ) ) );
		exit;
	}

	wp_safe_redirect(
		affwp_admin_url(
			'payouts',
			array(
				'tab'          => 'batches',
				'batch_id'     => (int) $result,
				'affwp_notice' => 'batch_submitted',
			)
		)
	);
	exit;
}
add_action( 'affwp_process_payout_store_credit', 'affwp_process_payout_store_credit_batch', 10, 6 );

/**
 * Action Scheduler callback — processes one affiliate's payout within the
 * given batch. Called once per enqueued action, drained by AS workers
 * independently of any browser session.
 *
 * Idempotent: the underlying `affwp_add_payout()` refuses to create a
 * duplicate payout for referrals already linked. If AS retries a failed
 * action, we don't double-pay.
 *
 * @since 2.33.0
 *
 * @param int $batch_id     Payout batch ID.
 * @param int $affiliate_id Affiliate ID to process. The worker re-resolves
 *                          this affiliate's candidate referrals from the
 *                          batch's stored filter snapshot, so the AS args
 *                          stay tiny regardless of referral count.
 * @return void
 */
function affwp_as_process_payout_affiliate( $batch_id, $affiliate_id ) {

	require_once AFFILIATEWP_PLUGIN_DIR . 'includes/payouts/class-batch-payout-processor.php';

	\AffWP\Payouts\Batch_Payout_Processor::process_affiliate_from_args(
		(int) $batch_id,
		(int) $affiliate_id
	);
}
add_action( 'affwp_process_payout_affiliate', 'affwp_as_process_payout_affiliate', 10, 2 );

/**
 * Action Scheduler callback — checks whether all per-affiliate actions in
 * the batch have drained. If any are still pending or in-progress, this
 * callback reschedules itself with a short delay. Once all siblings are
 * done, it performs the final recount and fires
 * `affwp_batch_generate_payouts_completed` so method integrations (PayPal,
 * Stripe) can submit their async providers.
 *
 * Self-rescheduling avoids the double-finalize race: only this callback
 * (the singleton finalize action) fires the completion hook. Subscribers
 * can still guard via batch status if they want belt-and-suspenders.
 *
 * @since 2.33.0
 *
 * @param int $batch_id Payout batch ID.
 * @return void
 */
function affwp_as_maybe_finalize_payout_batch( $batch_id ) {

	$batch_id = (int) $batch_id;

	if ( $batch_id <= 0 ) {
		return;
	}

	$batch = affwp_get_payout_batch( $batch_id );

	if ( ! $batch || 'processing' !== $batch->status ) {
		// Already finalized (or missing). Nothing to do.
		return;
	}

	if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
		// AS missing — shouldn't happen (bundled) but don't loop forever.
		return;
	}

	$pending = as_get_scheduled_actions(
		array(
			'group'    => affwp_payout_batch_as_group( $batch_id ),
			'hook'     => 'affwp_process_payout_affiliate',
			'status'   => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
			'per_page' => 1,
		),
		'ids'
	);

	if ( ! empty( $pending ) ) {
		// Siblings still draining — come back in 15 seconds.
		as_schedule_single_action(
			time() + 15,
			'affwp_maybe_finalize_payout_batch',
			array( $batch_id ),
			affwp_payout_batch_as_group( $batch_id )
		);
		return;
	}

	/*
	 * All per-affiliate actions have resolved. Final authoritative recount
	 * (so any last status updates from in-flight AS steps are captured) and
	 * fire the integration-level completion hook.
	 */
	affiliate_wp()->affiliates->payout_batches->recount( $batch_id );

	/**
	 * Fires once at the end of the batch, after every per-affiliate AS
	 * action has resolved and the batch has been fully recounted.
	 *
	 * Method integrations (PayPal, Stripe) hook this to submit their async
	 * providers in one API call with the complete set of method-matched
	 * payouts, and to schedule any verification work.
	 *
	 * @since 2.33.0
	 *
	 * @param int $batch_id Payout batch ID.
	 */
	do_action( 'affwp_batch_generate_payouts_completed', $batch_id );
}
add_action( 'affwp_maybe_finalize_payout_batch', 'affwp_as_maybe_finalize_payout_batch', 10, 1 );

/**
 * Returns the AS group name for a batch — the group is scoped per-batch so
 * concurrent batches' actions don't interfere, and the finalize callback
 * can query pending siblings in its own group only.
 *
 * Duplicated here as a free function (not a class static) so it's
 * reachable from the AS callback closure above without loading the
 * processor class.
 *
 * @since 2.33.0
 *
 * @param int $batch_id Payout batch ID.
 * @return string
 */
function affwp_payout_batch_as_group( $batch_id ) {
	return 'affwp-payout-batch-' . (int) $batch_id;
}

/**
 * Watchdog callback — re-arms `affwp_maybe_finalize_payout_batch` for any batch
 * stuck in `processing` past the grace window, where no finalize action is
 * currently queued. Runs on a 15-minute recurring schedule.
 *
 * @since 2.33.0
 *
 * @return void
 */
function affwp_as_payout_batch_watchdog() {
	global $wpdb;

	if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_schedule_single_action' ) ) {
		return;
	}

	$table        = affiliate_wp()->affiliates->payout_batches->table_name;
	$grace_cutoff = gmdate( 'Y-m-d H:i:s', time() - 15 * MINUTE_IN_SECONDS );

	$stuck_ids = $wpdb->get_col(
		$wpdb->prepare(
			"SELECT batch_id FROM {$table}
			 WHERE status = %s AND date_created < %s
			 LIMIT 50",
			'processing',
			$grace_cutoff
		)
	);

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

	foreach ( $stuck_ids as $batch_id ) {
		$batch_id = (int) $batch_id;
		if ( $batch_id <= 0 ) {
			continue;
		}

		$group = affwp_payout_batch_as_group( $batch_id );

		if ( as_has_scheduled_action( 'affwp_maybe_finalize_payout_batch', array( $batch_id ), $group ) ) {
			continue;
		}

		as_schedule_single_action(
			time(),
			'affwp_maybe_finalize_payout_batch',
			array( $batch_id ),
			$group
		);

		affiliate_wp()->utils->log( sprintf( 'Payout batch watchdog: re-armed finalize action for stuck batch %d.', $batch_id ) );
	}
}
add_action( 'affwp_payout_batch_watchdog', 'affwp_as_payout_batch_watchdog' );
