<?php
/**
 * Batch processor for generating per-affiliate payouts across mixed methods.
 *
 * Replaces the old CSV-export-derived `Generate_Payouts` processor. That
 * class conflated "export a list to CSV" with "process N payouts", which
 * created three bugs: premature batch termination when an in-system
 * method returned empty CSV data, a success banner that read pre-recount
 * totals (always "0 payouts, 0.00"), and a CSV that shipped for every
 * method regardless of whether admin needed it.
 *
 * Loop anchor here is "affiliates remaining in the compiled candidate
 * list" — not "CSV rows produced". The batch row in
 * affiliate_wp_payout_batches is the source of truth; the CSV is a side
 * effect that only fires for the legacy manual method.
 *
 * The Pay Affiliates form submits to the async path: `enqueue_async_batch()`
 * pre-queues one Action Scheduler action per affiliate, and AS workers call
 * `process_affiliate_from_args()` for each. The batch row updates live for
 * the polling progress UI. The legacy sync `process_step()` path remains in
 * the class for backwards compatibility with any external integration that
 * may still invoke the `generate-payouts` batch processor directly, but no
 * in-plugin UI route uses it.
 *
 * @package    AffiliateWP
 * @subpackage Payouts
 * @since      2.33.0
 */

namespace AffWP\Payouts;

use AffWP\Utils\Batch_Process as Batch;

#[\AllowDynamicProperties]

/**
 * Multi-method payout batch processor.
 *
 * @since 2.33.0
 *
 * @see \AffWP\Utils\Batch_Process
 * @see \AffWP\Utils\Batch_Process\With_PreFetch
 */
class Batch_Payout_Processor extends \AffWP\Utils\Batch_Process implements Batch\With_PreFetch {

	/**
	 * Batch processor ID. Kept as `generate-payouts` so existing form submits
	 * continue to route here without a client-side change.
	 *
	 * @since 2.33.0
	 * @var string
	 */
	public $batch_id = 'generate-payouts';

	/**
	 * Capability required to run this processor.
	 *
	 * @since 2.33.0
	 * @var string
	 */
	public $capability = 'export_affiliate_data';

	/**
	 * One affiliate per step. Only relevant to the legacy sync `process_step()`
	 * path; the async submission flow processes one affiliate per Action
	 * Scheduler action regardless of this value.
	 *
	 * @since 2.33.0
	 * @var int
	 */
	public $per_step = 1;

	/**
	 * Export-type marker used only by the CSV download filename. CSV is a
	 * legacy side-effect for the manual method — see `write_csv_row()`.
	 *
	 * @since 2.33.0
	 * @var string
	 */
	public $export_type = 'payouts';

	/**
	 * Whether the CSV side-effect has no rows. Hydrated from the data store
	 * on every instance so the 'done' step (a fresh instance) reads the
	 * same value the step that wrote the CSV row set. The AJAX handler
	 * reads this public property to decide whether to offer a download URL.
	 *
	 * @since 2.33.0
	 * @var bool
	 */
	public $is_empty = true;

	/**
	 * Optional affiliate filter from the preview form.
	 *
	 * Either an int (legacy single-affiliate `user_name` field — 0 means "no filter") or an
	 * array of int IDs (multi-select `include_affiliates[]` from the 2.33.0+ form, expanded
	 * with any `include_groups[]` members). build_candidate_list() passes this through to
	 * get_referrals() unchanged, which handles both shapes via `WHERE affiliate_id IN (...)`.
	 *
	 * Left untyped (vs. the sibling `array $date`, `float $min_amount`, etc.) precisely because
	 * the union `int|int[]` isn't expressible as a PHP 8.1 strict type. When a future cleanup
	 * normalizes this to always be `array<int>` it should also get the type declaration; until
	 * then the explicit `= 0` initializer mirrors the typed-property pattern so an unmarshalled
	 * instance (when init() bails on null $data) is never uninitialized.
	 *
	 * @since 2.33.0
	 * @var int|int[]
	 */
	protected $affiliate_id = 0;

	/**
	 * Start/end dates from the preview form.
	 *
	 * @since 2.33.0
	 * @var array{start?: string, end?: string}
	 */
	protected array $date = array();

	/**
	 * Minimum per-affiliate payout amount.
	 *
	 * @since 2.33.0
	 * @var float
	 */
	protected float $min_amount = 0.0;

	/**
	 * Include referrals still in the holding period.
	 *
	 * @since 2.33.0
	 * @var bool
	 */
	protected bool $bypass_holding = true;

	/**
	 * Batch-level method override from the Pay Affiliates form's "Pay everyone this batch via X"
	 * mode. When non-empty, every payout in this batch is recorded with this method, regardless
	 * of each affiliate's configured method. Empty = per-affiliate routing (the default).
	 *
	 * Crucially, this drives which side-effects fire: a Manual override means every row of the
	 * batch writes a CSV line (and `affwp_add_payout` records `payout_method='manual'`), while
	 * per-affiliate routing falls back to `affwp_get_affiliate_usable_payout_method($id)` and
	 * each row uses the affiliate's own method (or the site default).
	 *
	 * @since 2.33.0
	 * @var string
	 */
	protected string $payout_method_override = '';

	/**
	 * Method filter from the Pay Affiliates form's method chips ("only pay affiliates
	 * currently on these methods"). Affiliates are matched by their USABLE method, the
	 * same basis the preview uses, so the batch pays exactly the set the admin previewed.
	 * Distinct from the override above: this narrows WHO is paid, not HOW. Empty = no filter.
	 *
	 * @since 2.33.0
	 * @var string[]
	 */
	protected array $method_filter = array();

	/**
	 * Explicit referral-ID scope. When set, the batch pays only these referrals
	 * (intersected with the per-affiliate compute), rather than every unpaid referral
	 * matching the affiliate/date/method filters. Used by the Referrals bulk-pay drawer,
	 * which hand-picks a selection. Empty = no referral-level scope (the Pay Affiliates
	 * screen's filter-derived behavior).
	 *
	 * @since 2.33.0
	 * @var int[]
	 */
	protected array $referral_ids = array();

	/**
	 * Absolute path to the CSV side-effect file.
	 *
	 * @since 2.33.0
	 * @var string
	 */
	protected string $csv_file = '';

	/**
	 * Filename used for the Content-Disposition header on download.
	 *
	 * @since 2.33.0
	 * @var string
	 */
	public string $filename = '';

	/**
	 * Sets up the processor + CSV filename.
	 *
	 * @since 2.33.0
	 *
	 * @param int|string $step Step number or 'done'.
	 */
	public function __construct( $step = 1 ) {
		parent::__construct( $step );

		$this->filename = 'affiliate-wp-export-' . $this->export_type . '-' . gmdate( 'm-d-Y' ) . '.csv';

		$upload_dir     = wp_upload_dir();
		$base_dir       = $upload_dir['basedir'] ?? ABSPATH;
		$this->csv_file = trailingslashit( $base_dir ) . $this->filename;

		/*
		 * Hydrate from data store so cross-step state (written-by-prior-step)
		 * survives instance rebuilds. AJAX re-instantiates us per step. Note
		 * the data store stringifies values and collapses empty-strings to
		 * the default — so we store '1' when a row has been written and
		 * treat any other value as "still empty".
		 */
		$written = affiliate_wp()->utils->data->get( "{$this->batch_id}_csv_written", '' );
		$this->is_empty = '1' !== (string) $written;
	}

	/**
	 * Captures the preview-form filters passed by the AJAX step.
	 *
	 * @since 2.33.0
	 *
	 * @param array|null $data Form payload.
	 * @return void
	 */
	public function init( $data = null ) {

		if ( null === $data ) {
			return;
		}

		/*
		 * Affiliate filter input shapes, in priority order: include_affiliates[] plus
		 * include_groups[], then the legacy single user_name field. Stored as a single
		 * int when there's one ID, an array of ints when there are several, 0 = no filter.
		 */
		$multi_affiliates = array();

		if ( ! empty( $data['include_affiliates'] ) && is_array( $data['include_affiliates'] ) ) {
			foreach ( $data['include_affiliates'] as $aid ) {
				$aid = (int) $aid;
				if ( $aid > 0 ) {
					$multi_affiliates[] = $aid;
				}
			}
		}

		if ( ! empty( $data['include_groups'] ) && is_array( $data['include_groups'] ) ) {
			foreach ( $data['include_groups'] as $group_id ) {
				$group_id = (int) $group_id;
				if ( $group_id <= 0 ) {
					continue;
				}
				if ( 'affiliate-group' !== affiliate_wp()->groups->get_group_type( $group_id ) ) {
					continue;
				}
				foreach ( (array) affiliate_wp()->connections->get_connected( 'affiliate', 'group', $group_id ) as $member_id ) {
					if ( affwp_get_affiliate( (int) $member_id ) ) {
						$multi_affiliates[] = (int) $member_id;
					}
				}
			}
		}

		if ( ! empty( $multi_affiliates ) ) {
			$multi_affiliates = array_values( array_unique( $multi_affiliates ) );
			$this->affiliate_id = ( 1 === count( $multi_affiliates ) ) ? (int) $multi_affiliates[0] : $multi_affiliates;
		} elseif ( ! empty( $data['user_name'] ) ) {
			$affiliate = affwp_get_affiliate( $data['user_name'] );
			if ( $affiliate ) {
				$this->affiliate_id = (int) $affiliate->ID;
			}
		}

		if ( ! empty( $data['minimum'] ) ) {
			$this->min_amount = (float) affwp_sanitize_amount( $data['minimum'] );
		}

		if ( ! empty( $data['from'] ) ) {
			$this->date['start'] = sanitize_text_field( $data['from'] );
		}

		if ( ! empty( $data['to'] ) ) {
			$this->date['end'] = sanitize_text_field( $data['to'] );
		}

		$this->bypass_holding = (bool) ( $data['bypass_holding'] ?? true );

		/*
		 * Override mode: the form's singular `payout_method` means "pay everyone via X".
		 * Validated against the live registry, so a removed method falls back to
		 * per-affiliate routing rather than erroring.
		 */
		if ( ! empty( $data['payout_method'] ) ) {
			$candidate = strtolower( sanitize_key( $data['payout_method'] ) );
			if ( array_key_exists( $candidate, affwp_get_payout_methods() ) ) {
				$this->payout_method_override = $candidate;
			}
		}

		/*
		 * Method-filter chips ("only pay affiliates currently on these methods"), matched
		 * by usable method in build_candidate_list() on the same basis as the preview so
		 * the batch pays exactly the previewed set.
		 */
		if ( ! empty( $data['payout_methods'] ) && is_array( $data['payout_methods'] ) ) {
			$this->method_filter = array_values(
				array_filter(
					array_map(
						static function ( $method ) {
							return strtolower( sanitize_key( (string) $method ) );
						},
						$data['payout_methods']
					)
				)
			);
		}

		/*
		 * Explicit referral selection (Referrals bulk-pay drawer). Intersected with the
		 * per-affiliate compute so each worker pays only the selected referrals belonging
		 * to its affiliate. Empty = filter-derived behavior (Pay Affiliates screen).
		 */
		if ( ! empty( $data['referral_ids'] ) && is_array( $data['referral_ids'] ) ) {
			$this->referral_ids = array_values( array_filter( array_map( 'absint', $data['referral_ids'] ) ) );
		}
	}

	/**
	 * Compiles the candidate list on first invocation and sets total_count.
	 *
	 * Applies the same existing-payout filter the preview uses, so "preview
	 * showed 4" and "batch processed 4" never diverge. The old generator
	 * relied on `affwp_add_payout()` to silently strip duplicates inside
	 * each step, which made compiled totals disagree with actual payouts —
	 * shows up in CSV amounts that don't match payout row amounts.
	 *
	 * @since 2.33.0
	 *
	 * @return void
	 */
	public function pre_fetch() {

		$compiled = affiliate_wp()->utils->data->get( "{$this->batch_id}_compiled_data", false );

		if ( false !== $compiled ) {
			return;
		}

		$data = $this->build_candidate_list();

		affiliate_wp()->utils->data->write( "{$this->batch_id}_compiled_data", $data );

		$this->set_total_count( count( $data ) );
	}

	/**
	 * Processes one affiliate per step, finalizes the batch when exhausted.
	 *
	 * Offset-driven loop — 'done' fires when there are no more affiliates in
	 * the compiled list. The old CSV-derived processor returned 'done' any
	 * time a step produced an empty CSV row (e.g. for in-system methods
	 * like store_credit), silently skipping the remainder of the batch.
	 *
	 * @since 2.33.0
	 *
	 * @return int|string Next step number or 'done'.
	 */
	public function process_step() {

		$compiled      = $this->get_compiled_data();
		$affiliate_ids = array_keys( $compiled );
		$offset        = $this->get_offset();

		if ( ! isset( $affiliate_ids[ $offset ] ) ) {
			$this->finalize_batch();
			return 'done';
		}

		$affiliate_id = (int) $affiliate_ids[ $offset ];

		$this->process_affiliate( $affiliate_id, $compiled[ $affiliate_id ] );

		/*
		 * Roll up counts on the DB batch row after every step so any polling
		 * UI (Phase 4) sees live progress without waiting for finalize.
		 *
		 * Performance note: recount() does a SUM/COUNT scan over the
		 * batch's payouts, so per-step recount makes this sync path O(N²)
		 * for a batch with N affiliates. That's acceptable in 2.33.0 because
		 * the in-plugin UI route is async (see class docblock — submit form
		 * goes through enqueue_async_batch + per-affiliate AS workers, each
		 * worker recounts once via process_affiliate_from_args). The sync
		 * loop is kept callable for external integrations that still invoke
		 * the `generate-payouts` batch processor directly.
		 *
		 * If sync usage becomes hot, two options without breaking the
		 * polling UI: (a) increment row columns in-place via $wpdb->query
		 * rather than recount() — O(1) per step, but loses the rebuild-
		 * from-truth guarantee; (b) recount every K steps + on the last
		 * step. Don't drop the call without a replacement — the batch row
		 * is the central progress meter and tests + UI assume it's fresh.
		 */
		$payout_batch_id = $this->get_or_create_payout_batch_id();
		if ( $payout_batch_id ) {
			affiliate_wp()->affiliates->payout_batches->recount( $payout_batch_id );
		}

		$this->set_current_count( $offset + 1 );

		return ++$this->step;
	}

	/**
	 * Processes a single affiliate — core per-row work, invocable on its own.
	 *
	 * Phase 4 swaps the sync AJAX stepping for Action Scheduler by enqueueing
	 * one AS action per affiliate, each calling this method directly. Keeping
	 * it a discrete public method makes that migration a wiring change, not
	 * a refactor.
	 *
	 * @since 2.33.0
	 *
	 * @param int                                                   $affiliate_id Affiliate ID.
	 * @param array{amount: float, currency: string, referrals: array}|null $compiled Optional compiled entry.
	 *                                                                         When null, read from data store.
	 * @return int|false Created payout ID, or false on failure/skip.
	 */
	public function process_affiliate( int $affiliate_id, ?array $compiled = null ) {

		if ( null === $compiled ) {
			$store = $this->get_compiled_data();
			if ( ! array_key_exists( $affiliate_id, $store ) ) {
				return false;
			}
			$compiled = $store[ $affiliate_id ];
		}

		$payout_batch_id = $this->get_or_create_payout_batch_id();

		// Override wins; otherwise the affiliate's configured method (which itself falls back to the site default).
		$payout_method = '' !== $this->payout_method_override
			? $this->payout_method_override
			: affwp_get_affiliate_usable_payout_method( $affiliate_id );

		/**
		 * Filters the initial status assigned to a payout created by the batch processor.
		 *
		 * Sync methods (manual, store_credit) use 'paid'. Async methods (PayPal REST,
		 * Stripe) should return 'processing' so the payout stays pending until the
		 * provider confirms, and referrals stay 'unpaid' until then.
		 *
		 * @since 2.33.0
		 *
		 * @param string $status        Initial payout status. Default 'paid'.
		 * @param string $payout_method Payout method identifier.
		 * @param int    $affiliate_id  Affiliate ID.
		 * @param int    $batch_id      Payout batch ID.
		 */
		$initial_status = apply_filters( 'affwp_batch_payout_initial_status', 'paid', $payout_method, $affiliate_id, $payout_batch_id );

		/*
		 * Store Credit credits the balance off the affwp_set_referral_status hook inside
		 * affwp_add_payout(). Its own gate keys off the affiliate's resolved method, which
		 * would double-pay anyone whose pick is Store Credit when this payout routes
		 * elsewhere. Make the processor authoritative for the status writes: credit Store
		 * Credit only when this payout pays via it, then restore.
		 */
		$paying_store_credit = 'store_credit' === str_replace( '-', '_', (string) $payout_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'     => $compiled['referrals'],
				'payout_method' => $payout_method,
				'batch_id'      => $payout_batch_id,
				'status'        => $initial_status,
			)
		);

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

		/*
		 * Manual is the only method that still needs the CSV side-effect. Every
		 * integrated method (PayPal, Stripe, Store Credit) pays through its own
		 * code path — CSV rows for them would only ever be a double-pay
		 * footgun. See `affwp_csv_payout_methods` filter.
		 */
		if ( $payout_id && $this->should_write_csv_row( $payout_method ) ) {
			$this->write_csv_row( $affiliate_id, $compiled );
		}

		return $payout_id;
	}

	/**
	 * Finalizes the batch row (terminal status + totals) and fires the
	 * integration-level hook. Called once at offset-exhaustion, after every
	 * `process_affiliate()` has committed.
	 *
	 * @since 2.33.0
	 *
	 * @return void
	 */
	protected function finalize_batch(): void {

		$payout_batch_id = $this->get_or_create_payout_batch_id();

		if ( ! $payout_batch_id ) {
			return;
		}

		/*
		 * Final authoritative recount. Also roll up terminal status — see
		 * class-payout-batches-db.php::recount() for the status transitions.
		 */
		affiliate_wp()->affiliates->payout_batches->recount( $payout_batch_id );

		/**
		 * Fires once at the end of the batch, after all payouts have been created
		 * and totals recounted.
		 *
		 * Method integrations hook this to submit their async providers (PayPal,
		 * Stripe) with the full set of method-matched payouts in one API call
		 * and schedule any verification work.
		 *
		 * @since 2.33.0
		 *
		 * @param int $payout_batch_id Payout batch ID.
		 */
		do_action( 'affwp_batch_generate_payouts_completed', $payout_batch_id );
	}

	/**
	 * Returns the DB batch ID for this run, creating it on first call.
	 *
	 * Cached in the ephemeral data store so every step of a run points at
	 * the same batch row. The stored value survives across AJAX steps
	 * because each request re-instantiates the processor but reads the
	 * same data store.
	 *
	 * @since 2.33.0
	 *
	 * @return int
	 */
	public function get_or_create_payout_batch_id(): int {

		$stored = (int) affiliate_wp()->utils->data->get( "{$this->batch_id}_payout_batch_id", 0 );

		if ( $stored > 0 ) {
			return $stored;
		}

		$filters = array(
			'affiliate_id'   => $this->affiliate_id,
			'date'           => $this->date,
			'min_amount'     => $this->min_amount,
			'bypass_holding' => $this->bypass_holding,
			'method_filter'  => $this->method_filter,
		);

		/*
		 * Legacy sync stepping path — resolve the initial method from the
		 * compiled data store (same candidate list the AS path reads). Falls
		 * through to 'mixed' when the store isn't populated yet (first call
		 * from an uninitialized processor).
		 */
		$compiled       = $this->get_compiled_data();
		$initial_method = ! empty( $compiled )
			? affwp_resolve_batch_payout_method( array_keys( $compiled ) )
			: 'mixed';

		$new_id = affwp_add_payout_batch(
			array(
				'created_by'    => get_current_user_id(),
				'payout_method' => $initial_method,
				'status'        => 'processing',
				'meta'          => array( 'filters' => $filters ),
			)
		);

		if ( ! $new_id ) {
			return 0;
		}

		affiliate_wp()->utils->data->write( "{$this->batch_id}_payout_batch_id", $new_id );

		return (int) $new_id;
	}

	/**
	 * Reads the compiled candidate list written by `pre_fetch()`.
	 *
	 * @since 2.33.0
	 *
	 * @return array<int, array{amount: float, currency: string, referrals: array}>
	 */
	public function get_compiled_data(): array {
		$stored = affiliate_wp()->utils->data->get( "{$this->batch_id}_compiled_data", array() );
		return is_array( $stored ) ? $stored : array();
	}

	/**
	 * Decides whether a given method gets a row in the CSV side-effect file.
	 *
	 * Default: only 'manual'. Inverts the legacy
	 * `affwp_in_system_payout_methods` logic, which defaulted every method
	 * ON for CSV except store_credit — resulting in PayPal / Stripe rows
	 * that admins could (and did) upload to PayPal MassPay, double-paying
	 * affiliates who already received an API-driven payment.
	 *
	 * @since 2.33.0
	 *
	 * @param string $payout_method Method identifier.
	 * @return bool
	 */
	protected function should_write_csv_row( string $payout_method ): bool {

		/**
		 * Filters the list of payout methods that appear in the CSV side-effect.
		 *
		 * The CSV is intended for admins who pay out manually (e.g. wire
		 * transfer, external bank upload). Methods integrated with a provider
		 * API should NOT appear, or admin risks double-paying.
		 *
		 * @since 2.33.0
		 *
		 * @param string[] $methods Method identifiers that get a CSV row. Default: ['manual'].
		 */
		$csv_methods = (array) apply_filters( 'affwp_csv_payout_methods', array( 'manual' ) );

		return in_array( $payout_method, $csv_methods, true );
	}

	/**
	 * Appends one row to the CSV side-effect file. Writes the header + unlinks
	 * any prior file on the first row of this run.
	 *
	 * @since 2.33.0
	 *
	 * @param int                                                     $affiliate_id Affiliate ID.
	 * @param array{amount: float, currency: string, referrals: array} $compiled     Compiled entry for this affiliate.
	 * @return void
	 */
	protected function write_csv_row( int $affiliate_id, array $compiled ): void {

		if ( $this->is_empty ) {
			// First write of this run: writing without FILE_APPEND truncates, so the header starts a fresh file.
			if ( false === file_put_contents( $this->csv_file, $this->csv_header() ) ) {
				affiliate_wp()->utils->logs->log(
					sprintf( 'Batch payouts: failed to write CSV header to %s (batch %s, affiliate %d).', $this->csv_file, $this->batch_id, $affiliate_id )
				);
				return;
			}
			$this->is_empty = false;
			affiliate_wp()->utils->data->write( "{$this->batch_id}_csv_written", '1' );
		}

		/**
		 * Filters the data retrieved for a single generated payout during batch processing.
		 *
		 * @since 2.0.2
		 *
		 * @param array $data {
		 *     Payout data.
		 *
		 *     @type string $email    Affiliate payment email.
		 *     @type float  $amount   Payout amount.
		 *     @type string $currency Payout currency.
		 * }
		 * @param int   $affiliate_id Current affiliate ID.
		 * @param array $compiled     Compiled entry for this affiliate.
		 */
		$row = apply_filters(
			'affwp_batch_generate_payouts_get_data',
			array(
				'email'    => affwp_get_affiliate_payment_email( $affiliate_id ),
				'amount'   => affwp_format_amount( $compiled['amount'] ),
				'currency' => $compiled['currency'],
			),
			$affiliate_id,
			array( $affiliate_id => $compiled )
		);

		$cols      = $this->csv_cols();
		$line_data = '';
		$i         = 1;

		foreach ( $row as $col_id => $value ) {
			if ( ! array_key_exists( $col_id, $cols ) ) {
				continue;
			}
			$line_data .= '"' . addslashes( preg_replace( '/"/', "'", (string) $value ) ) . '"';
			$line_data .= $i === count( $cols ) ? '' : ',';
			$i++;
		}
		$line_data .= "\r\n";

		if ( false === file_put_contents( $this->csv_file, $line_data, FILE_APPEND ) ) {
			affiliate_wp()->utils->logs->log(
				sprintf( 'Batch payouts: failed to append CSV row to %s (batch %s, affiliate %d).', $this->csv_file, $this->batch_id, $affiliate_id )
			);
		}
	}

	/**
	 * Returns the CSV column header row.
	 *
	 * @since 2.33.0
	 *
	 * @return string
	 */
	protected function csv_header(): string {
		$cols = $this->csv_cols();
		$row  = '';
		$i    = 1;
		foreach ( $cols as $label ) {
			$row .= '"' . addslashes( $label ) . '"';
			$row .= $i === count( $cols ) ? '' : ',';
			$i++;
		}
		return $row . "\r\n";
	}

	/**
	 * Returns the CSV column set.
	 *
	 * @since 2.33.0
	 *
	 * @return array<string, string>
	 */
	public function csv_cols(): array {

		/**
		 * Filters the list of CSV columns used when generating payout logs.
		 *
		 * @since 2.0.2
		 *
		 * @param array $columns CSV columns. Default 'email', 'amount', 'currency'.
		 */
		return (array) apply_filters(
			'affwp_batch_generate_payouts_csv_cols',
			array(
				'email'    => __( 'Email', 'affiliate-wp' ),
				'amount'   => __( 'Amount', 'affiliate-wp' ),
				'currency' => __( 'Currency', 'affiliate-wp' ),
			)
		);
	}

	/**
	 * Caps completion percentage by known total.
	 *
	 * @since 2.33.0
	 *
	 * @return float
	 */
	public function get_percentage_complete() {
		$current = (int) $this->get_current_count();
		$total   = (int) $this->get_total_count();
		if ( $total <= 0 ) {
			return 0.0;
		}
		return min( 100.0, ( $current / $total ) * 100 );
	}

	/**
	 * Renders the final-state banner message, reading the already-finalized
	 * batch row. Called by the AJAX handler on 'done' — after
	 * `process_step()` has finalized via `finalize_batch()`, so totals are
	 * authoritative.
	 *
	 * @since 2.33.0
	 *
	 * @param string $code Message code.
	 * @return string
	 */
	public function get_message( $code ) {

		if ( 'done' !== $code ) {
			return '';
		}

		$payout_batch_id = (int) affiliate_wp()->utils->data->get( "{$this->batch_id}_payout_batch_id", 0 );
		$batch           = $payout_batch_id ? affwp_get_payout_batch( $payout_batch_id ) : null;

		if ( ! $batch || (int) $batch->total_count === 0 ) {
			return __( 'No unpaid referrals were found matching your criteria.', 'affiliate-wp' );
		}

		$total    = (int) $batch->total_count;
		$paid     = (int) $batch->processed_count;
		$failed   = (int) $batch->failed_count;
		$amount   = affwp_currency_filter( affwp_format_amount( (float) $batch->total_amount ) );
		$batch_no = (int) $batch->batch_id;

		/*
		 * Phase 4 will render a richer summary view from the same batch row;
		 * this banner is the sync fallback until that lands.
		 */
		if ( $failed === 0 ) {
			$message = sprintf(
				/* translators: 1: batch id, 2: affiliate count, 3: formatted total amount */
				_n(
					'Batch #%1$d processed — %2$s payout created totalling %3$s.',
					'Batch #%1$d processed — %2$s payouts created totalling %3$s.',
					$total,
					'affiliate-wp'
				),
				$batch_no,
				number_format_i18n( $total ),
				$amount
			);
		} else {
			$message = sprintf(
				/* translators: 1: batch id, 2: paid count, 3: failed count, 4: total amount */
				__( 'Batch #%1$d processed — %2$s paid · %3$s failed · %4$s sent.', 'affiliate-wp' ),
				$batch_no,
				number_format_i18n( $paid ),
				number_format_i18n( $failed ),
				$amount
			);
		}

		if ( ! $this->is_empty ) {
			$message .= ' ' . __( 'A CSV of manual-method payouts is available for download.', 'affiliate-wp' );
		}

		return $message;
	}

	/**
	 * Live status payload for the polling progress UI (Phase 4 AJAX
	 * endpoint). Reads the batch row + per-payout snapshot so the UI can
	 * render determinate progress + per-row dots without a second query.
	 *
	 * @since 2.33.0
	 *
	 * @return array<string, mixed>
	 */
	public function get_status(): array {

		$payout_batch_id = $this->get_or_create_payout_batch_id();
		$batch           = $payout_batch_id ? affwp_get_payout_batch( $payout_batch_id ) : null;

		if ( ! $batch ) {
			return array(
				'batch_id'   => 0,
				'status'     => 'processing',
				'total'      => 0,
				'processed'  => 0,
				'failed'     => 0,
				'total_amount' => 0.0,
				'percentage' => 0.0,
				'rows'       => array(),
			);
		}

		$payouts = affiliate_wp()->affiliates->payouts->get_payouts(
			array(
				'number'   => -1,
				'batch_id' => $payout_batch_id,
				'status'   => array( 'paid', 'failed', 'processing' ),
			)
		);

		$rows = array();
		foreach ( (array) $payouts as $payout ) {
			$rows[] = array(
				'payout_id'    => (int) $payout->payout_id,
				'affiliate_id' => (int) $payout->affiliate_id,
				'amount'       => (float) $payout->amount,
				'method'       => $payout->payout_method,
				'status'       => $payout->status,
				'description'  => $payout->description,
			);
		}

		return array(
			'batch_id'     => (int) $batch->batch_id,
			'status'       => $batch->status,
			'total'        => (int) $batch->total_count,
			'processed'    => (int) $batch->processed_count,
			'failed'       => (int) $batch->failed_count,
			'total_amount' => (float) $batch->total_amount,
			'percentage'   => $this->get_percentage_complete(),
			'rows'         => $rows,
		);
	}

	/**
	 * Gate for the AJAX handler's "offer download URL" branch.
	 *
	 * @since 2.33.0
	 *
	 * @return bool
	 */
	public function can_export(): bool {
		/**
		 * Filters the capability required to export payouts.
		 *
		 * @since 1.0
		 *
		 * @param string $capability Capability.
		 */
		return (bool) current_user_can( apply_filters( 'affwp_export_capability', $this->capability ) );
	}

	/**
	 * Streams the CSV side-effect file to the browser on download request.
	 *
	 * Called by `affwp_process_batch_export_download()` in tools.php.
	 *
	 * @since 2.33.0
	 *
	 * @return void
	 */
	public function export(): void {

		if ( ! $this->can_export() ) {
			wp_die(
				esc_html__( 'You do not have permission to export data.', 'affiliate-wp' ),
				esc_html__( 'Error', 'affiliate-wp' ),
				array( 'response' => 403 )
			);
		}

		ignore_user_abort( true );
		if ( ! affwp_is_func_disabled( 'set_time_limit' ) ) {
			set_time_limit( 0 );
		}

		nocache_headers();
		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename=' . $this->filename );
		header( 'Expires: 0' );

		if ( ! file_exists( $this->csv_file ) ) {
			wp_die(
				esc_html__( 'The export file is missing. The batch may have failed to write or has already been downloaded.', 'affiliate-wp' ),
				esc_html__( 'Error', 'affiliate-wp' ),
				array( 'response' => 404 )
			);
		}

		$contents = file_get_contents( $this->csv_file );

		if ( false === $contents ) {
			affiliate_wp()->utils->logs->log(
				sprintf( 'Batch payouts: failed to read export file %s for download.', $this->csv_file )
			);
			wp_die(
				esc_html__( 'The export file could not be read. Check filesystem permissions on the uploads directory.', 'affiliate-wp' ),
				esc_html__( 'Error', 'affiliate-wp' ),
				array( 'response' => 500 )
			);
		}

		if ( ! unlink( $this->csv_file ) ) {
			affiliate_wp()->utils->logs->log(
				sprintf( 'Batch payouts: failed to remove export file %s after download.', $this->csv_file )
			);
		}

		echo $contents; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw CSV stream.
		exit;
	}

	/**
	 * Clears this run's ephemeral data store keys. Called by the AJAX
	 * handler after `get_message('done')` — the batch row has been
	 * finalized by `process_step()`, so it's safe to drop the processor
	 * state here.
	 *
	 * @since 2.33.0
	 *
	 * @param string $batch_id Batch processor ID.
	 * @return void
	 */
	public function finish( $batch_id ) {
		affiliate_wp()->utils->data->delete_by_match( "^{$batch_id}[0-9a-z\_]+" );
	}

	/**
	 * Async entry point — compiles the candidate list, creates the DB batch
	 * row, and enqueues one Action Scheduler action per affiliate (+ a
	 * finalize checker). Returns the batch_id so the caller can redirect
	 * the admin to the batch drawer.
	 *
	 * The compiled entry for each affiliate (amount, currency, referrals)
	 * is passed as an AS action arg, so each worker has everything it
	 * needs without a separate data-store fetch. AS serializes action args
	 * to JSON in its own table — fine for the small per-affiliate payload.
	 *
	 * @since 2.33.0
	 *
	 * @param array $data Preview-form payload.
	 * @return int|\WP_Error Batch ID on success; WP_Error('empty_batch') when no candidates.
	 */
	public static function enqueue_async_batch( array $data ) {

		$processor = new self();
		$processor->init( $data );

		$compiled = $processor->build_candidate_list();

		if ( empty( $compiled ) ) {
			return new \WP_Error(
				'empty_batch',
				__( 'No unpaid referrals matched the specified criteria.', 'affiliate-wp' )
			);
		}

		/*
		 * Create the batch row up-front so the redirect can land the admin
		 * on a real batch drawer. Filters (in meta) are audit + future
		 * repeat-run fuel.
		 *
		 * payout_method_override is captured here too so each AS worker can
		 * reproduce the form's "Pay everyone via X" choice when it processes
		 * its affiliate — see hydrate_filters_for_affiliate() + the override
		 * gate in process_affiliate_from_args().
		 */
		$filters = array(
			'affiliate_id'           => $processor->affiliate_id,
			'date'                   => $processor->date,
			'min_amount'             => $processor->min_amount,
			'bypass_holding'         => $processor->bypass_holding,
			'payout_method_override' => $processor->payout_method_override,
			'method_filter'          => $processor->method_filter,
			'referral_ids'           => $processor->referral_ids,
		);

		/*
		 * Batch row's payout_method: when the form chose an override, that's
		 * the concrete method for the whole batch. Otherwise pre-resolve from
		 * the candidate list so the Summary card reads the concrete method
		 * ("Stripe") from the first processing frame, not "Mixed → Stripe"
		 * once finalize recounts.
		 */
		$initial_method = '' !== $processor->payout_method_override
			? $processor->payout_method_override
			: affwp_resolve_batch_payout_method( array_keys( $compiled ) );

		/*
		 * Seed total_count and total_amount from the compiled list so the drawer shows the
		 * previewed figures on the first frame instead of zeros. recount() overwrites both
		 * once workers run, correcting downward if a referral changed state before processing.
		 */
		$seed_amount = 0.0;
		foreach ( $compiled as $entry ) {
			$seed_amount += (float) ( $entry['amount'] ?? 0 );
		}

		$batch_id = affwp_add_payout_batch(
			array(
				'created_by'    => get_current_user_id(),
				'payout_method' => $initial_method,
				'status'        => 'processing',
				'total_count'   => count( $compiled ),
				'total_amount'  => $seed_amount,
				'meta'          => array( 'filters' => $filters ),
			)
		);

		if ( ! $batch_id ) {
			return new \WP_Error( 'batch_create_failed', __( 'Could not create the payout batch.', 'affiliate-wp' ) );
		}

		$group = affwp_payout_batch_as_group( $batch_id );

		/*
		 * AS args intentionally carry only $batch_id + $affiliate_id. The worker
		 * re-resolves the affiliate's candidate referrals from the batch's stored
		 * filter snapshot (in meta). Keeping action args tiny avoids the
		 * actionscheduler_actions.args length cliff on large-affiliate batches
		 * (a 5k-referral affiliate would otherwise serialize a 100KB+ JSON blob
		 * per row), and the re-resolve also picks up any referral state changes
		 * between enqueue and process.
		 */
		foreach ( $compiled as $affiliate_id => $entry ) {
			as_enqueue_async_action(
				'affwp_process_payout_affiliate',
				array( (int) $batch_id, (int) $affiliate_id ),
				$group
			);
		}

		/*
		 * Finalize checker — self-reschedules until all siblings drain, so
		 * `affwp_batch_generate_payouts_completed` fires exactly once.
		 * Delay gives the AS queue a chance to start draining.
		 */
		as_schedule_single_action(
			time() + 15,
			'affwp_maybe_finalize_payout_batch',
			array( (int) $batch_id ),
			$group
		);

		return (int) $batch_id;
	}

	/**
	 * Builds the candidate list WITHOUT stashing to the ephemeral data
	 * store. Used by the async path, which serializes each compiled entry
	 * into an AS action arg instead of relying on shared state.
	 *
	 * Mirrors `pre_fetch()`'s compilation logic one-to-one so the async
	 * and sync paths produce identical candidate sets for the same input.
	 *
	 * @since 2.33.0
	 *
	 * @return array<int, array{amount: float, currency: string, referrals: array}>
	 */
	protected function build_candidate_list(): array {

		$result = affwp_compute_eligible_payouts(
			array(
				'affiliate_id'        => $this->affiliate_id,
				'referral_id'         => ! empty( $this->referral_ids ) ? $this->referral_ids : false,
				'date'                => $this->date,
				'minimum'             => (float) $this->min_amount,
				'bypass_holding'      => (bool) $this->bypass_holding,
				'method_filter'       => $this->method_filter,
				'referral_shape'      => 'id',
				'include_currency'    => true,
				'args_filter'         => 'generate',
				'args_filter_context' => $this,
			)
		);

		return $result['payouts'];
	}

	/**
	 * AS worker — processes one affiliate's payout within the batch. Re-resolves
	 * the candidate referrals from the batch's stored filter snapshot rather than
	 * relying on a pre-compiled entry in the action args (see `enqueue_async_batch`
	 * for the rationale).
	 *
	 * @since 2.33.0
	 *
	 * @param int $batch_id     Payout batch ID (already exists).
	 * @param int $affiliate_id Affiliate ID.
	 * @return void
	 */
	public static function process_affiliate_from_args( int $batch_id, int $affiliate_id ): void {

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

		$batch = affwp_get_payout_batch( $batch_id );
		if ( ! $batch ) {
			return;
		}

		$meta    = is_string( $batch->meta ) && '' !== $batch->meta ? json_decode( $batch->meta, true ) : array();
		$filters = ( is_array( $meta ) && isset( $meta['filters'] ) && is_array( $meta['filters'] ) ) ? $meta['filters'] : array();

		$processor = new self();
		$processor->hydrate_filters_for_affiliate( $affiliate_id, $filters );

		$compiled = $processor->build_candidate_list();
		$entry    = $compiled[ $affiliate_id ] ?? null;

		if ( empty( $entry ) || empty( $entry['referrals'] ) ) {
			/*
			 * Nothing to pay — referrals were paid/rejected since enqueue, fell
			 * below min_amount, or no longer match the filter. Treat as success
			 * (no payout written) and let recount catch the batch up.
			 */
			affiliate_wp()->affiliates->payout_batches->recount( $batch_id );
			return;
		}

		/*
		 * Bypass `get_or_create_payout_batch_id()` — the AS path owns the
		 * batch row already, we pass its ID in directly.
		 */
		affiliate_wp()->utils->data->write( "{$processor->batch_id}_payout_batch_id", $batch_id );

		/*
		 * The "Pay everyone via X" override was persisted to batch meta at enqueue and is
		 * read back here so the worker can't silently fall back. An empty override resolves
		 * from the affiliate's own setting (which itself falls back to the site default).
		 */
		$payout_method = '' !== $processor->payout_method_override
			? $processor->payout_method_override
			: affwp_get_affiliate_usable_payout_method( $affiliate_id );

		/** This filter is documented in includes/payouts/class-batch-payout-processor.php */
		$initial_status = apply_filters( 'affwp_batch_payout_initial_status', 'paid', $payout_method, $affiliate_id, $batch_id );

		/*
		 * Mirror process_affiliate(): can_receive_store_credit() keys off the affiliate's
		 * resolved method, so leaving it unguarded would double-pay anyone whose pick is
		 * Store Credit when this payout routes elsewhere. Always register the filter so the
		 * processor stays authoritative for the status writes; it returns true only when
		 * this payout pays via Store Credit. Priority 99 matches the sync method.
		 */
		$paying_store_credit = 'store_credit' === str_replace( '-', '_', (string) $payout_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'     => $entry['referrals'],
				'payout_method' => $payout_method,
				'batch_id'      => $batch_id,
				'status'        => $initial_status,
				// Worker runs in cron context, so get_current_user_id() is 0; carry the batch creator through. Stays 0 for system-triggered batches (auto-retry, scheduled).
				'owner'         => (int) $batch->created_by,
			)
		);

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

		if ( $payout_id && $processor->should_write_csv_row( $payout_method ) ) {
			$processor->write_csv_row( $affiliate_id, $entry );
		}

		/*
		 * Live-update the batch row so the polling drawer sees progress
		 * after every AS worker, not only at finalize.
		 */
		affiliate_wp()->affiliates->payout_batches->recount( $batch_id );
	}

	/**
	 * Narrows the processor to a single affiliate and applies the date / min_amount /
	 * bypass_holding filters captured when the batch was enqueued. Used by the AS
	 * worker to reproduce the original candidate query for one affiliate.
	 *
	 * @since 2.33.0
	 *
	 * @param int   $affiliate_id Affiliate ID to narrow to.
	 * @param array $filters      Filter snapshot stored in batch meta.
	 * @return void
	 */
	private function hydrate_filters_for_affiliate( int $affiliate_id, array $filters ): void {

		$this->affiliate_id = $affiliate_id;

		if ( isset( $filters['date'] ) && is_array( $filters['date'] ) ) {
			$this->date = $filters['date'];
		}

		if ( isset( $filters['min_amount'] ) ) {
			$this->min_amount = (float) $filters['min_amount'];
		}

		if ( isset( $filters['bypass_holding'] ) ) {
			$this->bypass_holding = (bool) $filters['bypass_holding'];
		}

		if ( isset( $filters['method_filter'] ) && is_array( $filters['method_filter'] ) ) {
			$this->method_filter = array_values( array_filter( array_map( 'strval', $filters['method_filter'] ) ) );
		}

		if ( isset( $filters['referral_ids'] ) && is_array( $filters['referral_ids'] ) ) {
			$this->referral_ids = array_values( array_filter( array_map( 'absint', $filters['referral_ids'] ) ) );
		}

		/*
		 * Re-validate the override against the live registry: a method removed between
		 * enqueue and worker run falls back to per-affiliate routing rather than writing a
		 * payout row with a dead method.
		 */
		if ( ! empty( $filters['payout_method_override'] ) ) {
			$candidate = strtolower( sanitize_key( (string) $filters['payout_method_override'] ) );
			if ( array_key_exists( $candidate, affwp_get_payout_methods() ) ) {
				$this->payout_method_override = $candidate;
			}
		}
	}
}
