<?php
/**
 * Helper Functions
 *
 * @package     AffiliateWP Stripe Payouts
 * @subpackage  Functions
 * @copyright   Copyright (c) 2025, AffiliateWP, LLC
 * @license     GPL-2.0+
 * @since       2.29.0
 */

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

/**
 * Check if Stripe is connected for the admin
 *
 * @since 2.29.0
 * @param bool $check_toggle Optional. Whether to check if the feature toggle is enabled. Default true.
 * @return bool True if connected, false otherwise
 */
function affwp_stripe_payouts_is_admin_connected( $check_toggle = true ) {
	// Platform account is connected if we have API keys configured.
	return affwp_stripe_payouts_is_configured( $check_toggle );
}

/**
 * Forces newly-created Stripe payouts in the async batch flow to start in
 * the `processing` status instead of the default `paid`.
 *
 * Without this filter, `affwp_add_payout()` marks the payout row paid
 * synchronously while the AS worker is still queued, but no Stripe
 * Transfer has been created yet. Using `processing` keeps the payout
 * pending until `affwp_stripe_process_completed_batch()` actually calls
 * `Transfer::create()` and transitions it to `paid` or `failed`.
 *
 * Mirrors the PayPal equivalent
 * (AffiliateWP_PayPal_Payouts_Admin::filter_batch_initial_status).
 *
 * @since 2.33.0
 *
 * @param string $status        Initial payout status.
 * @param string $payout_method Method identifier.
 * @return string
 */
function affwp_stripe_filter_batch_initial_status( $status, $payout_method ) {

	if ( 'stripe' !== $payout_method ) {
		return $status;
	}

	if ( ! affwp_stripe_payouts_is_configured() || ! affwp_stripe_payouts_is_admin_connected() ) {
		return $status;
	}

	return 'processing';
}

/**
 * Processes every `processing` Stripe payout in a completed batch.
 *
 * Runs on `affwp_batch_generate_payouts_completed` after the per-affiliate
 * AS workers have created the payout rows. For each linked referral,
 * **delegates to `AffiliateWP_Stripe_Payouts_Processor::process_single_payout()`**
 * — the existing tested single-payout path that owns all Transfer::create
 * logic, status transitions, `stripe_transfer_id` + `_stripe_fee` meta,
 * and the `affwp_stripe_payout_success` action. Any future change to that
 * method flows through here automatically.
 *
 * This handler's job is purely orchestration:
 *   - gate on configuration,
 *   - loop the batch's processing Stripe payouts,
 *   - call `process_single_payout()` per referral,
 *   - aggregate outcomes into a payout-level status (`paid` / `failed`),
 *   - recount the batch.
 *
 * **Idempotent:** `process_single_payout()` refuses already-paid
 * referrals (returns WP_Error `invalid_referral`). Re-firing the hook
 * — finalize-check double-dispatch, manual replay — is safe.
 *
 * **AS-safe:** registered outside the `is_admin()` gate in init.php. The
 * singleton processor is instantiated unconditionally in class-stripe-payouts.php,
 * so it's always available in cron / AS contexts.
 *
 * @since 2.33.0
 *
 * @param int $batch_id Payout batch ID.
 * @return void
 */
function affwp_stripe_process_completed_batch( $batch_id ) {

	$batch_id = absint( $batch_id );

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

	if ( ! affwp_stripe_payouts_is_configured() || ! affwp_stripe_payouts_is_admin_connected() ) {
		return;
	}

	$payouts = affiliate_wp()->affiliates->payouts->get_payouts(
		array(
			'batch_id'      => $batch_id,
			'payout_method' => 'stripe',
			'status'        => 'processing',
			'number'        => -1,
		)
	);

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

	// Singleton instantiated at file load, so available in cron/Action Scheduler contexts.
	$processor = AffiliateWP_Stripe_Payouts_Processor::instance();

	foreach ( $payouts as $payout ) {
		$referral_ids  = array_filter( array_map( 'intval', explode( ',', $payout->referrals ) ) );
		$errors        = array();
		$first_code    = ''; // First non-idempotency error code (used by the classifier).
		$any_succeeded = false;

		foreach ( $referral_ids as $rid ) {

			$result = $processor->process_single_payout( $rid );

			if ( is_wp_error( $result ) ) {
				/*
				 * `invalid_referral` means the referral is already paid
				 * (idempotency reject from process_single_payout), not
				 * a real failure; don't count toward errors. Anything
				 * else is a genuine Transfer::create failure.
				 */
				if ( 'invalid_referral' === $result->get_error_code() ) {
					$any_succeeded = true;
					continue;
				}

				if ( '' === $first_code ) {
					$first_code = $result->get_error_code();
				}
				$errors[] = sprintf( 'Referral #%d: %s', $rid, $result->get_error_message() );
				continue;
			}

			if ( is_array( $result ) && ! empty( $result['success'] ) ) {
				$any_succeeded = true;
			}
		}

		/*
		 * Payout-level roll-up. Every referral succeeded → paid. Any
		 * errored → failed with the combined reasons stashed on the
		 * payout row so the drawer can surface them. Partial success
		 * is treated as failed too — the payout row represents "this
		 * affiliate's full amount didn't move", and partial recovery
		 * is a retry flow (admin re-runs the failed referrals).
		 */
		$new_status     = $any_succeeded && empty( $errors ) ? 'paid' : 'failed';
		$payout_updates = array( 'status' => $new_status );

		if ( ! empty( $errors ) ) {
			$combined_message              = implode( ' | ', $errors );
			$payout_updates['description']  = $combined_message;
			/*
			 * Tag the row with a machine-readable failure class so the
			 * drawer dispatch + auto-retry sweep + auto-email pipeline
			 * can act on it without re-parsing the description string.
			 * Cleared (set to NULL) on success.
			 */
			$payout_updates['failure_class'] = \AffWP\Payouts\Failure_Classifier::classify(
				'stripe',
				$first_code,
				$combined_message,
				$payout
			);
		} else {
			/*
			 * Defensive: ensure failure_class is cleared on success, in
			 * case this payout was previously failed and is now being
			 * re-processed (shouldn't happen via this path, but cheap
			 * insurance).
			 */
			$payout_updates['failure_class'] = null;
		}

		affiliate_wp()->affiliates->payouts->update( $payout->payout_id, $payout_updates, '', 'payout' );

		/*
		 * Auto-email dispatch. After the row is saved (so the dispatcher
		 * reads the just-persisted failure_class), trigger the email if
		 * the class is affiliate_action_required, the setting is on, and
		 * the per-affiliate cooldown isn't active. All gating lives in
		 * affwp_maybe_send_failure_email() so this call site stays small.
		 */
		if ( ! empty( $errors ) ) {
			affwp_maybe_send_failure_email( (int) $payout->payout_id );
		}
	}

	// Recount so the batch roll-up reflects the post-transfer state instead of staying 'processing'.
	affiliate_wp()->affiliates->payout_batches->recount( $batch_id );
}

/**
 * Registers Stripe as a payout method on the `affwp_payout_methods` filter.
 *
 * Pulled out of the admin class so the filter fires in every context —
 * WP-CLI, cron, AS workers, the Affiliate Area dropdown, and admin.
 * Previously the admin class's constructor owned this hook, which meant
 * the method list silently lacked Stripe outside admin requests (the new
 * AS-driven payout processor hits this directly).
 *
 * The label is always the plain brand name. Availability is tracked
 * separately via affwp_get_enabled_payout_methods(), not by mutating the
 * label, so there is no need for a disabled-state variant here.
 *
 * @since 2.33.0
 *
 * @param array $payout_methods Current payout methods map.
 * @return array Modified payout methods map.
 */
function affwp_stripe_register_payout_method( $payout_methods ) {

	$payout_methods['stripe'] = __( 'Stripe', 'affiliate-wp' );

	return $payout_methods;
}

/**
 * Check if a specific affiliate has connected their Stripe account
 *
 * @since 2.29.0
 * @param int $affiliate_id The affiliate ID.
 * @return bool True if connected, false otherwise
 */
function affwp_stripe_payouts_is_affiliate_connected( $affiliate_id ) {
	$user_id           = affwp_get_affiliate_user_id( $affiliate_id );
	$stripe_account_id = get_user_meta( $user_id, 'affwp_stripe_payouts_account_id', true );
	return ! empty( $stripe_account_id );
}

/**
 * Get the Stripe account ID for an affiliate
 *
 * @since 2.29.0
 * @param int $affiliate_id The affiliate ID.
 * @return string The Stripe account ID
 */
function affwp_stripe_payouts_get_affiliate_account_id( $affiliate_id ) {
	$user_id = affwp_get_affiliate_user_id( $affiliate_id );
	return get_user_meta( $user_id, 'affwp_stripe_payouts_account_id', true );
}

/**
 * Get the pending (onboarding-in-progress) Stripe account ID for an affiliate.
 *
 * Set when the Express account is created at connect, before Stripe onboarding is finished, and
 * cleared once onboarding completes (promoting it to the connected account ID) or the affiliate
 * disconnects. A non-empty value means the affiliate started but did not finish onboarding.
 *
 * @since 2.34.0
 * @param int $affiliate_id The affiliate ID.
 * @return string The pending Stripe account ID, or '' if none.
 */
function affwp_stripe_payouts_get_affiliate_pending_account_id( $affiliate_id ) {
	$user_id = affwp_get_affiliate_user_id( $affiliate_id );
	return get_user_meta( $user_id, 'affwp_stripe_payouts_pending_account_id', true );
}

/**
 * Store a one-time (flash) notice for the current user, shown once on the next affiliate-facing
 * page load and then cleared.
 *
 * Replaces passing the message text through the URL (`?stripe_notice=` / `?stripe_error=`), which
 * is user-editable and would let anyone craft an arbitrary on-screen message for an affiliate. The
 * text is held server-side in a short-lived per-user transient, so it can't be spoofed and never
 * appears in the address bar. Keyed by user (not surface), so it surfaces on whichever payout
 * settings screen the affiliate lands on (core Affiliate Area or Pro Portal).
 *
 * @since 2.34.0
 *
 * @param string $message Translated, display-ready message.
 * @param string $type    Notice type: 'info', 'success', or 'error'. Default 'info'.
 * @return void
 */
function affwp_stripe_payouts_set_flash_notice( $message, $type = 'info' ) {
	$user_id = get_current_user_id();
	$message = (string) $message;

	if ( ! $user_id || '' === $message ) {
		return;
	}

	set_transient(
		'affwp_stripe_payouts_flash_' . $user_id,
		array(
			'type'    => in_array( $type, array( 'info', 'success', 'error' ), true ) ? $type : 'info',
			'message' => $message,
		),
		5 * MINUTE_IN_SECONDS
	);
}

/**
 * Read and clear the current user's one-time Stripe flash notice.
 *
 * @since 2.34.0
 *
 * @return array|null { type: string, message: string }, or null when none is set.
 */
function affwp_stripe_payouts_get_flash_notice() {
	$user_id = get_current_user_id();
	if ( ! $user_id ) {
		return null;
	}

	$key    = 'affwp_stripe_payouts_flash_' . $user_id;
	$notice = get_transient( $key );

	if ( empty( $notice ) || ! is_array( $notice ) || empty( $notice['message'] ) ) {
		return null;
	}

	delete_transient( $key );

	return array(
		'type'    => isset( $notice['type'] ) ? (string) $notice['type'] : 'info',
		'message' => (string) $notice['message'],
	);
}

/**
 * Resolve the affiliate-facing payout settings URL for the surface in use.
 *
 * The Stripe connect flow (Stripe return/refresh links, post-connect redirects) lives in core and
 * defaults to the core Affiliate Area settings tab. When the Affiliate Portal addon is enabled it
 * replaces that surface and routes settings to its own URL, so core's
 * affwp_get_affiliate_area_page_url() returns the wrong destination (the `?tab=settings` page rather
 * than the Portal's settings view). Send affiliates back to the surface they actually use.
 *
 * @since 2.34.0
 *
 * @return string Settings URL for the active affiliate surface (Portal when enabled, else core).
 */
function affwp_stripe_payouts_get_settings_url() {
	if ( function_exists( 'affwp_is_portal_enabled' )
		&& affwp_is_portal_enabled()
		&& class_exists( '\AffiliateWP_Affiliate_Portal\Core\Components\Portal' )
	) {
		$portal_url = \AffiliateWP_Affiliate_Portal\Core\Components\Portal::get_page_url( 'settings' );
		if ( ! empty( $portal_url ) ) {
			return $portal_url;
		}
	}

	return affwp_get_affiliate_area_page_url( 'settings' );
}

/**
 * Get the admin Stripe account ID
 *
 * @since 2.29.0
 * @return string The Stripe account ID
 */
function affwp_stripe_payouts_get_admin_account_id() {
	return get_option( 'affwp_stripe_payouts_account_id', '' );
}

/**
 * Get the Stripe mode (test or live)
 *
 * @since 2.29.0
 * @return string The Stripe mode ('test' or 'live')
 */
function affwp_stripe_payouts_get_mode() {
	// Only check stripe_test_mode setting - single source of truth.
	$test_mode = affiliate_wp()->settings->get( 'stripe_test_mode', false );
	return $test_mode ? 'test' : 'live';
}

/**
 * Check if Stripe is in test/sandbox mode
 *
 * @since 2.29.0
 * @return bool True if in test mode, false if in live mode
 */
function affwp_stripe_payouts_is_test_mode() {
	return (bool) affiliate_wp()->settings->get( 'stripe_test_mode', false );
}

/**
 * Check if Stripe is in live/production mode
 *
 * @since 2.29.0
 * @return bool True if in live mode, false if in test mode
 */
function affwp_stripe_payouts_is_live_mode() {
	return ! affwp_stripe_payouts_is_test_mode();
}

/**
 * Get the Stripe client ID based on the current mode
 *
 * @since 2.29.0
 * @return string The Stripe client ID
 */
function affwp_stripe_payouts_get_client_id() {
	$mode              = affwp_stripe_payouts_get_mode();
	$client_id_setting = 'stripe_' . $mode . '_client_id';
	return affiliate_wp()->settings->get( $client_id_setting, '' );
}

/**
 * Get the Stripe secret key based on the current mode
 *
 * @since 2.29.0
 * @return string The Stripe secret key
 */
function affwp_stripe_payouts_get_secret_key() {
	$mode = affwp_stripe_payouts_get_mode();

	$secret_key_setting = 'stripe_' . $mode . '_secret_key';

	return affiliate_wp()->settings->get( $secret_key_setting, '' );
}

/**
 * Get the Stripe webhook secret
 *
 * @since 2.29.0
 * @return string The Stripe webhook secret
 */
function affwp_stripe_payouts_get_webhook_secret() {
	return affiliate_wp()->settings->get( 'stripe_webhook_secret', '' );
}

/**
 * Get the OAuth redirect URL for admin
 *
 * @since 2.29.0
 * @return string The OAuth redirect URL
 */
function affwp_stripe_payouts_get_admin_redirect_url() {
	return admin_url( 'admin.php?page=affiliate-wp-settings&tab=stripe_payouts&connect=1' );
}

/**
 * Determine whether a currency is a Stripe zero-decimal currency.
 *
 * Zero-decimal currencies (e.g. JPY, KRW) have no sub-unit, so amounts must be
 * sent to Stripe as-is rather than multiplied by 100. List per Stripe docs:
 * https://docs.stripe.com/currencies#zero-decimal
 *
 * Note: ISK and UGX are intentionally excluded. Although both "transitioned to
 * zero-decimal", Stripe still requires their amounts sent as two-decimal values
 * (multiplied by 100) for backward compatibility, so they are NOT zero-decimal
 * for API purposes. See https://docs.stripe.com/currencies#special-cases . This
 * matches the list used by Affiliate_WP_Stripe::is_zero_decimal().
 *
 * @since 2.29.0
 * @param string|null $currency Optional. Currency code. Defaults to the store currency.
 * @return bool True if the currency is zero-decimal, false otherwise.
 */
function affwp_stripe_payouts_is_zero_decimal_currency( $currency = null ) {
	if ( null === $currency ) {
		$currency = affwp_get_currency();
	}

	$zero_decimal = [ 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' ];

	return in_array( strtoupper( (string) $currency ), $zero_decimal, true );
}

/**
 * Format amount for Stripe (converts a decimal amount to Stripe's smallest unit)
 *
 * For most currencies this multiplies by 100 (dollars to cents). Zero-decimal
 * currencies (JPY, KRW, etc.) have no sub-unit and are sent as-is.
 *
 * @since 2.29.0
 * @param float       $amount   The amount in the currency's main unit.
 * @param string|null $currency Optional. Currency code. Defaults to the store currency.
 * @return int The amount in Stripe's smallest currency unit.
 */
function affwp_stripe_payouts_format_amount( $amount, $currency = null ) {
	if ( affwp_stripe_payouts_is_zero_decimal_currency( $currency ) ) {
		return round( $amount );
	}

	return round( $amount * 100 );
}

/**
 * Convert a Stripe smallest-unit amount back to a decimal amount.
 *
 * Inverse of affwp_stripe_payouts_format_amount(). For most currencies this
 * divides by 100; zero-decimal currencies are returned as-is.
 *
 * @since 2.29.0
 * @param int|float   $amount   The amount in Stripe's smallest currency unit.
 * @param string|null $currency Optional. Currency code. Defaults to the store currency.
 * @return float The amount in the currency's main unit.
 */
function affwp_stripe_payouts_unformat_amount( $amount, $currency = null ) {
	if ( affwp_stripe_payouts_is_zero_decimal_currency( $currency ) ) {
		return (float) $amount;
	}

	return $amount / 100;
}

/**
 * Log an error message
 *
 * @since 2.29.0
 * @param string $message The error message.
 * @param array  $data Additional data to log.
 * @return void
 */
function affwp_stripe_payouts_log_error( $message, $data = [] ) {
	// Logging temporarily disabled - will be re-enabled with admin setting in next release
	return;

	// Create logs directory if it doesn't exist
	$upload_dir = wp_upload_dir();
	$logs_dir   = trailingslashit( $upload_dir['basedir'] ) . 'affwp-stripe-payouts-logs';

	if ( ! file_exists( $logs_dir ) ) {
		wp_mkdir_p( $logs_dir );
	}

	// Create .htaccess file to protect logs
	$htaccess_file = trailingslashit( $logs_dir ) . '.htaccess';
	if ( ! file_exists( $htaccess_file ) ) {
		$htaccess_content = 'deny from all';
		@file_put_contents( $htaccess_file, $htaccess_content );
	}

	// Create index.php file to prevent directory listing
	$index_file = trailingslashit( $logs_dir ) . 'index.php';
	if ( ! file_exists( $index_file ) ) {
		$index_content = '<?php // Silence is golden';
		@file_put_contents( $index_file, $index_content );
	}

	// Format the log entry
	$log_entry = '[' . date_i18n( 'Y-m-d H:i:s' ) . '] ' . $message . "\n";

	if ( ! empty( $data ) ) {
		$log_entry .= print_r( $data, true ) . "\n";
	}

	// Write to log file
	$log_file = trailingslashit( $logs_dir ) . 'stripe-payouts-' . date_i18n( 'Y-m-d' ) . '.log';
	@file_put_contents( $log_file, $log_entry, FILE_APPEND );
}

/**
 * Log balance state before payout attempt
 *
 * @since 2.29.0
 * @param float  $payout_amount Amount attempting to payout
 * @param string $currency Currency code
 * @return void
 */
function affwp_stripe_payouts_log_balance_state( $payout_amount, $currency = 'usd' ) {
	$balance = affwp_stripe_payouts_check_platform_balance();

	if ( is_wp_error( $balance ) ) {
		affwp_stripe_payouts_log_error(
			'Failed to retrieve balance state before payout',
			[ 'error' => $balance->get_error_message() ]
		);
		return;
	}

	$available_balance = 0;
	if ( isset( $balance['available'] ) && is_array( $balance['available'] ) ) {
		foreach ( $balance['available'] as $balance_item ) {
			if ( isset( $balance_item['currency'] ) && $balance_item['currency'] === strtolower( $currency ) ) {
				$available_balance = isset( $balance_item['amount'] ) ? affwp_stripe_payouts_unformat_amount( $balance_item['amount'], $currency ) : 0;
				break;
			}
		}
	}

	$log_data = [
		'requested_payout'  => $payout_amount,
		'available_balance' => $available_balance,
		'currency'          => $currency,
		'sufficient_funds'  => ( $payout_amount <= $available_balance ),
		'timestamp'         => current_time( 'mysql' ),
	];

	affwp_stripe_payouts_log_info(
		sprintf(
			'Balance check: Requested %s %s, Available %s %s',
			$payout_amount,
			strtoupper( $currency ),
			$available_balance,
			strtoupper( $currency )
		),
		$log_data
	);
}

/**
 * Log payment intent association with referral
 *
 * @since 2.29.0
 * @param int    $referral_id Referral ID
 * @param string $payment_intent_id Payment intent ID from Stripe
 * @param float  $amount Amount of the payment
 * @return void
 */
function affwp_stripe_payouts_log_payment_association( $referral_id, $payment_intent_id, $amount ) {
	$log_data = [
		'referral_id'       => $referral_id,
		'payment_intent_id' => $payment_intent_id,
		'amount'            => $amount,
		'timestamp'         => current_time( 'mysql' ),
	];

	affwp_stripe_payouts_log_info(
		sprintf(
			'Payment intent %s associated with referral #%d for amount %s',
			$payment_intent_id,
			$referral_id,
			affwp_currency_filter( $amount )
		),
		$log_data
	);
}

/**
 * Log Stripe Payouts info messages
 *
 * @since 2.29.0
 * @param string $message The info message to log
 * @param array  $data    Optional additional data to log
 * @return void
 */
function affwp_stripe_payouts_log_info( $message, $data = [] ) {
	// Logging temporarily disabled - will be re-enabled with admin setting in next release
	return;

	// Only log if debug mode is enabled
	if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
		affwp_stripe_payouts_log_error( '[INFO] ' . $message, $data );
	}
}

/**
 * Initialize the Stripe API
 *
 * @since 2.29.0
 * @param string|null $api_key Optional API key to use. If null, uses the saved setting.
 * @return bool True if API was initialized, false otherwise.
 */
function affwp_stripe_payouts_init_api( $api_key = null ) {
	// Check if the Stripe PHP SDK is already loaded
	if ( ! class_exists( '\Stripe\Stripe' ) ) {
		// If not loaded, require the Composer autoloader
		$autoloader = AFFILIATEWP_PLUGIN_DIR . 'vendor/autoload.php';

		if ( file_exists( $autoloader ) ) {
			require_once $autoloader;
		} else {
			// Log an error if the autoloader is not found
			affwp_stripe_payouts_log_error( 'Stripe PHP SDK not found. Please install via Composer.' );
			return false;
		}
	}

	// Use provided key if available, otherwise fetch from settings
	$key_to_use = $api_key ? $api_key : affwp_stripe_payouts_get_secret_key();

	if ( empty( $key_to_use ) ) {
		affwp_stripe_payouts_log_error( 'Stripe API key is empty, cannot initialize API.' );
		return false;
	}

	// Set the API key
	\Stripe\Stripe::setApiKey( $key_to_use );
	return true;
}

/**
 * Check if required Stripe settings are configured
 *
 * @since 2.29.0
 * @param bool $check_toggle Optional. Whether to check if the feature toggle is enabled. Default true.
 * @return bool True if configured, false otherwise
 */
function affwp_stripe_payouts_is_configured( $check_toggle = true ) {
	// Check if Stripe payouts are enabled (unless bypassed).
	if ( $check_toggle ) {
		$is_enabled = affiliate_wp()->settings->get( 'stripe_payouts', false );
		if ( ! $is_enabled ) {
			return false;
		}
	}

	// Get the secret key for the current mode (test or live)
	$secret_key = affwp_stripe_payouts_get_secret_key();

	// Configuration requires a secret key for the active mode (and optionally the toggle to be on)
	return ! empty( $secret_key );
}

/**
 * Generate an Account Link for an affiliate to manage their Stripe Express account
 *
 * @since 2.29.0
 * @param int $affiliate_id The affiliate ID
 * @return string|WP_Error The Account Link URL or WP_Error on failure
 */
function affwp_stripe_payouts_generate_account_link( $affiliate_id ) {
	// Check if affiliate exists
	if ( ! affiliate_wp()->affiliates->get_by( 'affiliate_id', $affiliate_id ) ) {
		return new WP_Error( 'invalid_affiliate', __( 'Invalid affiliate.', 'affiliate-wp' ) );
	}

	// Check if the current user is the affiliate or an admin
	$user_id = affwp_get_affiliate_user_id( $affiliate_id );

	if ( get_current_user_id() != $user_id && ! current_user_can( 'manage_affiliates' ) ) {
		return new WP_Error( 'permission_denied', __( 'You do not have permission to manage this affiliate account.', 'affiliate-wp' ) );
	}

	// Check if affiliate is connected to Stripe
	if ( ! affwp_stripe_payouts_is_affiliate_connected( $affiliate_id ) ) {
		return new WP_Error( 'not_connected', __( 'Affiliate is not connected to Stripe.', 'affiliate-wp' ) );
	}

	// Get the Stripe account ID
	$account_id = affwp_stripe_payouts_get_affiliate_account_id( $affiliate_id );

	if ( empty( $account_id ) ) {
		return new WP_Error( 'no_account_id', __( 'No Stripe account ID found for this affiliate.', 'affiliate-wp' ) );
	}

	// Check if Stripe is configured
	if ( ! affwp_stripe_payouts_is_configured() ) {
		return new WP_Error( 'not_configured', __( 'Stripe is not configured. Please contact the site administrator.', 'affiliate-wp' ) );
	}

	try {
		// Initialize Stripe API
		if ( ! affwp_stripe_payouts_init_api() ) {
			return new WP_Error( 'api_init_failed', __( 'Failed to initialize Stripe API.', 'affiliate-wp' ) );
		}

		// Clear any cached account status to ensure fresh data
		$cache_key = 'affwp_stripe_account_status_' . $account_id;
		delete_transient( $cache_key );

		// Retrieve the account to check its status
		$account = \Stripe\Account::retrieve( $account_id );

		// Log account status for debugging
		affwp_stripe_payouts_log_error(
			'Account status for ' . $account_id,
			[
				'details_submitted' => $account->details_submitted,
				'charges_enabled'   => $account->charges_enabled,
				'payouts_enabled'   => $account->payouts_enabled,
				'requirements'      => $account->requirements->toArray(),
			]
		);

		// Determine the appropriate Account Link type based on account status
		$link_type = $account->details_submitted ? 'account_update' : 'account_onboarding';

		affwp_stripe_payouts_log_error( 'Using link type: ' . $link_type . ' for account ' . $account_id );

		// Try to create account link with the determined type
		try {
			$account_link = \Stripe\AccountLink::create(
				[
					'account'     => $account_id,
					'refresh_url' => home_url(
						'/wp-json/affwp/v1/stripe/manage?' . http_build_query(
							[
								'affiliate_id' => $affiliate_id,
								'_wpnonce'     => wp_create_nonce( 'affwp_stripe_payouts_manage_' . $affiliate_id ),
							]
						)
					),
					'return_url'  => add_query_arg(
						[
							'stripe_manage_complete' => '1',
						],
						affwp_get_affiliate_area_page_url( 'settings' )
					),
					'type'        => $link_type,
				]
			);

			return $account_link->url;

		} catch ( \Stripe\Exception\InvalidRequestException $e ) {
			// If account_update fails, try account_onboarding as fallback
			if ( $link_type === 'account_update' && strpos( $e->getMessage(), 'account_update' ) !== false ) {
				affwp_stripe_payouts_log_error( 'account_update failed, trying account_onboarding for ' . $account_id );

				$account_link = \Stripe\AccountLink::create(
					[
						'account'     => $account_id,
						'refresh_url' => home_url(
							'/wp-json/affwp/v1/stripe/manage?' . http_build_query(
								[
									'affiliate_id' => $affiliate_id,
									'_wpnonce'     => wp_create_nonce( 'affwp_stripe_payouts_manage_' . $affiliate_id ),
								]
							)
						),
						'return_url'  => add_query_arg(
							[
								'stripe_manage_complete' => '1',
							],
							affwp_get_affiliate_area_page_url( 'settings' )
						),
						'type'        => 'account_onboarding',
					]
				);

				return $account_link->url;
			}

			// Re-throw if it's a different error
			throw $e;
		}
	} catch ( Exception $e ) {
		affwp_stripe_payouts_log_error( 'Error generating account link for affiliate ' . $affiliate_id . ': ' . $e->getMessage() );
		return new WP_Error( 'stripe_error', sprintf( __( 'Error generating account link: %s', 'affiliate-wp' ), $e->getMessage() ) );
	}
}

/**
 * Generate an Express Dashboard login link for an affiliate to manage their Stripe Express account
 *
 * This provides direct access to the Stripe Express Dashboard for daily management tasks
 * like viewing balances, updating bank information, and managing payouts.
 *
 * @since 2.29.0
 * @param int $affiliate_id The affiliate ID
 * @return string|WP_Error The Express Dashboard login URL or WP_Error on failure
 */
function affwp_stripe_payouts_generate_express_dashboard_link( $affiliate_id ) {
	// Check if affiliate exists
	if ( ! affiliate_wp()->affiliates->get_by( 'affiliate_id', $affiliate_id ) ) {
		return new WP_Error( 'invalid_affiliate', __( 'Invalid affiliate.', 'affiliate-wp' ) );
	}

	// Check if the current user is the affiliate or an admin
	$user_id = affwp_get_affiliate_user_id( $affiliate_id );

	if ( get_current_user_id() != $user_id && ! current_user_can( 'manage_affiliates' ) ) {
		return new WP_Error( 'permission_denied', __( 'You do not have permission to manage this affiliate account.', 'affiliate-wp' ) );
	}

	// Check if affiliate is connected to Stripe
	if ( ! affwp_stripe_payouts_is_affiliate_connected( $affiliate_id ) ) {
		return new WP_Error( 'not_connected', __( 'Affiliate is not connected to Stripe.', 'affiliate-wp' ) );
	}

	// Get the Stripe account ID
	$account_id = affwp_stripe_payouts_get_affiliate_account_id( $affiliate_id );

	if ( empty( $account_id ) ) {
		return new WP_Error( 'no_account_id', __( 'No Stripe account ID found for this affiliate.', 'affiliate-wp' ) );
	}

	// Check if Stripe is configured
	if ( ! affwp_stripe_payouts_is_configured() ) {
		return new WP_Error( 'not_configured', __( 'Stripe is not configured. Please contact the site administrator.', 'affiliate-wp' ) );
	}

	try {
		// Initialize Stripe API
		if ( ! affwp_stripe_payouts_init_api() ) {
			return new WP_Error( 'api_init_failed', __( 'Failed to initialize Stripe API.', 'affiliate-wp' ) );
		}

		// Create Express Dashboard login link
		// This provides direct access to the Stripe Express Dashboard
		$login_link = \Stripe\Account::createLoginLink( $account_id );

		affwp_stripe_payouts_log_error( 'Generated Express Dashboard login link for account ' . $account_id );

		return $login_link->url;

	} catch ( \Stripe\Exception\InvalidRequestException $e ) {
		// This error typically occurs if the account is not an Express account
		// or if the account hasn't completed onboarding
		affwp_stripe_payouts_log_error( 'Error generating Express Dashboard link for affiliate ' . $affiliate_id . ': ' . $e->getMessage() );

		// Check if it's because the account isn't Express or hasn't completed onboarding
		if ( strpos( $e->getMessage(), 'express' ) !== false || strpos( $e->getMessage(), 'login_link' ) !== false ) {
			return new WP_Error( 'not_express_account', __( 'This feature is only available for Express accounts that have completed onboarding.', 'affiliate-wp' ) );
		}

		return new WP_Error( 'stripe_error', sprintf( __( 'Error generating dashboard link: %s', 'affiliate-wp' ), $e->getMessage() ) );
	} catch ( Exception $e ) {
		affwp_stripe_payouts_log_error( 'Error generating Express Dashboard link for affiliate ' . $affiliate_id . ': ' . $e->getMessage() );
		return new WP_Error( 'stripe_error', sprintf( __( 'Error generating dashboard link: %s', 'affiliate-wp' ), $e->getMessage() ) );
	}
}

/**
 * Validate affiliate ID
 *
 * @since 2.29.0
 * @param mixed $affiliate_id
 * @return int|WP_Error
 */
function affwp_stripe_payouts_validate_affiliate_id( $affiliate_id ) {
	$affiliate_id = absint( $affiliate_id );

	if ( $affiliate_id <= 0 ) {
		return new WP_Error( 'invalid_affiliate_id', __( 'Invalid affiliate ID.', 'affiliate-wp' ) );
	}

	// Check if affiliate exists
	$affiliate = affwp_get_affiliate( $affiliate_id );
	if ( ! $affiliate ) {
		return new WP_Error( 'affiliate_not_found', __( 'Affiliate not found.', 'affiliate-wp' ) );
	}

	return $affiliate_id;
}

/**
 * Validate referral ID
 *
 * @since 2.29.0
 * @param mixed $referral_id
 * @return int|WP_Error
 */
function affwp_stripe_payouts_validate_referral_id( $referral_id ) {
	$referral_id = absint( $referral_id );

	if ( $referral_id <= 0 ) {
		return new WP_Error( 'invalid_referral_id', __( 'Invalid referral ID.', 'affiliate-wp' ) );
	}

	// Check if referral exists
	$referral = affwp_get_referral( $referral_id );
	if ( ! $referral ) {
		return new WP_Error( 'referral_not_found', __( 'Referral not found.', 'affiliate-wp' ) );
	}

	return $referral_id;
}

/**
 * Validate amount value
 *
 * @since 2.29.0
 * @param mixed $amount
 * @param float $min_amount Minimum allowed amount (default: 0.50)
 * @param float $max_amount Maximum allowed amount (default: 999999.99)
 * @return float|WP_Error
 */
function affwp_stripe_payouts_validate_amount( $amount, $min_amount = 0.50, $max_amount = 999999.99 ) {
	// Sanitize and convert to float
	$amount = floatval( $amount );

	if ( $amount < $min_amount ) {
		return new WP_Error( 'amount_too_small', sprintf( __( 'Amount must be at least %s.', 'affiliate-wp' ), affwp_currency_filter( $min_amount ) ) );
	}

	if ( $amount > $max_amount ) {
		return new WP_Error( 'amount_too_large', sprintf( __( 'Amount cannot exceed %s.', 'affiliate-wp' ), affwp_currency_filter( $max_amount ) ) );
	}

	return $amount;
}

/**
 * Validate email address
 *
 * @since 2.29.0
 * @param string $email
 * @return string|WP_Error
 */
function affwp_stripe_payouts_validate_email( $email ) {
	$email = sanitize_email( $email );

	if ( empty( $email ) ) {
		return new WP_Error( 'invalid_email', __( 'Email address is required.', 'affiliate-wp' ) );
	}

	if ( ! is_email( $email ) ) {
		return new WP_Error( 'invalid_email_format', __( 'Please enter a valid email address.', 'affiliate-wp' ) );
	}

	return $email;
}

/**
 * Validate URL
 *
 * @since 2.29.0
 * @param string $url
 * @param bool   $require_https Whether to require HTTPS (default: false)
 * @return string|WP_Error
 */
function affwp_stripe_payouts_validate_url( $url, $require_https = false ) {
	$url = esc_url_raw( $url );

	if ( empty( $url ) ) {
		return new WP_Error( 'invalid_url', __( 'URL is required.', 'affiliate-wp' ) );
	}

	if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
		return new WP_Error( 'invalid_url_format', __( 'Please enter a valid URL.', 'affiliate-wp' ) );
	}

	if ( $require_https && strpos( $url, 'https://' ) !== 0 ) {
		return new WP_Error( 'url_not_https', __( 'URL must use HTTPS.', 'affiliate-wp' ) );
	}

	return $url;
}

/**
 * Validate text field with length limits
 *
 * @since 2.29.0
 * @param string $text
 * @param int    $min_length Minimum length (default: 0)
 * @param int    $max_length Maximum length (default: 255)
 * @param bool   $allow_empty Whether to allow empty values (default: true)
 * @return string|WP_Error
 */
function affwp_stripe_payouts_validate_text_field( $text, $min_length = 0, $max_length = 255, $allow_empty = true ) {
	$text = sanitize_text_field( $text );

	if ( ! $allow_empty && empty( $text ) ) {
		return new WP_Error( 'field_required', __( 'This field is required.', 'affiliate-wp' ) );
	}

	$length = strlen( $text );

	if ( ! empty( $text ) && $length < $min_length ) {
		return new WP_Error( 'text_too_short', sprintf( __( 'Text must be at least %d characters long.', 'affiliate-wp' ), $min_length ) );
	}

	if ( $length > $max_length ) {
		return new WP_Error( 'text_too_long', sprintf( __( 'Text cannot exceed %d characters.', 'affiliate-wp' ), $max_length ) );
	}

	return $text;
}

/**
 * Check platform Stripe balance using MCP
 *
 * @since 2.29.0
 * @return array|WP_Error Balance data or error
 */
function affwp_stripe_payouts_check_platform_balance() {
	try {
		// Initialize Stripe API
		affwp_stripe_payouts_init_api();

		// Get the platform account balance
		$balance = \Stripe\Balance::retrieve();

		// Convert Stripe balance object to array format
		$formatted_balance = [
			'available' => [],
			'pending'   => [],
		];

		// Format available balances
		if ( ! empty( $balance->available ) ) {
			foreach ( $balance->available as $bal ) {
				$formatted_balance['available'][] = [
					'amount'   => $bal->amount,
					'currency' => $bal->currency,
				];
			}
		}

		// Format pending balances
		if ( ! empty( $balance->pending ) ) {
			foreach ( $balance->pending as $bal ) {
				$formatted_balance['pending'][] = [
					'amount'   => $bal->amount,
					'currency' => $bal->currency,
				];
			}
		}

		return $formatted_balance;

	} catch ( Exception $e ) {
		return new WP_Error( 'balance_check_failed', $e->getMessage() );
	}
}

/**
 * Verify platform has sufficient funds for payout
 *
 * @since 2.29.0
 * @param float  $amount Amount to payout in dollars
 * @param string $currency Currency code (default: usd)
 * @return bool|WP_Error True if sufficient funds, error otherwise
 */
function affwp_stripe_payouts_verify_sufficient_funds( $amount, $currency = 'usd' ) {
	$balance = affwp_stripe_payouts_check_platform_balance();

	if ( is_wp_error( $balance ) ) {
		return $balance;
	}

	// Convert amount to cents for comparison
	$amount_cents = affwp_stripe_payouts_format_amount( $amount );

	// Find the balance for the specified currency
	$available_balance = 0;
	if ( isset( $balance['available'] ) && is_array( $balance['available'] ) ) {
		foreach ( $balance['available'] as $balance_item ) {
			if ( isset( $balance_item['currency'] ) && $balance_item['currency'] === strtolower( $currency ) ) {
				$available_balance = isset( $balance_item['amount'] ) ? absint( $balance_item['amount'] ) : 0;
				break;
			}
		}
	}

	if ( $amount_cents > $available_balance ) {
		return new WP_Error(
			'insufficient_funds',
			sprintf(
				__( 'Insufficient funds. Required: %1$s, Available: %2$s', 'affiliate-wp' ),
				affwp_currency_filter( $amount ),
				affwp_currency_filter( affwp_stripe_payouts_unformat_amount( $available_balance, $currency ) )
			)
		);
	}

	return true;
}

/**
 * Get formatted balance display for admin
 *
 * @since 2.29.0
 * @return string HTML formatted balance display
 */
function affwp_stripe_payouts_get_balance_display() {
	$balance = affwp_stripe_payouts_check_platform_balance();

	if ( is_wp_error( $balance ) ) {
		return '<span class="affwp-stripe-balance-error">' . esc_html( $balance->get_error_message() ) . '</span>';
	}

	$output = '<div class="affwp-stripe-balance-display">';

	// Display available balance
	if ( isset( $balance['available'] ) && is_array( $balance['available'] ) ) {
		$output .= '<div class="affwp-stripe-balance-available">';
		$output .= '<strong>' . __( 'Available Balance:', 'affiliate-wp' ) . '</strong> ';

		foreach ( $balance['available'] as $balance_item ) {
			if ( isset( $balance_item['amount'] ) && isset( $balance_item['currency'] ) ) {
				$amount  = affwp_stripe_payouts_unformat_amount( $balance_item['amount'], $balance_item['currency'] ); // Convert from smallest unit
				$output .= '<span class="affwp-balance-amount">';
				$output .= esc_html( affwp_currency_filter( $amount ) ) . ' ' . strtoupper( $balance_item['currency'] );
				$output .= '</span> ';
			}
		}
		$output .= '</div>';
	}

	// Display pending balance
	if ( isset( $balance['pending'] ) && is_array( $balance['pending'] ) && ! empty( $balance['pending'] ) ) {
		$has_pending = false;
		foreach ( $balance['pending'] as $balance_item ) {
			if ( isset( $balance_item['amount'] ) && $balance_item['amount'] > 0 ) {
				$has_pending = true;
				break;
			}
		}

		if ( $has_pending ) {
			$output .= '<div class="affwp-stripe-balance-pending">';
			$output .= '<strong>' . __( 'Pending Balance:', 'affiliate-wp' ) . '</strong> ';

			foreach ( $balance['pending'] as $balance_item ) {
				if ( isset( $balance_item['amount'] ) && $balance_item['amount'] > 0 && isset( $balance_item['currency'] ) ) {
					$amount  = affwp_stripe_payouts_unformat_amount( $balance_item['amount'], $balance_item['currency'] ); // Convert from smallest unit
					$output .= '<span class="affwp-balance-amount">';
					$output .= esc_html( affwp_currency_filter( $amount ) ) . ' ' . strtoupper( $balance_item['currency'] );
					$output .= '</span> ';
				}
			}
			$output .= '</div>';
		}
	}

	$output .= '</div>';

	return $output;
}

/**
 * Get cached balance from transient
 *
 * @since 2.29.0
 * @return array|false Balance data or false if not cached
 */
function affwp_stripe_payouts_get_cached_balance() {
	$cache_key = 'affwp_stripe_' . ( affwp_stripe_payouts_is_testing_mode() ? 'test' : 'live' ) . '_balance';
	$cached    = get_transient( $cache_key );

	// If no cache exists and we have API credentials, fetch fresh balance
	if ( $cached === false && affwp_stripe_payouts_is_configured() ) {
		$fresh_balance = affwp_stripe_payouts_check_platform_balance();
		if ( ! is_wp_error( $fresh_balance ) ) {
			// Cache for 1 hour
			affwp_stripe_payouts_set_cached_balance( $fresh_balance, 3600 );
			return $fresh_balance;
		}
	}

	return $cached;
}

/**
 * Set cached balance in transient
 *
 * @since 2.29.0
 * @param array $balance Balance data from Stripe
 * @param int   $expiration Expiration time in seconds (default 1 hour)
 * @return bool Success
 */
function affwp_stripe_payouts_set_cached_balance( $balance, $expiration = 3600 ) {
	if ( ! is_array( $balance ) ) {
		return false;
	}

	$cache_key = 'affwp_stripe_' . ( affwp_stripe_payouts_is_testing_mode() ? 'test' : 'live' ) . '_balance';
	return set_transient( $cache_key, $balance, $expiration );
}

/**
 * Clear cached balance
 *
 * @since 2.29.0
 * @return bool Success
 */
function affwp_stripe_payouts_clear_cached_balance() {
	$cache_key = 'affwp_stripe_' . ( affwp_stripe_payouts_is_testing_mode() ? 'test' : 'live' ) . '_balance';
	return delete_transient( $cache_key );
}

/**
 * Get formatted balance amount for display
 *
 * @since 2.29.0
 * @param array  $balance Balance data from Stripe or cache
 * @param string $currency Currency code (default null to use AffiliateWP currency or first available)
 * @return string Formatted balance amount
 */
function affwp_stripe_payouts_format_balance_amount( $balance, $currency = null ) {
	if ( ! is_array( $balance ) || ! isset( $balance['available'] ) ) {
		// Format zero with proper decimal places
		$formatted = affwp_format_amount( 0 );
		return affwp_currency_filter( $formatted );
	}

	// If no currency specified, try to use AffiliateWP's configured currency
	if ( $currency === null ) {
		$currency = strtolower( affwp_get_currency() );
	}

	// Find the balance for the specified currency
	foreach ( $balance['available'] as $balance_item ) {
		if ( isset( $balance_item['currency'] ) && strtolower( $balance_item['currency'] ) === strtolower( $currency ) ) {
			$amount = isset( $balance_item['amount'] ) ? affwp_stripe_payouts_unformat_amount( $balance_item['amount'], $currency ) : 0;
			// Format the amount with proper thousands separator and decimal places
			$formatted = affwp_format_amount( $amount );
			return affwp_currency_filter( $formatted );
		}
	}

	// No balance in the store currency. Do NOT fall back to another currency's
	// amount: affwp_currency_filter() always renders the store currency symbol,
	// so showing e.g. a USD balance would misleadingly read as "¥396". The
	// honest value is zero in the store currency. The per-currency balance list
	// (rendered elsewhere) still shows balances held in other currencies.
	$formatted = affwp_format_amount( 0 );
	return affwp_currency_filter( $formatted );
}

/**
 * Validate Stripe OAuth authorization code
 *
 * @since 2.29.0
 * @param string $code
 * @return string|WP_Error
 */
function affwp_stripe_payouts_validate_oauth_code( $code ) {
	$code = sanitize_text_field( $code );

	if ( empty( $code ) ) {
		return new WP_Error( 'missing_code', __( 'Authorization code is missing.', 'affiliate-wp' ) );
	}

	// Special case for "completed" status from Express Connect flow
	if ( $code === 'completed' ) {
		return $code;
	}

	// Validate OAuth code format (Stripe codes are typically alphanumeric, 20+ chars)
	if ( ! preg_match( '/^[a-zA-Z0-9_-]{20,}$/', $code ) ) {
		return new WP_Error( 'invalid_code_format', __( 'Invalid authorization code format.', 'affiliate-wp' ) );
	}

	return $code;
}

/**
 * Validate Stripe error parameter from OAuth flow
 *
 * @since 2.29.0
 * @param string $error
 * @return string|WP_Error
 */
function affwp_stripe_payouts_validate_stripe_error( $error ) {
	$error = sanitize_text_field( $error );

	if ( empty( $error ) ) {
		return new WP_Error( 'missing_error', __( 'Error parameter is missing.', 'affiliate-wp' ) );
	}

	// Validate error parameter format (should be alphanumeric with underscores)
	if ( ! preg_match( '/^[a-zA-Z_]{1,50}$/', $error ) ) {
		return new WP_Error( 'invalid_error_format', __( 'Invalid error parameter format.', 'affiliate-wp' ) );
	}

	return $error;
}

/**
 * Validate nonce with specific action
 *
 * @since 2.29.0
 * @param string $nonce_value
 * @param string $action
 * @return bool|WP_Error
 */
function affwp_stripe_payouts_validate_nonce( $nonce_value, $action ) {
	if ( empty( $nonce_value ) ) {
		return new WP_Error( 'missing_nonce', __( 'Security token is missing.', 'affiliate-wp' ) );
	}

	if ( ! wp_verify_nonce( $nonce_value, $action ) ) {
		return new WP_Error( 'invalid_nonce', __( 'Security check failed.', 'affiliate-wp' ) );
	}

	return true;
}

/**
 * Validate AJAX request context
 *
 * @since 2.29.0
 * @param string $capability Required capability (default: 'manage_affiliate_options')
 * @return bool|WP_Error
 */
function affwp_stripe_payouts_validate_ajax_request( $capability = 'manage_affiliate_options' ) {
	// Check if it's an AJAX request
	if ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX ) {
		return new WP_Error( 'not_ajax', __( 'Invalid request type.', 'affiliate-wp' ) );
	}

	// Check user capability
	if ( ! current_user_can( $capability ) ) {
		return new WP_Error( 'insufficient_permissions', __( 'You do not have permission to perform this action.', 'affiliate-wp' ) );
	}

	return true;
}

/**
 * Sanitize and validate error message for display
 *
 * @since 2.29.0
 * @param string $message
 * @param int    $max_length Maximum message length (default: 500)
 * @return string
 */
function affwp_stripe_payouts_sanitize_error_message( $message, $max_length = 500 ) {
	// Sanitize the message
	$message = sanitize_text_field( $message );

	// Truncate if too long
	if ( strlen( $message ) > $max_length ) {
		$message = substr( $message, 0, $max_length - 3 ) . '...';
	}

	return $message;
}

/**
 * Disconnect an affiliate from Stripe
 *
 * @since 2.29.0
 * @param int $affiliate_id The affiliate ID
 * @return bool|WP_Error True on success, WP_Error on failure
 */
function affwp_stripe_payouts_disconnect_affiliate( $affiliate_id ) {
	// Validate affiliate
	$validated = affwp_stripe_payouts_validate_affiliate_id( $affiliate_id );
	if ( is_wp_error( $validated ) ) {
		return $validated;
	}

	$user_id = affwp_get_affiliate_user_id( $affiliate_id );
	if ( ! $user_id ) {
		return new WP_Error( 'invalid_user', __( 'Invalid affiliate user', 'affiliate-wp' ) );
	}

	// Get the account ID before disconnecting
	$account_id = get_user_meta( $user_id, 'affwp_stripe_payouts_account_id', true );

	// Delete Stripe metadata
	delete_user_meta( $user_id, 'affwp_stripe_payouts_account_id' );
	delete_user_meta( $user_id, 'affwp_stripe_payouts_account_status' );
	delete_user_meta( $user_id, 'affwp_stripe_payouts_connected' );

	// Clear the saved method only when it's still 'stripe', so a later non-Stripe override is left alone.
	$current_method = function_exists( 'affwp_get_affiliate_meta' )
		? affwp_get_affiliate_meta( $affiliate_id, 'payout_method', true )
		: '';
	if ( 'stripe' === $current_method ) {
		affwp_delete_affiliate_meta( $affiliate_id, 'payout_method' );
	}

	// Clear any cached data
	if ( $account_id ) {
		delete_transient( 'affwp_stripe_account_status_' . $account_id );
	}

	// Log the disconnection
	affwp_stripe_payouts_log_info( sprintf( 'Affiliate #%d disconnected from Stripe account %s', $affiliate_id, $account_id ) );

	return true;
}

/**
 * Check if testing features should be enabled
 *
 * Testing features are enabled when:
 * 1. Stripe is in test mode (admin setting), OR
 * 2. The AFFWP_STRIPE_PAYOUTS_TESTING constant is defined (for developers)
 *
 * Testing features include:
 * - Test charge creation
 * - Debug information display
 * - Account testing utilities
 *
 * @since 2.29.0
 * @return bool True if testing features should be enabled
 */
function affwp_stripe_payouts_is_testing_mode() {
	// Enable testing features if admin has set test mode
	if ( affwp_stripe_payouts_is_test_api_mode() ) {
		return true;
	}

	// Also enable if developer constant is set (for advanced debugging)
	return defined( 'AFFWP_STRIPE_PAYOUTS_TESTING' ) && AFFWP_STRIPE_PAYOUTS_TESTING;
}

/**
 * Validate and sanitize webhook payload
 *
 * @since 2.29.0
 * @param string $payload
 * @param string $signature
 * @return array|WP_Error
 */
function affwp_stripe_payouts_validate_webhook_payload( $payload, $signature ) {
	if ( empty( $payload ) ) {
		return new WP_Error( 'empty_payload', __( 'Webhook payload is empty.', 'affiliate-wp' ) );
	}

	if ( empty( $signature ) ) {
		return new WP_Error( 'missing_signature', __( 'Webhook signature is missing.', 'affiliate-wp' ) );
	}

	// Validate signature format
	if ( ! preg_match( '/^t=\d+,v1=[a-f0-9]{64}/', $signature ) ) {
		return new WP_Error( 'invalid_signature_format', __( 'Invalid webhook signature format.', 'affiliate-wp' ) );
	}

	// Validate JSON payload
	$decoded = json_decode( $payload, true );
	if ( json_last_error() !== JSON_ERROR_NONE ) {
		return new WP_Error( 'invalid_json', __( 'Invalid JSON in webhook payload.', 'affiliate-wp' ) );
	}

	return $decoded;
}

/**
 * Validate user permissions for affiliate operations
 *
 * @since 2.29.0
 * @param int    $affiliate_id
 * @param string $operation
 * @return bool|WP_Error
 */
function affwp_stripe_payouts_validate_user_permissions( $affiliate_id, $operation = 'manage' ) {
	$current_user_id = get_current_user_id();

	if ( ! $current_user_id ) {
		return new WP_Error( 'not_logged_in', __( 'You must be logged in to perform this action.', 'affiliate-wp' ) );
	}

	// Validate affiliate ID first
	$validated_affiliate_id = affwp_stripe_payouts_validate_affiliate_id( $affiliate_id );
	if ( is_wp_error( $validated_affiliate_id ) ) {
		return $validated_affiliate_id;
	}

	$affiliate_user_id = affwp_get_affiliate_user_id( $validated_affiliate_id );

	// Check if user is the affiliate owner or has admin permissions
	if ( $current_user_id === $affiliate_user_id ) {
		return true; // User owns this affiliate
	}

	if ( current_user_can( 'manage_affiliates' ) ) {
		return true; // User has admin permissions
	}

	return new WP_Error( 'insufficient_permissions', __( 'You do not have permission to manage this affiliate.', 'affiliate-wp' ) );
}

/**
 * Rate limit check for sensitive operations
 *
 * @since 2.29.0
 * @param string $action
 * @param int    $user_id
 * @param int    $limit
 * @param int    $window_seconds
 * @return bool|WP_Error
 */
function affwp_stripe_payouts_check_rate_limit( $action, $user_id = null, $limit = 10, $window_seconds = 3600 ) {
	if ( ! $user_id ) {
		$user_id = get_current_user_id();
	}

	if ( ! $user_id ) {
		return new WP_Error( 'no_user', __( 'Unable to check rate limit without user ID.', 'affiliate-wp' ) );
	}

	$cache_key = 'affwp_stripe_rate_limit_' . $action . '_' . $user_id;
	$attempts  = get_transient( $cache_key );

	if ( false === $attempts ) {
		$attempts = 0;
	}

	if ( $attempts >= $limit ) {
		return new WP_Error(
			'rate_limit_exceeded',
			sprintf(
				__( 'Rate limit exceeded. Please wait %d minutes before trying again.', 'affiliate-wp' ),
				round( $window_seconds / 60 )
			)
		);
	}

	// Increment counter
	set_transient( $cache_key, $attempts + 1, $window_seconds );

	return true;
}

/**
 * Check if we're using Stripe test API keys
 *
 * @since 2.29.0
 * @return bool True if using test API keys
 */
function affwp_stripe_payouts_is_test_api_mode() {
	return 'test' === affwp_stripe_payouts_get_mode();
}

/**
 * Check if a referral has been previously paid via Stripe
 *
 * This function checks if a referral has a Stripe transfer ID in its meta,
 * indicating it was previously paid through Stripe, regardless of its current status.
 *
 * @since 2.29.0
 * @param int $referral_id The referral ID to check
 * @return bool True if the referral has been paid via Stripe before, false otherwise
 */
function affwp_stripe_payouts_referral_has_stripe_payment( $referral_id ) {
	$stripe_transfer_id = affwp_get_referral_meta( $referral_id, 'stripe_transfer_id', true );
	return ! empty( $stripe_transfer_id );
}

/**
 * Get the Stripe transfer ID for a referral
 *
 * @since 2.29.0
 * @param int $referral_id The referral ID
 * @return string|false The Stripe transfer ID or false if not found
 */
function affwp_stripe_payouts_get_referral_transfer_id( $referral_id ) {
	$stripe_transfer_id = affwp_get_referral_meta( $referral_id, 'stripe_transfer_id', true );
	return ! empty( $stripe_transfer_id ) ? $stripe_transfer_id : false;
}

/**
 * Check if a referral has failed Stripe transfers
 *
 * @since 2.29.0
 * @param int $referral_id The referral ID
 * @return bool True if the referral has failed transfers, false otherwise
 */
function affwp_stripe_payouts_referral_has_failed_transfer( $referral_id ) {
	$failed_transfer_id = affwp_get_referral_meta( $referral_id, 'stripe_transfer_failed', true );
	return ! empty( $failed_transfer_id );
}

/**
 * Check if a referral has reversed Stripe transfers
 *
 * @since 2.29.0
 * @param int $referral_id The referral ID
 * @return bool True if the referral has reversed transfers, false otherwise
 */
function affwp_stripe_payouts_referral_has_reversed_transfer( $referral_id ) {
	$reversed_transfer_id = affwp_get_referral_meta( $referral_id, 'stripe_transfer_reversed', true );
	return ! empty( $reversed_transfer_id );
}

/**
 * Whether international affiliate payouts are enabled.
 *
 * The "cross_border" naming (and the stripe_cross_border_payouts setting key) is kept for
 * back-compat, but this gates Stripe's full-agreement "Cross-Border Payouts" product: payable
 * from US/UK platforms to affiliates in US/UK/EEA/CA/CH. AffiliateWP does not use the recipient
 * ("Global Payouts") service agreement.
 * See https://docs.stripe.com/connect/cross-border-payouts
 *
 * @since 2.29.0
 * @return bool True if international affiliate payouts are enabled, false otherwise.
 */
function affwp_stripe_payouts_cross_border_enabled() {
	return (bool) affiliate_wp()->settings->get( 'stripe_cross_border_payouts', false );
}

/**
 * Get the platform's Stripe account country
 *
 * @since 2.29.0
 * @return string|WP_Error The country code (e.g., 'US') or error if unable to retrieve
 */
function affwp_stripe_payouts_get_platform_country() {
	static $cached_country = null;
	static $cached_fp      = null;

	// Fingerprint of the active secret key. The platform country is cached against it (both per-request
	// and cross-request), so swapping the key — even for another account in the same mode, including
	// mid-request on the settings-save render — forces a fresh lookup instead of reusing the previous
	// key's country.
	$key_fingerprint = substr( md5( (string) affwp_stripe_payouts_get_secret_key() ), 0, 12 );

	// Per-request cache: the platform account country is stable and is read several times per request
	// (connect flow, affiliate picker, settings panel). Reuse it only when resolved for the current key.
	if ( null !== $cached_country && $cached_fp === $key_fingerprint ) {
		return $cached_country;
	}

	// Cross-request cache, keyed by mode (test/live can be different sandboxes) and pinned to the same
	// key fingerprint. A legacy string value (pre-fingerprint) fails the check and re-fetches.
	$test_mode        = (bool) affiliate_wp()->settings->get( 'stripe_test_mode', false );
	$transient_key    = 'affwp_stripe_platform_country_' . ( $test_mode ? 'test' : 'live' );
	$cached_transient = get_transient( $transient_key );
	if ( is_array( $cached_transient )
		&& isset( $cached_transient['country'], $cached_transient['fp'] )
		&& $cached_transient['fp'] === $key_fingerprint
		&& '' !== $cached_transient['country']
	) {
		$cached_country = strtoupper( $cached_transient['country'] );
		$cached_fp      = $key_fingerprint;
		// Keep the stable option in sync on the common (warm-transient) path too, so background
		// contexts like the usage check-in can read the platform country without a Stripe API call.
		update_option( 'affwp_stripe_platform_country', $cached_country, false );
		return $cached_country;
	}

	// Check if Stripe is configured
	if ( ! affwp_stripe_payouts_is_configured() ) {
		return new WP_Error( 'not_configured', __( 'Stripe is not configured', 'affiliate-wp' ) );
	}

	// Initialize Stripe API
	if ( ! affwp_stripe_payouts_init_api() ) {
		return new WP_Error( 'api_init_failed', __( 'Failed to initialize Stripe API', 'affiliate-wp' ) );
	}

	try {
		// Retrieve the platform account details
		$account = \Stripe\Account::retrieve();

		if ( ! isset( $account->country ) ) {
			return new WP_Error( 'no_country', __( 'Could not determine platform country', 'affiliate-wp' ) );
		}

		$cached_country = strtoupper( $account->country );
		$cached_fp      = $key_fingerprint;
		set_transient( $transient_key, [ 'country' => $cached_country, 'fp' => $key_fingerprint ], 6 * HOUR_IN_SECONDS );

		// Persist the last-known platform country to a stable (non-expiring) option as well. The
		// transient is short-lived, but background contexts such as the usage check-in need to read
		// the platform country without triggering a blocking Stripe API call.
		update_option( 'affwp_stripe_platform_country', $cached_country, false );

		return $cached_country;

	} catch ( Exception $e ) {
		affwp_stripe_payouts_log_error( 'Failed to retrieve platform country: ' . $e->getMessage() );
		return new WP_Error( 'api_error', $e->getMessage() );
	}
}

/**
 * Clears the cached platform country (both test and live transients, plus the stable option).
 *
 * Hooked to settings saves so a swapped Stripe account or API key isn't served a stale platform
 * country for up to 6 hours by {@see affwp_stripe_payouts_get_platform_country()}. The non-expiring
 * affwp_stripe_platform_country option is cleared too: background contexts (the usage check-in, the
 * unserviceable-affiliate count) read it without a live API call, so it must not outlive an account swap.
 *
 * @since 2.34.0
 *
 * @return void
 */
function affwp_stripe_payouts_flush_platform_country_cache() {
	delete_transient( 'affwp_stripe_platform_country_test' );
	delete_transient( 'affwp_stripe_platform_country_live' );
	delete_option( 'affwp_stripe_platform_country' );
}

/**
 * Check if the platform supports cross-border payouts.
 *
 * Cross-border payouts use Stripe's full-agreement "Cross-Border Payouts" product, sandbox-verified
 * payable from US- and UK-based platforms to affiliates in US/UK/EEA/CA/CH. Stripe also allows EEA,
 * CA, and CH platforms for that product; the gate stays US/UK to match what's been verified and
 * could widen later. See https://docs.stripe.com/connect/cross-border-payouts
 *
 * @since 2.29.0
 * @return bool True if the platform can do cross-border payouts, false otherwise.
 */
function affwp_stripe_payouts_platform_supports_cross_border() {
	$platform_country = affwp_stripe_payouts_get_platform_country();

	if ( is_wp_error( $platform_country ) ) {
		return false;
	}

	// US and GB (United Kingdom) are the Global Payouts platform countries.
	return in_array( $platform_country, [ 'US', 'GB' ], true );
}

/**
 * Counts affiliates who declared a payout country Stripe can't reach.
 *
 * Powers a data-grounded admin notice: when affiliates genuinely sit in unserviceable countries
 * and Stripe is the only self-serve method, the store owner is on the hook to pay them manually.
 * Counts only affiliates who actually answered the payout-country question with an unsupported
 * country, so it's a real population, not a guess. Cached for an hour — it's advisory.
 *
 * @since 2.34.0
 *
 * @return int
 */
function affwp_stripe_payouts_count_unserviceable_affiliates() {
	global $wpdb;

	$serviceable = array();
	if ( function_exists( 'affwp_stripe_payouts_get_domestic_country' ) ) {
		$domestic = affwp_stripe_payouts_get_domestic_country();
		if ( is_string( $domestic ) && '' !== $domestic ) {
			$serviceable[] = strtoupper( $domestic );
		}
	}
	if ( affwp_stripe_payouts_cross_border_enabled() && affwp_stripe_payouts_platform_supports_cross_border() ) {
		$serviceable = array_merge( $serviceable, array_map( 'strtoupper', array_keys( affwp_stripe_payouts_get_supported_countries() ) ) );
	}
	$serviceable = array_values( array_unique( $serviceable ) );

	$cache_key = 'affwp_sp_unserviceable_count_' . md5( implode( ',', $serviceable ) );
	$cached    = get_transient( $cache_key );
	if ( false !== $cached ) {
		return (int) $cached;
	}

	$table = affiliate_wp()->affiliate_meta->table_name;

	if ( empty( $serviceable ) ) {
		$count = (int) $wpdb->get_var(
			"SELECT COUNT(DISTINCT affiliate_id) FROM {$table} WHERE meta_key = 'affwp_payout_country' AND meta_value <> ''" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is internal, no user input.
		);
	} else {
		$placeholders = implode( ',', array_fill( 0, count( $serviceable ), '%s' ) );
		$count        = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT affiliate_id) FROM {$table} WHERE meta_key = 'affwp_payout_country' AND meta_value <> '' AND meta_value NOT IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- placeholders are %s, values bound below.
				$serviceable
			)
		);
	}

	set_transient( $cache_key, $count, HOUR_IN_SECONDS );

	return $count;
}

/**
 * The store's own (domestic) Stripe country — the only country payable when international payouts
 * are off. Reads the connected Stripe account country first, then the WooCommerce/EDD store base.
 *
 * @since 2.34.0
 *
 * @return string Uppercase ISO 3166-1 alpha-2 code, or '' if unknown.
 */
function affwp_stripe_payouts_get_domestic_country() {
	$platform = affwp_stripe_payouts_get_platform_country();
	if ( ! is_wp_error( $platform ) && is_string( $platform ) && '' !== $platform ) {
		return strtoupper( $platform );
	}

	if ( function_exists( 'wc_get_base_location' ) ) {
		$location = wc_get_base_location();
		if ( is_array( $location ) && ! empty( $location['country'] ) ) {
			return strtoupper( $location['country'] );
		}
	} elseif ( function_exists( 'edd_get_shop_country' ) ) {
		$country = edd_get_shop_country();
		if ( ! empty( $country ) ) {
			return strtoupper( $country );
		}
	}

	return '';
}

/**
 * Human-readable label for the store's domestic country, e.g. "United States". Falls back to a
 * generic phrase when the country can't be resolved or named, so affiliate-facing copy reads cleanly.
 *
 * @since 2.34.0
 *
 * @return string
 */
function affwp_stripe_payouts_get_domestic_country_label() {
	$code = affwp_stripe_payouts_get_domestic_country();

	if ( '' === $code ) {
		return __( "your store's country", 'affiliate-wp' );
	}

	$supported = affwp_stripe_payouts_get_supported_countries();
	if ( isset( $supported[ $code ] ) ) {
		return $supported[ $code ];
	}

	if ( function_exists( 'WC' ) && isset( WC()->countries ) && ! empty( WC()->countries->countries[ $code ] ) ) {
		return WC()->countries->countries[ $code ];
	}

	return $code;
}

/**
 * The domestic country name with a leading definite article where English grammar needs one
 * ("the United States"), bare otherwise ("Germany"). For inline sentence use.
 *
 * @since 2.34.0
 *
 * @return string
 */
function affwp_stripe_payouts_get_domestic_country_phrase() {
	$label     = affwp_stripe_payouts_get_domestic_country_label();
	$needs_the = array( 'United States', 'United Kingdom', 'Netherlands', 'Philippines', 'United Arab Emirates', 'Czech Republic' );

	if ( in_array( $label, $needs_the, true ) ) {
		/* translators: %s: a country name that takes a leading article, e.g. "the United States". */
		return sprintf( __( 'the %s', 'affiliate-wp' ), $label );
	}

	return $label;
}

/**
 * Whether the store's region supports international affiliate payouts (US or UK).
 *
 * Reads the connected Stripe account country first (cached — no live API call on a warm
 * cache, and no call at all when Stripe isn't configured). Falls back to the store's base
 * country from WooCommerce or EDD when Stripe isn't connected yet; an unknown region
 * suppresses discovery rather than guessing.
 *
 * @since 2.34.0
 *
 * @return bool
 */
function affwp_stripe_payouts_discovery_region_supported() {
	$country = affwp_stripe_payouts_get_platform_country();

	if ( ! is_wp_error( $country ) && is_string( $country ) && '' !== $country ) {
		return in_array( strtoupper( $country ), [ 'US', 'GB' ], true );
	}

	$base = '';

	if ( function_exists( 'wc_get_base_location' ) ) {
		$location = wc_get_base_location();
		if ( is_array( $location ) && ! empty( $location['country'] ) ) {
			$base = $location['country'];
		}
	} elseif ( function_exists( 'edd_get_shop_country' ) ) {
		$base = edd_get_shop_country();
	}

	if ( '' === $base ) {
		return false;
	}

	return in_array( strtoupper( $base ), [ 'US', 'GB' ], true );
}

/**
 * Eligibility gate for the International Affiliate Payouts in-product discovery.
 *
 * Order is cheapest-first: capability, then the (cheap) "already enabled" settings read so an
 * adopted store short-circuits before the region lookup, then region.
 *
 * @since 2.34.0
 *
 * @return bool
 */
function affwp_stripe_payouts_discovery_eligible() {
	if ( ! function_exists( 'affwp_discovery_capability' ) || ! current_user_can( affwp_discovery_capability() ) ) {
		return false;
	}

	if ( affwp_stripe_payouts_cross_border_enabled() ) {
		return false;
	}

	return affwp_stripe_payouts_discovery_region_supported();
}

/**
 * Registers the International Affiliate Payouts discovery with the framework.
 *
 * The feature owns its own copy, eligibility, CTA target, and docs link; the discovery
 * framework stays generic.
 *
 * @since 2.34.0
 *
 * @param AffiliateWP_Feature_Discovery $discovery Discovery coordinator.
 */
function affwp_stripe_payouts_register_discovery( $discovery ) {
	$cta_target = add_query_arg(
		[
			'page' => 'affiliate-wp-settings',
			'tab'  => 'payouts',
		],
		admin_url( 'admin.php' )
	) . '#stripe-international-payouts';

	$discovery->register(
		'intl_affiliate_payouts',
		[
			'introduced_in' => '2.34.0',
			'eligibility'   => 'affwp_stripe_payouts_discovery_eligible',
			'docs_url'      => 'https://affiliatewp.com/docs/international-affiliate-payouts/',
			'cta_target'    => $cta_target,
			'notice'        => [
				'eyebrow'   => __( 'New', 'affiliate-wp' ),
				'heading'   => __( 'Pay international affiliates in their local currency', 'affiliate-wp' ),
				'body'      => __( 'Pay affiliates based outside your country straight from your Stripe account, in their own currency. Available for US- and UK-based Stripe accounts.', 'affiliate-wp' ),
				'cta_label' => __( 'Set it up', 'affiliate-wp' ),
			],
		]
	);
}
add_action( 'affwp_register_feature_discoveries', 'affwp_stripe_payouts_register_discovery' );

/**
 * Supported affiliate payout countries as an ISO 3166-1 alpha-2 code => display-name map.
 *
 * Single source of truth for both the affiliate country selector and the support check
 * (affwp_stripe_payouts_is_country_supported()). These are Stripe's full-agreement "Cross-Border
 * Payouts" destinations (US, UK, EEA, Canada, Switzerland), sandbox-verified payable from US and UK
 * stores. The Asia-Pacific/LatAm long tail (IN, AU, JP, SG, TH, HK, ID, MY, MX, NZ, GI) was removed
 * because it requires the recipient service agreement, which AffiliateWP does not use. Re-validate
 * against https://docs.stripe.com/connect/cross-border-payouts if Stripe changes the list.
 *
 * @since 2.34.0
 *
 * @return array<string,string> Country code => translated country name.
 */
function affwp_stripe_payouts_get_supported_countries() {
	$countries = [
		'US' => __( 'United States', 'affiliate-wp' ),
		'AT' => __( 'Austria', 'affiliate-wp' ),
		'BE' => __( 'Belgium', 'affiliate-wp' ),
		'BG' => __( 'Bulgaria', 'affiliate-wp' ),
		'CA' => __( 'Canada', 'affiliate-wp' ),
		'CH' => __( 'Switzerland', 'affiliate-wp' ),
		'CY' => __( 'Cyprus', 'affiliate-wp' ),
		'CZ' => __( 'Czechia', 'affiliate-wp' ),
		'DE' => __( 'Germany', 'affiliate-wp' ),
		'DK' => __( 'Denmark', 'affiliate-wp' ),
		'EE' => __( 'Estonia', 'affiliate-wp' ),
		'ES' => __( 'Spain', 'affiliate-wp' ),
		'FI' => __( 'Finland', 'affiliate-wp' ),
		'FR' => __( 'France', 'affiliate-wp' ),
		'GB' => __( 'United Kingdom', 'affiliate-wp' ),
		'GR' => __( 'Greece', 'affiliate-wp' ),
		'HR' => __( 'Croatia', 'affiliate-wp' ),
		'HU' => __( 'Hungary', 'affiliate-wp' ),
		'IE' => __( 'Ireland', 'affiliate-wp' ),
		'IT' => __( 'Italy', 'affiliate-wp' ),
		'LI' => __( 'Liechtenstein', 'affiliate-wp' ),
		'LT' => __( 'Lithuania', 'affiliate-wp' ),
		'LU' => __( 'Luxembourg', 'affiliate-wp' ),
		'LV' => __( 'Latvia', 'affiliate-wp' ),
		'MT' => __( 'Malta', 'affiliate-wp' ),
		'NL' => __( 'Netherlands', 'affiliate-wp' ),
		'NO' => __( 'Norway', 'affiliate-wp' ),
		'PL' => __( 'Poland', 'affiliate-wp' ),
		'PT' => __( 'Portugal', 'affiliate-wp' ),
		'RO' => __( 'Romania', 'affiliate-wp' ),
		'SE' => __( 'Sweden', 'affiliate-wp' ),
		'SI' => __( 'Slovenia', 'affiliate-wp' ),
		'SK' => __( 'Slovakia', 'affiliate-wp' ),
	];

	/**
	 * Filters the affiliate payout countries offered in the Stripe connect selector.
	 *
	 * @since 2.34.0
	 *
	 * @param array<string,string> $countries Country code => display name.
	 */
	return apply_filters( 'affwp_stripe_payouts_supported_countries', $countries );
}

/**
 * Check if an affiliate's country is supported for cross-border payouts.
 *
 * @since 2.29.0
 * @param string $country The affiliate's country code
 * @return bool True if the country is supported, false otherwise
 */
function affwp_stripe_payouts_is_country_supported( $country ) {
	return array_key_exists( strtoupper( (string) $country ), affwp_stripe_payouts_get_supported_countries() );
}

/**
 * Whether the platform can actually pay an affiliate located in the given country via Stripe.
 *
 * Domestic affiliates (same country as the platform) are always serviceable. Affiliates in other
 * countries are serviceable only when cross-border payouts are enabled on a US/UK platform
 * and the country is supported. Used both to validate connect attempts and to hide Stripe from the
 * affiliate payout-method picker when it can't serve them.
 *
 * @since 2.34.0
 *
 * @param string $affiliate_country ISO 3166-1 alpha-2 country code.
 * @return bool True if the platform can pay an affiliate in this country.
 */
function affwp_stripe_payouts_is_country_serviceable( $affiliate_country ) {
	$affiliate_country = strtoupper( (string) $affiliate_country );
	if ( '' === $affiliate_country ) {
		return false;
	}

	$platform_country = affwp_stripe_payouts_get_platform_country();

	// If the platform can't be determined, don't block — let the connect flow surface any error.
	if ( is_wp_error( $platform_country ) ) {
		return true;
	}

	// Domestic affiliates are always serviceable.
	if ( $affiliate_country === $platform_country ) {
		return true;
	}

	// Foreign affiliates require cross-border payouts enabled on a US/UK platform.
	return affwp_stripe_payouts_cross_border_enabled()
		&& affwp_stripe_payouts_platform_supports_cross_border()
		&& affwp_stripe_payouts_is_country_supported( $affiliate_country );
}

/**
 * The explicit set of countries Stripe can pay from this platform: always the domestic country
 * (which resolves to the store base when the Stripe platform country is unknown), plus the
 * cross-border supported countries when international payouts are enabled.
 *
 * Single source for the affiliate-area connect gate and the unserviceable-country save warning, so
 * the inline "not available" message and the saved-settings warning always agree. Unlike
 * {@see affwp_stripe_payouts_is_country_serviceable()}, this does not fail open when the platform
 * country is undetermined — it's an explicit allowlist for affiliate-facing messaging.
 *
 * @since 2.34.0
 *
 * @return string[] Uppercase ISO 3166-1 alpha-2 codes.
 */
function affwp_stripe_payouts_get_serviceable_countries() {
	$serviceable = array();

	$domestic = affwp_stripe_payouts_get_domestic_country();
	if ( is_string( $domestic ) && '' !== $domestic ) {
		$serviceable[] = strtoupper( $domestic );
	}

	if ( affwp_stripe_payouts_cross_border_enabled()
		&& affwp_stripe_payouts_platform_supports_cross_border()
	) {
		$serviceable = array_merge(
			$serviceable,
			array_map( 'strtoupper', array_keys( affwp_stripe_payouts_get_supported_countries() ) )
		);
	}

	return array_values( array_unique( $serviceable ) );
}

/**
 * The payout method an affiliate is routed to when Stripe can't serve their country.
 *
 * Mirrors the effective-method downgrade: the site default when it isn't Stripe, otherwise the
 * first enabled non-Stripe method. Returns '' when Stripe is the only enabled method — a genuine
 * dead end, nowhere to fall back to. Shared by the effective-method resolver (so the affiliate is
 * actually paid this way) and the affiliate-area UI (so the inline "switch to" route names the same
 * destination the payout engine will use).
 *
 * @since 2.34.0
 *
 * @return string Method slug, or '' when no fallback exists.
 */
function affwp_stripe_payouts_get_fallback_method() {

	$default = affwp_get_default_payout_method();
	if ( 'stripe' !== $default ) {
		return $default;
	}

	$alternatives = function_exists( 'affwp_get_enabled_payout_methods' )
		? array_values( array_diff( affwp_get_enabled_payout_methods(), array( 'stripe' ) ) )
		: array();

	return ! empty( $alternatives ) ? $alternatives[0] : '';
}

/**
 * Returns the affiliate's upcoming Stripe payout estimate (amount, currency, arrival date, status).
 *
 * Primary source is webhook-captured data (payout.created/updated) in affiliate meta. When that's
 * missing or stale (no webhook configured, or older than the TTL), it lazily fetches the connected
 * account's most recent payout from Stripe and caches it — so the dashboard makes no per-load API
 * call once populated.
 *
 * @since 2.34.0
 *
 * @param int $affiliate_id Affiliate ID.
 * @return array|null { amount (smallest unit), currency, arrival_date (timestamp), status, synced_at } or null.
 */
function affwp_stripe_payouts_get_affiliate_payout_summary( $affiliate_id ) {
	$summary = affwp_get_affiliate_meta( $affiliate_id, 'stripe_upcoming_payout', true );

	if ( is_array( $summary ) && ! empty( $summary['synced_at'] ) ) {
		// Real (non-zero) summaries are cached for 6h. The "nothing upcoming" result (amount 0) is
		// cached for a shorter window so a newly-accrued balance surfaces sooner, while still sparing
		// us an API call on every dashboard render in the common between-payouts state.
		$ttl = empty( $summary['amount'] ) ? HOUR_IN_SECONDS : ( 6 * HOUR_IN_SECONDS );
		if ( ( current_time( 'timestamp' ) - (int) $summary['synced_at'] ) < $ttl ) {
			return $summary;
		}
	}

	$account_id = affwp_stripe_payouts_get_affiliate_account_id( $affiliate_id );
	if ( empty( $account_id ) || ! affwp_stripe_payouts_init_api() ) {
		return is_array( $summary ) ? $summary : null;
	}

	try {
		// Prefer a real, in-flight Payout object — it carries Stripe's exact arrival_date.
		$payouts = \Stripe\Payout::all( array( 'limit' => 1 ), array( 'stripe_account' => $account_id ) );
		$payout  = ! empty( $payouts->data[0] ) ? $payouts->data[0] : null;

		if ( $payout && isset( $payout->status ) && in_array( $payout->status, array( 'pending', 'in_transit' ), true ) ) {
			$summary = array(
				'amount'       => (int) $payout->amount,
				'currency'     => strtoupper( (string) $payout->currency ),
				'arrival_date' => (int) $payout->arrival_date,
				'status'       => $payout->status,
				'synced_at'    => current_time( 'timestamp' ),
			);
			affwp_update_affiliate_meta( $affiliate_id, 'stripe_upcoming_payout', $summary );
			return $summary;
		}

		/*
		 * No in-flight Payout yet: before the scheduled payout date Stripe only shows a projection
		 * (balance + schedule), not a Payout object. Fall back to the connected account's available
		 * balance so the affiliate sees the upcoming amount now; the exact arrival_date arrives later
		 * via the payout.created webhook (or the next lazy refresh once Stripe creates the Payout).
		 */
		$balance = \Stripe\Balance::retrieve( array( 'stripe_account' => $account_id ) );
		foreach ( (array) $balance->available as $bal ) {
			if ( ! empty( $bal->amount ) && (int) $bal->amount > 0 ) {
				$summary = array(
					'amount'       => (int) $bal->amount,
					'currency'     => strtoupper( (string) $bal->currency ),
					'arrival_date' => 0,
					'status'       => 'pending',
					'synced_at'    => current_time( 'timestamp' ),
				);
				affwp_update_affiliate_meta( $affiliate_id, 'stripe_upcoming_payout', $summary );
				return $summary;
			}
		}

		// Both Stripe calls succeeded but there's nothing upcoming: cache an explicit empty result so we
		// don't re-run Payout::all + Balance::retrieve on every render while the affiliate sits at a zero
		// balance. The payout.created webhook overwrites this the moment a real payout appears.
		$summary = array(
			'amount'       => 0,
			'currency'     => '',
			'arrival_date' => 0,
			'status'       => 'none',
			'synced_at'    => current_time( 'timestamp' ),
		);
		affwp_update_affiliate_meta( $affiliate_id, 'stripe_upcoming_payout', $summary );
		return $summary;
	} catch ( \Exception $e ) {
		// Don't cache on error — let the next render retry instead of masking a transient failure.
		affwp_stripe_payouts_log_error( 'Failed to fetch affiliate payout summary: ' . $e->getMessage() );
	}

	return is_array( $summary ) ? $summary : null;
}

/**
 * Registers the affiliate-portal "Next payout" card on the Payouts screen.
 *
 * Merges into the Payouts view's core `payouts-summary` group (Unpaid earnings / Lifetime paid),
 * which sits above the payouts table — mirroring the classic affiliate-area Payouts tab, where the
 * summary stats sit above the table. The card is owned here (Stripe data); the surrounding earnings
 * cards are owned by the portal core so they remain if Stripe is ever deactivated.
 *
 * Hooked from the module bootstrap (init.php) rather than the portal integration's constructor: the
 * portal fires `affwp_portal_controls_registry_init` during `affwp_plugins_loaded`, which runs before
 * the Stripe module's later `plugins_loaded` instantiation — so a constructor-registered listener is
 * always too late. Attaching from init.php (which loads while Affiliate_WP boots, before
 * `affwp_plugins_loaded`) guarantees the listener is in place when the registry initializes.
 *
 * `show_empty => false` hides just this card when there is no upcoming payout, leaving the core
 * earnings cards in place. The value computes lazily per affiliate via a render-time data callback
 * (bounded by the 6-hour summary cache).
 *
 * @since 2.34.0
 *
 * @param object $registry Portal controls registry.
 * @return void
 */
function affwp_stripe_payouts_register_portal_summary_card( $registry ) {
	$card_class = '\AffiliateWP_Affiliate_Portal\Core\Components\Controls\Card_Control';

	if ( ! class_exists( $card_class ) || ! is_object( $registry ) || ! method_exists( $registry, 'add_control' ) ) {
		return;
	}

	$registry->add_control( new $card_class( array(
		'id'       => 'stripe_next_payout',
		'view_id'  => 'payouts',
		'section'  => 'payouts-table',
		'parent'   => 'payouts-summary',
		'args'     => array(
			'title'      => __( 'Next payout', 'affiliate-wp' ),
			'data'       => 'affwp_stripe_payouts_portal_summary_card_data',
			'data_key'   => 'stripe_next_payout',
			'layout'     => 'stat',
			'show_empty' => false,
		),
	) ) );
}

/**
 * Builds the display labels (amount + meta sub-line) for an upcoming-payout summary.
 *
 * Shared by the classic affiliate-area summary ({@see AffiliateWP_Stripe_Payouts_Affiliate_Area::render_stripe_payout_summary()})
 * and the portal "Next payout" card ({@see affwp_stripe_payouts_portal_summary_card_data()}) so the
 * amount formatting and the "Arrives …"/"Processing" wording stay identical in both places. Callers own
 * the markup; this only returns the strings. Expects an already-fetched, non-empty summary array.
 *
 * @since 2.34.0
 *
 * @param array $summary Summary array from affwp_stripe_payouts_get_affiliate_payout_summary().
 * @return array { 'amount_label' (defaults to '—'), 'meta_label' }.
 */
function affwp_stripe_payouts_payout_summary_labels( $summary ) {
	$amount_label = '—';
	if ( ! empty( $summary['currency'] ) && function_exists( 'affwp_stripe_payouts_unformat_amount' ) ) {
		$major        = affwp_stripe_payouts_unformat_amount( $summary['amount'], strtolower( $summary['currency'] ) );
		$amount_label = number_format_i18n( (float) $major, 2 ) . ' ' . $summary['currency'];
	}

	if ( ! empty( $summary['arrival_date'] ) ) {
		/* translators: %s: estimated arrival date. */
		$meta_label = sprintf( __( 'Arrives %s', 'affiliate-wp' ), affwp_date_i18n( (int) $summary['arrival_date'] ) );
	} else {
		$meta_label = __( 'Processing', 'affiliate-wp' );
	}

	return array(
		'amount_label' => $amount_label,
		'meta_label'   => $meta_label,
	);
}

/**
 * Supplies the value for the portal "Next payout" stat card.
 *
 * Mirrors the classic affiliate-area summary: the upcoming amount, with a small sub-line giving the
 * exact arrival date once Stripe has created the Payout, or "Processing" while only the balance
 * projection is known. Returns '' (card hidden via `show_empty`) when there is no upcoming payout.
 *
 * @since 2.34.0
 *
 * @param string $data_key     Unused card data key.
 * @param int    $affiliate_id Current affiliate ID.
 * @return string Card markup, or '' to hide the card.
 */
function affwp_stripe_payouts_portal_summary_card_data( $data_key, $affiliate_id ) {
	if ( empty( $affiliate_id ) || ! affwp_stripe_payouts_get_affiliate_account_id( $affiliate_id ) ) {
		return '';
	}

	$summary = affwp_stripe_payouts_get_affiliate_payout_summary( $affiliate_id );
	if ( empty( $summary ) || empty( $summary['amount'] ) || ( isset( $summary['status'] ) && 'paid' === $summary['status'] ) ) {
		return '';
	}

	$labels = affwp_stripe_payouts_payout_summary_labels( $summary );

	// The stat-card value div is echoed raw, so render the sub-line as a smaller block span to match
	// the classic "Processing"/"Arrives …" meta line beneath the amount.
	return esc_html( $labels['amount_label'] )
		. '<span class="block mt-1 text-sm leading-5 font-normal text-gray-500">' . esc_html( $labels['meta_label'] ) . '</span>';
}

/**
 * Returns the last four digits of the affiliate's connected Stripe bank account.
 *
 * Primary source is webhook-captured data (account.updated). Falls back to a one-time lazy fetch
 * when not yet stored, then cached in affiliate meta.
 *
 * @since 2.34.0
 *
 * @param int $affiliate_id Affiliate ID.
 * @return string The last four digits, or '' if unavailable.
 */
function affwp_stripe_payouts_get_affiliate_bank_last4( $affiliate_id ) {
	$last4 = affwp_get_affiliate_meta( $affiliate_id, 'stripe_bank_last4', true );
	if ( ! empty( $last4 ) ) {
		return (string) $last4;
	}

	// Negative cache: if we recently checked and the account had no bank account, skip the API for a
	// short window so an uncached affiliate doesn't trigger a Stripe call on every dashboard render.
	// A separate timestamp meta (not a sentinel in the last4 value) keeps the value clean and lets a
	// newly-attached bank surface after the window even without an account.updated webhook.
	$checked = (int) affwp_get_affiliate_meta( $affiliate_id, 'stripe_bank_last4_checked', true );
	if ( $checked && ( current_time( 'timestamp' ) - $checked ) < HOUR_IN_SECONDS ) {
		return '';
	}

	$account_id = affwp_stripe_payouts_get_affiliate_account_id( $affiliate_id );
	if ( empty( $account_id ) || ! affwp_stripe_payouts_init_api() ) {
		return '';
	}

	try {
		$external = \Stripe\Account::allExternalAccounts( $account_id, array( 'object' => 'bank_account', 'limit' => 1 ) );
		if ( ! empty( $external->data[0]->last4 ) ) {
			$last4 = $external->data[0]->last4;
			affwp_update_affiliate_meta( $affiliate_id, 'stripe_bank_last4', $last4 );
		} else {
			// API succeeded but no bank account yet: stamp the negative check so we skip the API for a
			// while. The account.updated webhook overwrites stripe_bank_last4 once a bank is attached.
			affwp_update_affiliate_meta( $affiliate_id, 'stripe_bank_last4_checked', current_time( 'timestamp' ) );
		}
	} catch ( \Exception $e ) {
		// Don't stamp on error — retry on the next render.
		affwp_stripe_payouts_log_error( 'Failed to fetch affiliate bank last4: ' . $e->getMessage() );
	}

	return is_string( $last4 ) ? $last4 : '';
}

/**
 * Clear all affiliate Stripe connections
 *
 * Used when platform account changes and admin chooses to clear connections.
 *
 * @since 2.29.0
 * @return int Number of connections cleared
 */
function affwp_stripe_payouts_clear_all_connections() {
	global $wpdb;

	// Count affected users first
	$affected_count = $wpdb->get_var(
		"SELECT COUNT(DISTINCT user_id) FROM {$wpdb->usermeta}
		WHERE meta_key = 'affwp_stripe_payouts_account_id'
		AND meta_value != ''"
	);

	// Delete all Stripe connection metadata
	$wpdb->delete(
		$wpdb->usermeta,
		[ 'meta_key' => 'affwp_stripe_payouts_account_id' ]
	);

	$wpdb->delete(
		$wpdb->usermeta,
		[ 'meta_key' => 'affwp_stripe_payouts_pending_account_id' ]
	);

	$wpdb->delete(
		$wpdb->usermeta,
		[ 'meta_key' => 'affwp_stripe_payouts_account_status' ]
	);

	$wpdb->delete(
		$wpdb->usermeta,
		[ 'meta_key' => 'affwp_stripe_payouts_connected' ]
	);

	// Log the action
	affwp_stripe_payouts_log_info(
		'Cleared all Stripe connections due to platform account change',
		[ 'affected_affiliates' => $affected_count ]
	);

	return $affected_count;
}

/**
 * Check if a specific affiliate connection is valid with current platform
 *
 * @since 2.29.0
 * @param int $affiliate_id The affiliate ID
 * @return bool True if connection is valid, false otherwise
 */
function affwp_stripe_payouts_validate_affiliate_connection( $affiliate_id ) {
	$account_id = affwp_stripe_payouts_get_affiliate_account_id( $affiliate_id );

	if ( empty( $account_id ) ) {
		return false;
	}

	// Initialize Stripe API
	affwp_stripe_payouts_init_api();

	try {
		// Try to retrieve the account
		$account = \Stripe\Account::retrieve( $account_id );

		// Check if account exists and has required capabilities
		if ( $account && ! empty( $account->id ) ) {
			return true;
		}
	} catch ( \Exception $e ) {
		affwp_stripe_payouts_log_error(
			'Failed to validate affiliate connection',
			[
				'affiliate_id' => $affiliate_id,
				'account_id'   => $account_id,
				'error'        => $e->getMessage(),
			]
		);
	}

	return false;
}

/**
 * Check if an affiliate's Stripe connection is still valid with the current platform
 *
 * @since 2.29.0
 * @param int $affiliate_id The affiliate ID
 * @return bool|string Returns 'valid', 'invalid', or 'unknown'
 */
function affwp_stripe_payouts_check_affiliate_platform_status( $affiliate_id ) {
	// Get the affiliate's stored platform ID.
	$user_id = affwp_get_affiliate_user_id( $affiliate_id );
	if ( ! $user_id ) {
		return 'unknown';
	}

	$affiliate_platform_id = get_user_meta( $user_id, 'affwp_stripe_connected_platform_id', true );

	// If no platform ID stored, we can't determine status
	if ( empty( $affiliate_platform_id ) ) {
		// Check if they have an account ID (legacy connection)
		$account_id = get_user_meta( $user_id, 'affwp_stripe_payouts_account_id', true );
		if ( ! empty( $account_id ) ) {
			// Legacy connection, we'll need to validate on first use
			return 'unknown';
		}
		return 'invalid'; // Not connected at all
	}

	// Get the current platform ID
	$current_platform_id = get_option( 'affwp_stripe_platform_account_id', '' );

	if ( empty( $current_platform_id ) ) {
		// Platform not configured
		return 'invalid';
	}

	// Check if they match
	if ( $affiliate_platform_id === $current_platform_id ) {
		return 'valid';
	}

	return 'invalid';
}

/**
 * Store the platform ID for an affiliate when they connect
 *
 * @since 2.29.0
 * @param int    $affiliate_id The affiliate ID
 * @param string $platform_id The platform account ID
 * @return bool
 */
function affwp_stripe_payouts_store_affiliate_platform( $affiliate_id, $platform_id = null ) {
	$user_id = affwp_get_affiliate_user_id( $affiliate_id );
	if ( ! $user_id ) {
		return false;
	}

	// If no platform ID provided, use the current one
	if ( null === $platform_id ) {
		$platform_id = get_option( 'affwp_stripe_platform_account_id', '' );
	}

	if ( empty( $platform_id ) ) {
		return false;
	}

	return update_user_meta( $user_id, 'affwp_stripe_connected_platform_id', $platform_id );
}

/**
 * Clear an affiliate's Stripe connection due to platform change
 *
 * @since 2.29.0
 * @param int $affiliate_id The affiliate ID
 * @return bool
 */
function affwp_stripe_payouts_clear_affiliate_connection( $affiliate_id ) {
	$user_id = affwp_get_affiliate_user_id( $affiliate_id );
	if ( ! $user_id ) {
		return false;
	}

	// Clear all Stripe-related meta
	delete_user_meta( $user_id, 'affwp_stripe_payouts_account_id' );
	delete_user_meta( $user_id, 'affwp_stripe_payouts_connected' );
	delete_user_meta( $user_id, 'affwp_stripe_connected_platform_id' );

	// Clear any cached status
	$account_id = get_user_meta( $user_id, 'affwp_stripe_payouts_account_id', true );
	if ( ! empty( $account_id ) ) {
		delete_transient( 'affwp_stripe_account_status_' . $account_id );
	}

	// Log the disconnection
	affwp_stripe_payouts_log_info( sprintf( 'Affiliate #%d disconnected due to platform change', $affiliate_id ) );

	return true;
}

/**
 * Get Stripe account status with caching
 *
 * @since 2.29.0
 * @param string $account_id The Stripe account ID
 * @return array Account status information with keys: details_submitted, charges_enabled, payouts_enabled
 */
function affwp_stripe_payouts_get_account_status( $account_id ) {
	if ( empty( $account_id ) ) {
		return [ 'details_submitted' => false ];
	}

	// Check cache first (5 minute cache)
	$cache_key     = 'affwp_stripe_account_status_' . $account_id;
	$cached_status = get_transient( $cache_key );

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

	// Default status
	$status = [ 'details_submitted' => false ];

	try {
		// Initialize Stripe API
		if ( affwp_stripe_payouts_init_api() ) {
			$account = \Stripe\Account::retrieve( $account_id );
			$status  = [
				'details_submitted' => $account->details_submitted,
				'charges_enabled'   => $account->charges_enabled,
				'payouts_enabled'   => $account->payouts_enabled,
			];

			// Cache for 5 minutes
			set_transient( $cache_key, $status, 5 * MINUTE_IN_SECONDS );
		}
	} catch ( Exception $e ) {
		// Log error but don't break the UI
		affwp_stripe_payouts_log_error( 'Error retrieving account status for ' . $account_id . ': ' . $e->getMessage() );
	}

	return $status;
}

/**
 * Check and handle platform change for all affiliates
 *
 * @since 2.29.0
 * @param string $old_platform_id The old platform account ID
 * @param string $new_platform_id The new platform account ID
 * @return int Number of affiliates affected
 */
function affwp_stripe_payouts_handle_platform_change( $old_platform_id, $new_platform_id ) {
	global $wpdb;

	// Get all affiliates connected to the old platform
	$connected_users = $wpdb->get_col(
		$wpdb->prepare(
			"SELECT user_id FROM {$wpdb->usermeta}
			WHERE meta_key = 'affwp_stripe_connected_platform_id'
			AND meta_value = %s",
			$old_platform_id
		)
	);

	// Also get legacy connections (no platform ID stored)
	$legacy_users = $wpdb->get_col(
		"SELECT user_id FROM {$wpdb->usermeta}
		WHERE meta_key = 'affwp_stripe_payouts_account_id'
		AND meta_value != ''
		AND user_id NOT IN (
			SELECT user_id FROM {$wpdb->usermeta}
			WHERE meta_key = 'affwp_stripe_connected_platform_id'
		)"
	);

	$all_affected_users = array_unique( array_merge( $connected_users, $legacy_users ) );
	$affected_count     = 0;

	foreach ( $all_affected_users as $user_id ) {
		$affiliate_id = affwp_get_affiliate_id( $user_id );
		if ( $affiliate_id ) {
			affwp_stripe_payouts_clear_affiliate_connection( $affiliate_id );
			++$affected_count;
		}
	}

	// Log the platform change
	affwp_stripe_payouts_log_info(
		sprintf(
			'Platform changed from %s to %s. %d affiliates disconnected.',
			$old_platform_id,
			$new_platform_id,
			$affected_count
		)
	);

	return $affected_count;
}

/**
 * Check affiliate payment capabilities
 *
 * Checks if an affiliate can receive transfers and/or payouts.
 * This provides more granular information than just checking disabled_reason.
 *
 * @since 2.29.0
 * @param int $affiliate_id The affiliate ID
 * @return array|WP_Error Array with capability status or WP_Error on failure
 *                        Return array structure:
 *                        - can_receive_transfers: bool - Whether affiliate can receive transfers
 *                        - can_payout: bool - Whether affiliate can withdraw to bank
 *                        - disabled_reason: string|null - Reason for any restrictions
 *                        - charges_enabled: bool - Raw Stripe charges_enabled status
 *                        - payouts_enabled: bool - Raw Stripe payouts_enabled status
 *                        - warning_message: string|null - User-friendly warning message
 *                        - error_message: string|null - User-friendly error message
 */
function affwp_stripe_payouts_check_affiliate_payment_capability( $affiliate_id ) {
	// Check if affiliate is connected
	if ( ! affwp_stripe_payouts_is_affiliate_connected( $affiliate_id ) ) {
		return new WP_Error( 'not_connected', __( 'Affiliate is not connected to Stripe', 'affiliate-wp' ) );
	}

	// Get account ID
	$account_id = affwp_stripe_payouts_get_affiliate_account_id( $affiliate_id );
	if ( empty( $account_id ) ) {
		return new WP_Error( 'no_account', __( 'No Stripe account found for affiliate', 'affiliate-wp' ) );
	}

	// Initialize Stripe API
	affwp_stripe_payouts_init_api();

	// Use transient caching to avoid excessive API calls (30 second cache for near real-time updates)
	$cache_key = 'affwp_stripe_capability_' . $account_id;
	$cached    = get_transient( $cache_key );

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

	try {
		// Retrieve account from Stripe
		$account = \Stripe\Account::retrieve( $account_id );

		// Build capability status array
		$capability_status = [
			'can_receive_transfers' => false,
			'can_payout'            => false,
			'disabled_reason'       => null,
			'charges_enabled'       => false,
			'payouts_enabled'       => false,
			'payments_pausing_soon' => false,
			'payouts_pausing_soon'  => false,
			'future_requirements'   => [],
			'current_deadline'      => null,
			'warning_message'       => null,
			'error_message'         => null,
		];

		// Check raw Stripe capabilities
		$capability_status['charges_enabled'] = ! empty( $account->charges_enabled );
		$capability_status['payouts_enabled'] = ! empty( $account->payouts_enabled );

		// Check for future requirements that will affect capabilities
		if ( ! empty( $account->requirements->future_requirements ) ) {
			$capability_status['future_requirements'] = $account->requirements->future_requirements;

			// Check if future requirements will affect payments/charges
			// Stripe will pause charges/payments if identity or other critical requirements aren't met
			foreach ( $account->requirements->future_requirements as $req ) {
				// Requirements that typically affect payment capabilities
				if ( in_array(
					$req,
					[
						'individual.verification.document',
						'individual.verification.additional_document',
						'individual.id_number',
						'company.verification.document',
						'external_account',
						'tos_acceptance',
					],
					true
				) ) {
					$capability_status['payments_pausing_soon'] = true;
					break;
				}
			}
		}

		// Check current deadline for when requirements are due
		if ( ! empty( $account->requirements->current_deadline ) ) {
			$capability_status['current_deadline'] = $account->requirements->current_deadline;
		}

		// Check if currently_due requirements exist (more urgent than future)
		if ( ! empty( $account->requirements->currently_due ) ) {
			// If there are currently due requirements, payments might pause very soon
			$capability_status['payments_pausing_soon'] = true;
		}

		// Check for disabled reason
		if ( ! empty( $account->requirements->disabled_reason ) ) {
			$capability_status['disabled_reason'] = $account->requirements->disabled_reason;
		}

		// Determine what the affiliate can do
		// Can receive transfers if charges are enabled (or no explicit block on transfers capability)
		if ( $capability_status['charges_enabled'] ) {
			$capability_status['can_receive_transfers'] = true;
		} elseif ( empty( $capability_status['disabled_reason'] ) ||
					! in_array( $capability_status['disabled_reason'], [ 'requirements.past_due', 'rejected.fraud', 'rejected.terms_of_service' ], true ) ) {
			// If there's no disabled reason or it's not a severe one, they might still receive transfers
			// Check the specific transfer capability if available
			if ( isset( $account->capabilities->transfers ) && $account->capabilities->transfers === 'active' ) {
				$capability_status['can_receive_transfers'] = true;
			}
		}

		// Can payout if payouts are enabled
		$capability_status['can_payout'] = $capability_status['payouts_enabled'];

		// Set user-friendly messages based on status
		if ( ! $capability_status['can_receive_transfers'] ) {
			// Cannot receive transfers at all
			$capability_status['error_message'] = sprintf(
				__( 'This affiliate cannot receive payments. Their Stripe account is restricted%s. The affiliate needs to complete their Stripe account setup.', 'affiliate-wp' ),
				$capability_status['disabled_reason'] ? ' (' . $capability_status['disabled_reason'] . ')' : ''
			);
		} elseif ( ! $capability_status['can_payout'] ) {
			// Can receive transfers but cannot payout to bank
			$capability_status['warning_message'] = __( 'This payment will be sent to the affiliate\'s Stripe account, but they cannot withdraw funds to their bank until they complete verification.', 'affiliate-wp' );
		}

		// Add the simplified can_pay flag and reason for easier checking
		$capability_status['can_pay'] = $capability_status['can_receive_transfers'];
		$capability_status['reason']  = $capability_status['error_message'] ?:
			( $capability_status['warning_message'] ?:
			( $capability_status['disabled_reason'] ?: '' ) );

		// Cache the result for 30 seconds for near real-time updates
		set_transient( $cache_key, $capability_status, 30 );

		return $capability_status;

	} catch ( \Exception $e ) {
		affwp_stripe_payouts_log_error( 'Error checking affiliate capabilities: ' . $e->getMessage() );
		return new WP_Error( 'stripe_error', $e->getMessage() );
	}
}

/**
 * Whether the Stripe Payouts settings section should render for a given affiliate.
 *
 * Renders the template into the DOM when EITHER (a) the affiliate's usable
 * (effective) payout method is already 'stripe', OR (b) self-selection is on
 * and Stripe appears in the affiliate-selectable list. The second condition
 * ensures the section is present in the markup for affiliates who could switch
 * to Stripe via the picker — without it, switching the picker from PayPal to
 * Stripe would have nothing to reveal (server didn't render the panel).
 *
 * Actual on-screen visibility is governed client-side by an Alpine `x-show`
 * bound to the picker's LIVE value, so an affiliate currently on PayPal sees
 * no Stripe block until they pick 'stripe' from the dropdown — matching
 * team-reference.md §9's "appears when current selected method is Stripe"
 * promise and avoiding the pre-2.33.0 "dormant block" experience.
 *
 * Disconnect remains admin-mediated from the Stripe Payouts admin tab —
 * affiliates never need a permanent on-page reminder of a connection they're
 * not actively using.
 *
 * @since 2.33.0
 *
 * @param int $affiliate_id Affiliate ID.
 * @return bool
 */
function affwp_stripe_payouts_section_is_relevant_for_affiliate( $affiliate_id ) {

	if ( ! affwp_stripe_payouts_is_configured() ) {
		return false;
	}

	if ( function_exists( 'affwp_get_affiliate_usable_payout_method' )
		&& 'stripe' === affwp_get_affiliate_usable_payout_method( $affiliate_id )
	) {
		return true;
	}

	if ( function_exists( 'affwp_affiliate_payout_picker_is_visible' )
		&& affwp_affiliate_payout_picker_is_visible()
	) {
		$selectable = affwp_get_payout_methods_for_display( 'affiliate' );
		if ( isset( $selectable['stripe'] ) ) {
			return true;
		}
	}

	return false;
}

/**
 * Renders the in-app "add test funds" modal used by the payout preview's Stripe
 * balance-shortfall warning while in test mode.
 *
 * Reuses the affwp_stripe_create_test_charge endpoint (pm_card_bypassPending makes
 * the funds available immediately). The amount is prefilled with the batch shortfall
 * so a single top-up clears the run. On success it re-checks the balance until it
 * reflects the top-up (Stripe's Balance API can lag the charge, bounded retries),
 * then reloads so the preview recomputes and the warning clears.
 *
 * The modal is queued to admin_footer by affwp_modal() and registered by ID, so this
 * is safe to call more than once per request. The Alpine component is printed once.
 *
 * @since 2.34.0
 *
 * @param float $shortfall Amount still needed to clear the batch; prefills the field.
 * @param float $required  Total the batch needs in the site currency; balance poll target.
 * @return void
 */
function affwp_stripe_payouts_render_test_topup_modal( $shortfall = 0, $required = 0 ) {

	if ( ! affwp_stripe_payouts_is_test_mode() ) {
		return;
	}

	$currency = affwp_get_currency();

	// Prefill the full shortfall so a single top-up always clears the batch, clamped to the
	// endpoint's accepted range (1–999,999).
	$prefill = min( 999999, max( 1, round( (float) $shortfall, 2 ) ) );

	$config = array(
		'prefill'      => $prefill,
		'required'     => round( (float) $required, 2 ),
		// Pre-charge available balance; lets the poll target what the top-up can actually reach
		// (available + amount) instead of a total a partial top-up would never hit.
		'available'    => max( 0, round( (float) $required - (float) $shortfall, 2 ) ),
		'currency'     => $currency,
		// Stripe reports zero-decimal balances in whole units, not cents, so the JS divisor must match.
		'zeroDecimal'  => affwp_stripe_payouts_is_zero_decimal_currency( $currency ),
		'nonce'        => wp_create_nonce( 'affwp_stripe_create_test_charge' ),
		'balanceNonce' => wp_create_nonce( 'affwp_stripe_check_balance' ),
		'i18n'         => array(
			'invalid' => __( 'Enter an amount between 1 and 999,999.', 'affiliate-wp' ),
			'failed'  => __( 'Could not add test funds. Please try again.', 'affiliate-wp' ),
			'network' => __( 'Network error. Please try again.', 'affiliate-wp' ),
		),
	);

	$x_data = 'affwpStripeTestTopup(' . wp_json_encode( $config ) . ')';

	ob_start();
	?>
	<div class="space-y-5">
		<p class="m-0 text-sm/6 text-gray-600">
			<?php esc_html_e( 'Add funds to your Stripe test balance so this batch can process. No real money moves in test mode.', 'affiliate-wp' ); ?>
		</p>

		<div>
			<label for="affwp-stripe-topup-amount" class="block mb-1 text-sm font-medium text-gray-700"><?php esc_html_e( 'Amount to add', 'affiliate-wp' ); ?></label>
			<div class="flex items-center rounded-md bg-white pl-3 border border-gray-300 focus-within:ring-2 focus-within:ring-affwp-brand-500 max-w-48">
				<span class="shrink-0 text-sm text-gray-500 select-none"><?php echo esc_html( $currency ); ?></span>
				<input
					id="affwp-stripe-topup-amount"
					type="number"
					min="1"
					max="999999"
					step="0.01"
					x-model.number="amount"
					@keydown.enter.prevent="submit()"
					class="block min-w-0 w-full py-2 pr-3 pl-2 text-sm text-gray-900 bg-transparent border-0 focus:outline-none" />
			</div>
			<p class="mt-1.5 text-xs text-gray-500">
				<?php
				printf(
					/* translators: %s: shortfall amount with currency symbol */
					esc_html__( 'Prefilled with the %s this batch is short.', 'affiliate-wp' ),
					esc_html( html_entity_decode( affwp_currency_filter( affwp_format_amount( $prefill ) ), ENT_COMPAT, 'UTF-8' ) )
				);
				?>
			</p>
		</div>

		<div x-show="error" x-transition x-cloak class="rounded-md bg-red-50 border border-red-200 px-3 py-2">
			<p class="m-0 text-sm text-red-700" x-text="error"></p>
		</div>

	</div>
	<?php
	$content = ob_get_clean();

	affwp_modal(
		array(
			'id'              => 'affwp-stripe-topup-modal',
			'icon'            => 'beaker',
			'title'           => __( 'Add test funds', 'affiliate-wp' ),
			'content'         => $content,
			'size'            => 'sm',
			'variant'         => 'info',
			'close_on_escape' => true,
			'attributes'      => array( 'x-data' => $x_data ),
			'footer_actions'  => array(
				array(
					'text'       => __( 'Cancel', 'affiliate-wp' ),
					'variant'    => 'secondary',
					'action'     => 'close',
					'attributes' => array( 'x-bind:disabled' => 'submitting' ),
				),
				array(
					'text'         => __( 'Add test funds', 'affiliate-wp' ),
					'variant'      => 'primary',
					'alpine_text'  => sprintf(
						"submitting ? '%s' : '%s'",
						esc_js( __( 'Adding…', 'affiliate-wp' ) ),
						esc_js( __( 'Add test funds', 'affiliate-wp' ) )
					),
					'dynamic_icon' => array(
						'name'           => 'loading',
						'show_condition' => 'submitting',
					),
					'attributes'   => array( '@click' => 'submit()', 'x-bind:disabled' => 'submitting' ),
				),
			),
		)
	);

	static $printed_component = false;
	if ( $printed_component ) {
		return;
	}
	$printed_component = true;
	?>
	<script>
	document.addEventListener( 'alpine:init', () => {
		Alpine.data( 'affwpStripeTestTopup', ( config ) => ( {
			amount: config.prefill,
			required: config.required,
			available: config.available,
			target: 0,
			currency: ( config.currency || '' ).toLowerCase(),
			zeroDecimal: !! config.zeroDecimal,
			submitting: false,
			error: '',

			submit() {
				const amount = parseFloat( this.amount );

				if ( ! amount || isNaN( amount ) || amount <= 0 || amount > 999999 ) {
					this.error = config.i18n.invalid;
					return;
				}

				// What the balance should read once this top-up lands: capped at the batch total
				// so overshooting still exits on the first check.
				this.target = Math.min( this.required, this.available + amount );

				this.submitting = true;
				this.error      = '';

				const body = new FormData();
				body.append( 'action', 'affwp_stripe_create_test_charge' );
				body.append( 'nonce', config.nonce );
				body.append( 'amount', amount );

				fetch( ajaxurl, { method: 'POST', body, credentials: 'same-origin' } )
					.then( ( response ) => response.json() )
					.then( ( data ) => {
						if ( ! data || ! data.success ) {
							this.submitting = false;
							this.error      = ( data && data.data && data.data.message ) ? data.data.message : config.i18n.failed;
							return;
						}

						// Funds added. Stripe's Balance API can lag behind the charge, so re-check until it
						// reflects the top-up (bounded), then reload to recompute the preview.
						this.pollBalanceThenReload( 0 );
					} )
					.catch( () => {
						this.submitting = false;
						this.error      = config.i18n.network;
					} );
			},

			pollBalanceThenReload( attempt ) {
				const delays = [ 400, 800, 1500, 2500, 4000 ];

				const body = new FormData();
				body.append( 'action', 'affwp_stripe_check_balance' );
				body.append( 'nonce', config.balanceNonce );

				fetch( ajaxurl, { method: 'POST', body, credentials: 'same-origin' } )
					.then( ( response ) => response.json() )
					.then( ( data ) => {
						if ( this.balanceCovers( data ) || attempt >= delays.length ) {
							window.location.reload();
							return;
						}
						setTimeout( () => this.pollBalanceThenReload( attempt + 1 ), delays[ attempt ] );
					} )
					.catch( () => window.location.reload() );
			},

			balanceCovers( data ) {
				if ( ! data || ! data.success || ! data.data || ! data.data.balance ) {
					return false;
				}

				const available = data.data.balance.available || [];

				const divisor = this.zeroDecimal ? 1 : 100;

				for ( const bucket of available ) {
					if ( ( bucket.currency || '' ).toLowerCase() === this.currency ) {
						return ( bucket.amount / divisor ) >= this.target;
					}
				}

				return false;
			},
		} ) );
	} );
	</script>
	<?php
}

