<?php

namespace SplitPay\Core;

use SplitPay\Contracts\OrderAdapterInterface;
use SplitPay\Contracts\GatewayAdapterInterface;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class TransferEngine {

	private StripeService $stripe;
	private TransferLogger $logger;
	private ConnectedAccounts $accounts;

	/**
	 * Per-order audit context populated at the top of processTransfers() and
	 * consumed by recordResult() so every log row carries the full schema
	 * (stripe_mode, charge_*, date_* etc.). 3.7.3 (sc-7168).
	 *
	 * @var string
	 */
	private string $stripeMode = 'live';
	private float  $chargeAmount = 0.0;
	private string $chargeDate = '';
	private string $chargeDescription = '';

	/**
	 * Multiset of sub-transfers that ALREADY succeeded for the current order,
	 * keyed by "destination|amountInCents" => count. Built per-run from the transfer
	 * log so executeTransfer() never re-pays a destination already paid — making
	 * manual + automatic Retry idempotent. 3.7.5 (sc-RETRY-IDEMPOTENCY).
	 *
	 * @var array<string,int>
	 */
	private array $succeededTransfers = [];

	public function __construct( StripeService $stripe, TransferLogger $logger, ConnectedAccounts $accounts ) {
		$this->stripe   = $stripe;
		$this->logger   = $logger;
		$this->accounts = $accounts;
	}

	/**
	 * Acquire a per-order MySQL named lock around a unit of work and release it
	 * on every exit path. Returns the work() result if the lock is acquired, or
	 * a synthetic 'already_in_progress' result row if another connection holds
	 * the lock right now.
	 *
	 * sc-CONCURRENCY (2026-05-24, payments-grade hardening): without this,
	 * two simultaneous webhook deliveries for the same order both pass
	 * isTransferProcessed()=false at the same instant and both fire transfers
	 * — vendor gets paid 2× and the platform eats the loss.
	 *
	 * GET_LOCK semantics:
	 *   - Connection-scoped — a single PHP-FPM worker can re-enter (we don't,
	 *     but the property is reassuring).
	 *   - Returns 1 on acquire, 0 on timeout (we use timeout=0 = "try once").
	 *   - RELEASE_LOCK in finally guarantees release on any exception path.
	 *   - Survives across PHP-FPM workers because each gets a distinct MySQL
	 *     connection — so concurrent webhook deliveries genuinely serialize
	 *     even if they hit different PHP processes.
	 *
	 * @param int $orderId
	 * @param callable():array $work
	 * @return array
	 */
	private function withOrderLock( int $orderId, callable $work ): array {
		global $wpdb;
		if ( ! isset( $wpdb ) ) {
			// Unit-test environment without wpdb: skip locking, run work directly.
			return $work();
		}

		$lockName = 'spp_xfer_' . $orderId;
		$got      = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lockName ) );

		if ( 1 !== $got ) {
			self::log( sprintf(
				'Order %d: another worker holds the transfer lock — skipping this delivery (Stripe will not retry, the other worker is handling it).',
				$orderId
			) );
			// audit P2-4 — concurrent-race spec asserts exactly-one of
			// the parallel webhooks fired transfers; this branch is the
			// loser side, observable via the counter.
			if ( function_exists( 'do_action' ) ) {
				do_action( 'pts_increment_counter', 'transfer_lock_contended' );
			}
			return [ [ 'status' => 'already_in_progress', 'order_id' => $orderId ] ];
		}

		// audit P2-4 — winner side of the race lock.
		if ( function_exists( 'do_action' ) ) {
			do_action( 'pts_increment_counter', 'transfer_lock_acquired' );
		}

		try {
			return $work();
		} finally {
			$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lockName ) );
			if ( function_exists( 'do_action' ) ) {
				do_action( 'pts_increment_counter', 'transfer_lock_released' );
			}
		}
	}

	/**
	 * READ (without advancing) the per-order Stripe idempotency-attempt salt.
	 *
	 * Returns the current attempt counter, defaulting to 1 (so the first batch
	 * uses `a1`) when no `_spp_idem_attempt` meta exists yet. It does NOT write —
	 * advancing the salt is the exclusive job of a genuine retry (see
	 * bumpAttemptSalt()).
	 *
	 * 3.7.5 (sc-DUP-XFER): this read-only behaviour is the crux of the duplicate-
	 * transfer fix. The salt MUST stay identical across a double-fire of the same
	 * operation so both deliveries send the same Stripe idempotency key and Stripe
	 * dedupes the replay. Pre-3.7.5 the engine BUMPED this counter on every
	 * processTransfers() call, so two fires got two different keys and Stripe
	 * created two real transfers (order 2288 double-pay).
	 *
	 * @return int Current attempt number (>=1).
	 */
	private function readAttemptSalt( OrderAdapterInterface $orderAdapter ): int {
		$current = (int) $orderAdapter->getOrderMeta( '_spp_idem_attempt' );
		return $current >= 1 ? $current : 1;
	}

	/**
	 * ADVANCE the per-order Stripe idempotency-attempt salt — called ONLY when a
	 * genuine retry is initiated (manual "Retry Split Pay Transfers" order action
	 * or the automatic all-fail Action-Scheduler retry), where the done-flag is
	 * also cleared. Persisted in order meta `_spp_idem_attempt` so the retry's
	 * sub-transfer idempotency keys differ from the prior attempt's.
	 *
	 * Why a retry needs a FRESH key (and a double-fire must NOT advance):
	 * Stripe's v1 Transfers API caches the response for an idempotency key — a
	 * repeated key replays the saved response, INCLUDING a saved error. So a
	 * retry of a sub-transfer that previously FAILED must use a new key to
	 * actually re-execute; while two deliveries of the SAME (not-yet-retried)
	 * operation must reuse the key so Stripe returns the original transfer (or
	 * idempotency_key_in_use) instead of duplicating. Production evidence for the
	 * retry side: levinarf.com order 2166 on 2026-05-23 — three transfers failed
	 * on first attempt (destination-capability errors), the order moved
	 * Processing → Completed, SPP retried; with a fresh salt the retry re-executes
	 * cleanly instead of replaying the cached failure.
	 *
	 * Public so the WooCommerce/FluentCart retry entry points can advance the salt
	 * at the exact moment they clear the done-flag.
	 *
	 * @return string e.g. "a2", "a3". Always non-empty.
	 */
	public function bumpAttemptSalt( OrderAdapterInterface $orderAdapter ): string {
		$current = $this->readAttemptSalt( $orderAdapter );
		$next    = $current + 1;
		try {
			$orderAdapter->setOrderMeta( '_spp_idem_attempt', (string) $next );
		} catch ( \Throwable $e ) {
			// Non-fatal: if persistence fails we still proceed with the
			// computed salt — the worst case is a future retry collision,
			// which the caller already logs and surfaces to the order notes.
			self::log( sprintf(
				'bumpAttemptSalt: could not persist _spp_idem_attempt for order %d: %s',
				$orderAdapter->getOrderId(),
				$e->getMessage()
			) );
		}
		return 'a' . $next;
	}

	public function processTransfers(
		OrderAdapterInterface $orderAdapter,
		GatewayAdapterInterface $gateway,
		string $integrationSource = 'woocommerce',
		?string $chargeIdOverride = null
	): array {
		$orderId = $orderAdapter->getOrderId();

		// Extensibility (3.7.5): early veto for the order→transfer entry point.
		// Evaluated before the per-order lock is acquired and before any side
		// effect (charge lookup, transfer creation, log writes). Default is
		// true, so with no callback attached every order is processed exactly
		// as today; a callback returning false makes the engine a no-op for
		// that order (returns an empty result set, like an order with nothing
		// to transfer). Lets integrators skip split processing for specific
		// orders (e.g. by gateway, customer, or custom meta) without forking.
		if ( function_exists( 'apply_filters' )
			&& ! apply_filters( 'spp_should_process_order', true, $orderId ) ) {
			return [];
		}

		return $this->withOrderLock( $orderId, function () use ( $orderAdapter, $gateway, $integrationSource, $chargeIdOverride, $orderId ) {
			return $this->processTransfersLocked( $orderAdapter, $gateway, $integrationSource, $chargeIdOverride, $orderId );
		} );
	}

	private function processTransfersLocked(
		OrderAdapterInterface $orderAdapter,
		GatewayAdapterInterface $gateway,
		string $integrationSource,
		?string $chargeIdOverride,
		int $orderId
	): array {
		$results = [];

		// 3.7.5 (sc-DUP-XFER): the order-processed guard is now checked INSIDE the
		// per-order lock (this method runs under withOrderLock), and it is read
		// FRESH from the database — bypassing the persistent object cache (Redis on
		// Cloudways) that served a stale `is_transfer_process_done=0` to the second
		// of two near-simultaneous webhook deliveries. The pre-3.7.5 code read this
		// guard OUTSIDE the lock at WooCommerceIntegration.php:305 from a cached
		// wc_get_order() object, so both fires passed it and both ran transfers.
		// Production: Yuliana / levinarf.com order 2288 fired 5 transfers for 3
		// vendors. The fresh read is provided by the adapter's optional
		// isTransferProcessedFresh() (WCOrderAdapter / FCOrderAdapter); doubles
		// without it fall back to the cached read.
		if ( method_exists( $orderAdapter, 'isTransferProcessedFresh' ) ) {
			if ( $orderAdapter->isTransferProcessedFresh() ) {
				return [ [ 'status' => 'already_processed', 'order_id' => $orderId ] ];
			}
		} elseif ( $orderAdapter->isTransferProcessed() ) {
			return [ [ 'status' => 'already_processed', 'order_id' => $orderId ] ];
		}

		// 3.7.5 (sc-DUP-XFER / supersedes sc-YA8): READ — do NOT bump — the
		// per-order Stripe-idempotency attempt salt at the start of a batch.
		//
		// THE FIX FOR THE DOUBLE-PAY: the salt MUST stay STABLE across a
		// double-fire of the SAME operation so the two deliveries send the SAME
		// idempotency key and Stripe's v1 Transfers API dedupes the replay
		// (returns the original transfer, or raises idempotency_key_in_use while
		// the first is in flight — handled as already-created in StripeService).
		// Pre-3.7.5 this bumped `_spp_idem_attempt` on EVERY processTransfers()
		// call, so two fires for one order produced DIFFERENT keys (…_a1 vs …_a2)
		// and Stripe created two real transfers — order 2288's double-pay.
		//
		// The salt still advances on a GENUINE retry, but that bump now happens
		// where a retry is actually initiated — WooCommerceIntegration::retryTransfers()
		// / retryTransfersById() (and the FluentCart equivalent), right where the
		// done-flag is cleared — NOT here. A retry of a previously-FAILED
		// sub-transfer therefore gets a FRESH key (Stripe v1 replays a cached
		// FAILURE for a reused key, so a failed transfer needs a new key to
		// re-execute), while a benign double-fire reuses the key and cannot
		// double-pay. The ledger dedup below still skips destinations already paid.
		$attemptSalt = 'a' . $this->readAttemptSalt( $orderAdapter );

		$chargeId = ( $chargeIdOverride !== null && $chargeIdOverride !== '' )
			? $chargeIdOverride
			: $gateway->resolveChargeId( null );
		if ( empty( $chargeId ) ) {
			$orderAdapter->addOrderNote( 'Split Pay: Could not resolve charge ID for this order.' );
			return [ [ 'status' => 'error', 'message' => 'Could not resolve charge ID.' ] ];
		}

		// 3.7.3 (sc-7168): capture charge audit fields + Stripe mode for the
		// Transfers tab. Stored on $this so recordResult() can stamp every
		// log row with the full schema (legacy parity with
		// class-bsd-...::transfer_controller).
		// PT-fix-3.7.5 (security #7): pass the actual key the engine uses
		// for the Stripe API call so the log row's stripe_mode reflects
		// the REAL key, not whatever WC's gateway-settings option happens
		// to say. Prefer the transfer-specific key (the SPP per-gateway
		// override) over the gateway's general key — that's what actually
		// drives the StripeService instance for transfer creation.
		$gatewaySecretKey = '';
		if ( method_exists( $gateway, 'getTransferSecretKey' ) ) {
			$gatewaySecretKey = (string) $gateway->getTransferSecretKey();
		}
		if ( $gatewaySecretKey === '' && method_exists( $gateway, 'getSecretKey' ) ) {
			$gatewaySecretKey = (string) $gateway->getSecretKey();
		}
		$this->stripeMode        = TransferLogger::resolveStripeMode( $gatewaySecretKey );
		$this->chargeAmount      = 0.0;
		$this->chargeDate        = '';
		$this->chargeDescription = '';
		try {
			$charge = $this->stripe->retrieveCharge( $chargeId );
			if ( $charge->status !== 'succeeded' ) {
				$paymentMethod = $charge->payment_method_details->type ?? 'unknown';
				$orderAdapter->addOrderNote(
					sprintf(
						'Split Pay: Charge status is "%s" (payment method: %s). Transfers deferred.',
						$charge->status,
						$paymentMethod
					)
				);
				return [ [ 'status' => 'charge_not_succeeded', 'charge_status' => $charge->status ] ];
			}

			// Stripe amounts are in the smallest currency unit; convert to major
			// units to match how the legacy log writes them (see
			// class-bsd-...:2752-2767).
			$this->chargeAmount      = isset( $charge->amount ) ? ( (int) $charge->amount ) / 100 : 0.0;
			$this->chargeDate        = isset( $charge->created ) ? gmdate( 'Y-m-d H:i:s', (int) $charge->created ) : '';
			$this->chargeDescription = $charge->description ?? '';
		} catch ( \Exception $e ) {
			self::log( 'Could not verify charge status: ' . $e->getMessage() );
		}

		// 3.7.5 (sc-RETRY-IDEMPOTENCY): a manual or automatic Retry re-runs this whole
		// method, and bumpAttemptSalt() above gives every sub-transfer a NEW Stripe
		// idempotency key — so without a guard a re-run would mint fresh transfers for
		// destinations that already succeeded (double-pay). Build a multiset of the
		// already-succeeded sub-transfers (destination + amount-in-cents) from the log;
		// executeTransfer() consults it and skips any sub-transfer already paid, so a
		// retry only re-attempts the ones that have NOT yet succeeded. Count-keyed, so
		// two legitimately-equal (destination, amount) sub-transfers are handled too.
		// Built only once the charge is confirmed succeeded (after the empty-charge and
		// charge-not-succeeded early returns): an order that bails out early skips this
		// log read entirely, which is also why those guard paths never touch the log.
		// 3.7.5 (currency-safe idempotency): the multiset key MUST use the same
		// smallest-currency-unit basis executeTransfer() looks up with (its $amount
		// is already in Stripe minor units). A hardcoded * 100 only matched two-decimal
		// currencies; for zero-decimal (JPY) / three-decimal (KWD) the key diverged from
		// the lookup so the dedupe missed and a retry RE-PAID the vendor. toStripeAmount()
		// converts per the currency's actual decimals (x1 / x100 / x1000).
		$spp_idem_currency        = strtolower( $orderAdapter->getCurrency() ) ?: 'usd';
		$this->succeededTransfers = [];
		foreach ( $this->logger->getTransfersByOrderId( $orderId ) as $spp_row ) {
			if ( empty( $spp_row['transfer_id'] ) ) {
				continue;
			}
			$spp_cents = CurrencyHelper::toStripeAmount( (float) ( $spp_row['transfer_amount'] ?? 0 ), $spp_idem_currency );
			$spp_key   = ( $spp_row['transfer_destination'] ?? '' ) . '|' . $spp_cents;
			$this->succeededTransfers[ $spp_key ] = ( $this->succeededTransfers[ $spp_key ] ?? 0 ) + 1;
		}

		$platformAccountId = '';
		try {
			$platformAccountId = $this->stripe->retrieveAccount( 'self' )->id;
		} catch ( \Exception $e ) {
			self::log( 'Could not retrieve platform account ID: ' . $e->getMessage() );
		}

		$sendingMeta   = get_option( 'sending_meta', '0' );
		$transferTaxes = get_option( 'transfer_taxes', '0' );
		$taxType       = get_option( 'tax_transfer_type', 'partial' );
		$couponAdjust  = get_option( 'coupon_adjust_transfers', '0' );

		$settings = [
			'transfer_taxes'          => $transferTaxes,
			'tax_transfer_type'       => $taxType,
			'coupon_adjust_transfers' => $couponAdjust,
		];

		$calculator    = new TransferCalculator( $orderAdapter, $settings );
		$globalAccounts = $this->accounts->getGlobalAccounts();
		$plan          = $calculator->calculate( $globalAccounts );

		$currency     = strtolower( $orderAdapter->getCurrency() ) ?: 'usd';
		$customerInfo = $orderAdapter->getCustomerInfo();
		$cusName      = trim( ( $customerInfo['first_name'] ?? '' ) . ' ' . ( $customerInfo['last_name'] ?? '' ) );
		$cusEmail     = $customerInfo['email'] ?? '';
		$description  = $cusName . ' - Order #' . $orderId;

		$baseMeta = [];
		if ( $sendingMeta === '1' ) {
			$baseMeta = [
				'Site Name'      => function_exists( 'get_bloginfo' ) ? get_bloginfo( 'name' ) : '',
				'Site Url'       => function_exists( 'get_bloginfo' ) ? get_bloginfo( 'url' ) : '',
				'Order Id'       => $orderId,
				'Customer Name'  => $cusName,
				'Customer Email' => $cusEmail,
			];
		}

		$index                  = 0;
		$globalAmount           = $plan['total_global_transfer_amount'];
		$originalGlobalAmount   = $globalAmount; // 3.7.0 fix: snapshot the pool so percentages
		                                          // compute against the original total (legacy
		                                          // semantics in class-bsd-...:1556-1557), not
		                                          // against the post-decrement value.
		$poolRemaining          = $globalAmount;  // running tracker only for early-exit /
		                                          // fixed-amount overdraw protection.
		$globalTax              = $plan['global_tax_total'];
		$allTaxesArray          = $plan['all_taxes_array'];

		// Detect over-configured global percentages (sum > 100). When that happens we
		// proportionally scale each percentage so transfers sum to 100% of the eligible
		// pool. Below 100% we don't scale up — leftover stays on the platform.
		$percentageScalingFactor = self::computePercentageScalingFactor(
			$globalAccounts,
			'bsd_spscwt_type',
			'bsd_spscwt_percentage_amount'
		);
		if ( $percentageScalingFactor < 1.0 ) {
			$rawSum = self::sumAccountPercentages(
				$globalAccounts,
				'bsd_spscwt_type',
				'bsd_spscwt_percentage_amount'
			);
			self::log( sprintf(
				'Order %d: multi-account global percentages sum to %s%% (>100). Scaling each percentage by factor %s so transfers sum to 100%% of the eligible global amount. Configure individual account percentages to sum to <=100%% to avoid this scaling.',
				$orderId,
				number_format( $rawSum, 2 ),
				number_format( $percentageScalingFactor, 4 )
			) );
		}

		$storeTotalTransferred = $this->precomputeGlobalTotal( $globalAccounts, $originalGlobalAmount );

		foreach ( $globalAccounts as $acct ) {
			if ( $poolRemaining <= 0 ) {
				break;
			}

			$accountId   = $acct['bsd_connected_account_id'] ?? '';
			$acctType    = $acct['bsd_spscwt_type'] ?? 'percentage';
			$acctValue   = (float) ( $acct['bsd_spscwt_percentage_amount'] ?? 0 );
			$accountTax  = 0.0;

			if ( $acctType === 'percentage' ) {
				$factor    = ( $acctValue / 100 ) * $percentageScalingFactor;
				$xferAmt   = self::computeGlobalPercentageShare( $originalGlobalAmount, $acctValue, $percentageScalingFactor );
				$accountTax = $this->computeGlobalTax( $transferTaxes, $taxType, $globalTax, $factor, $xferAmt, $storeTotalTransferred, count( $globalAccounts ) );
			} else {
				$xferAmt = $acctValue;
				if ( $xferAmt > $poolRemaining ) {
					break;
				}
				$accountTax = $this->computeGlobalTaxFixed( $transferTaxes, $taxType, $globalTax, $xferAmt, $originalGlobalAmount, $storeTotalTransferred, count( $globalAccounts ) );
			}

			if ( $xferAmt <= 0 ) {
				++$index;
				continue;
			}

			$stripeAmount = $this->toStripeTransferAmount( $xferAmt, $accountTax, $transferTaxes, $currency );
			if ( $stripeAmount < $this->minimumStripeTransferAmount( $accountId, $orderId ) ) {
				++$index;
				continue;
			}

			$metadata = $baseMeta;
			if ( $transferTaxes === '1' ) {
				$metadata['account_wise_tax'] = $accountTax;
			}
			$metadata = apply_filters( 'spp_global_account_wise_meta_data', $metadata, $orderId, $accountId );

			$result = $this->executeTransfer(
				$stripeAmount, $currency, $chargeId, $accountId, $description, $metadata,
				'spp_' . $orderId . '_' . $accountId . '_g' . ( $acctType === 'percentage' ? 'p' : 'a' ) . '_' . $index . '_' . $attemptSalt
			);

			$label = $acctType === 'percentage'
				? 'Global Transfer (' . $acctValue . '%)'
				: 'Global Transfer ($' . $acctValue . ' fixed)';

			$this->recordResult(
				$result, $orderAdapter, $orderId, $accountId, $chargeId, $xferAmt, $label,
				$platformAccountId, $integrationSource, $results,
				[
					// 3.7.3 (sc-7168): audit columns for the Transfers tab.
					'transfer_type'          => $acctType === 'percentage' ? 1 : 0,
					'transfer_entered_value' => $acctValue,
					'transfer_tax_value'     => $accountTax,
					'item_total'             => $originalGlobalAmount,
					'item_transfer_amount'   => $xferAmt,
				]
			);

			// poolRemaining is a running tracker for the SUM of intended transfers (used to
			// detect overdraw and stop further fixed-amount transfers). Percentage transfers
			// always compute against $originalGlobalAmount above, so the math doesn't depend
			// on this decrement.
			$poolRemaining -= $xferAmt;

			++$index;
		}

		foreach ( $plan['product_transfers'] as $pt ) {
			$xferAmt    = $pt['transfer_amount'];
			$accountTax = $pt['account_wise_tax'];
			$accountId  = $pt['connected_acc_id'];

			if ( $xferAmt <= 0 ) {
				++$index;
				continue;
			}

			$stripeAmount = $this->toStripeTransferAmount( $xferAmt, $accountTax, $transferTaxes, $currency );
			if ( $stripeAmount < $this->minimumStripeTransferAmount( $accountId, $orderId ) ) {
				++$index;
				continue;
			}

			$metadata = [];
			if ( $sendingMeta === '1' ) {
				$metadata = array_merge( $baseMeta, [
					'product_id'                    => $pt['product_id'],
					'transfer_type'                 => $pt['transfer_type'],
					'transfer_percentage_or_amount' => $pt['transfer_percentage_or_amount'],
					'account_wise_tax'              => $accountTax,
				] );
			}
			$metadata = apply_filters( 'spp_product_wise_meta_data', $metadata, $orderId, $accountId );

			$result = $this->executeTransfer(
				$stripeAmount, $currency, $chargeId, $accountId, $description, $metadata,
				'spp_' . $orderId . '_' . $accountId . '_pp_' . $index . '_' . $attemptSalt
			);

			$suffix = $pt['transfer_type'] === 'percentage'
				? $pt['transfer_percentage_or_amount'] . '%'
				: '$' . $pt['transfer_percentage_or_amount'] . ' fixed';
			$label  = 'Product #' . $pt['product_id'] . ' Transfer (' . $suffix . ')';

			$this->recordResult(
				$result, $orderAdapter, $orderId, $accountId, $chargeId, $xferAmt, $label,
				$platformAccountId, $integrationSource, $results,
				[
					'transfer_type'          => $pt['transfer_type'] === 'percentage' ? 1 : 0,
					'transfer_entered_value' => (float) $pt['transfer_percentage_or_amount'],
					'transfer_tax_value'     => (float) $accountTax,
					'item_total'             => (float) ( $pt['item_total'] ?? 0 ),
					'item_transfer_amount'   => (float) $xferAmt,
					'item_tax_total'         => (float) ( $pt['item_tax_total'] ?? 0 ),
				]
			);
			++$index;
		}

		$globalShipAmount          = $plan['global_shipping_amount'];
		$originalGlobalShipAmount  = $globalShipAmount; // 3.7.0 fix: snapshot, mirrors the
		                                                 // global-transfer fix above.
		$shipPoolRemaining         = $globalShipAmount;
		$totalShippingTax          = $this->sumShippingTax( $allTaxesArray );

		// Same proportional-scaling defense for shipping percentages summing >100%.
		$shipPercentageScalingFactor = self::computePercentageScalingFactor(
			$globalAccounts,
			'bsd_global_shipping_type',
			'bsd_global_shipping_percentage_amount'
		);
		if ( $shipPercentageScalingFactor < 1.0 ) {
			$rawShipSum = self::sumAccountPercentages(
				$globalAccounts,
				'bsd_global_shipping_type',
				'bsd_global_shipping_percentage_amount'
			);
			self::log( sprintf(
				'Order %d: multi-account global shipping percentages sum to %s%% (>100). Scaling each percentage by factor %s.',
				$orderId,
				number_format( $rawShipSum, 2 ),
				number_format( $shipPercentageScalingFactor, 4 )
			) );
		}

		$storeTotalShipTransferred = $this->precomputeGlobalShippingTotal( $globalAccounts, $originalGlobalShipAmount );

		foreach ( $globalAccounts as $acct ) {
			if ( $shipPoolRemaining <= 0 ) {
				break;
			}

			$accountId = $acct['bsd_connected_account_id'] ?? '';
			$shipType  = $acct['bsd_global_shipping_type'] ?? '';
			$shipValue = (float) ( $acct['bsd_global_shipping_percentage_amount'] ?? 0 );

			if ( empty( $shipType ) || empty( $shipValue ) ) {
				++$index;
				continue;
			}

			$accountTax = 0.0;
			$xferAmt    = 0.0;

			if ( $shipType === 'percentage' ) {
				$factor  = ( $shipValue / 100 ) * $shipPercentageScalingFactor;
				$xferAmt = self::computeGlobalPercentageShare( $originalGlobalShipAmount, $shipValue, $shipPercentageScalingFactor );
				$accountTax = $this->computeShippingTax( $transferTaxes, $taxType, $totalShippingTax, $factor, $xferAmt, $storeTotalShipTransferred, count( $globalAccounts ) );

				if ( $xferAmt > $shipPoolRemaining ) {
					++$index;
					continue;
				}
			} else {
				$xferAmt = $shipValue;
				$accountTax = $this->computeShippingTaxFixed( $transferTaxes, $taxType, $totalShippingTax, $xferAmt, $storeTotalShipTransferred, count( $globalAccounts ) );

				if ( $xferAmt > $shipPoolRemaining ) {
					++$index;
					continue;
				}
			}

			if ( $xferAmt <= 0 ) {
				++$index;
				continue;
			}

			$stripeAmount = $this->toStripeTransferAmount( $xferAmt, $accountTax, $transferTaxes, $currency );
			if ( $stripeAmount < $this->minimumStripeTransferAmount( $accountId, $orderId ) ) {
				++$index;
				continue;
			}

			$metadata = $baseMeta;
			if ( $transferTaxes === '1' ) {
				$metadata['account_wise_tax'] = $accountTax;
			}

			$filterHook = $shipType === 'percentage'
				? 'spp_shipping_transfer_meta_data'
				: 'spp_global_shipping_transfer_meta_data';
			$metadata = apply_filters( $filterHook, $metadata, $orderId, $accountId );

			$idempSuffix = $shipType === 'percentage' ? 'gsp' : 'gsa';
			$result = $this->executeTransfer(
				$stripeAmount, $currency, $chargeId, $accountId, $description, $metadata,
				'spp_' . $orderId . '_' . $accountId . '_' . $idempSuffix . '_' . $index . '_' . $attemptSalt
			);

			$suffix = $shipType === 'percentage'
				? $shipValue . '%'
				: '$' . $shipValue . ' fixed';
			$label  = 'Global Shipping Transfer (' . $suffix . ')';

			$this->recordResult(
				$result, $orderAdapter, $orderId, $accountId, $chargeId, $xferAmt, $label,
				$platformAccountId, $integrationSource, $results,
				[
					'transfer_type'          => $shipType === 'percentage' ? 1 : 0,
					'transfer_entered_value' => $shipValue,
					'transfer_tax_value'     => $accountTax,
					'item_total'             => $originalGlobalShipAmount,
					'item_transfer_amount'   => $xferAmt,
				]
			);

			// shipPoolRemaining is for early-exit / fixed-amount overdraw protection only;
			// percentage transfers always compute against $originalGlobalShipAmount above.
			$shipPoolRemaining -= $xferAmt;

			++$index;
		}

		foreach ( $plan['product_shipping_transfers'] as $pst ) {
			$xferAmt   = $pst['transfer_amount'];
			$accountId = $pst['connected_acc_id'];

			if ( $xferAmt <= 0 ) {
				++$index;
				continue;
			}

			$stripeAmount = CurrencyHelper::toStripeAmount( $xferAmt, $currency );
			if ( $stripeAmount < $this->minimumStripeTransferAmount( $accountId, $orderId ) ) {
				++$index;
				continue;
			}

			$metadata = [];
			if ( $sendingMeta === '1' ) {
				$metadata = array_merge( $baseMeta, [
					'product_id'                    => $pst['product_id'],
					'transfer_type'                 => $pst['transfer_type'],
					'transfer_percentage_or_amount' => $pst['transfer_percentage_or_amount'],
				] );
			}
			$metadata = apply_filters( 'spp_product_wise_shipping_transfer_meta_data', $metadata, $orderId, $accountId );

			$result = $this->executeTransfer(
				$stripeAmount, $currency, $chargeId, $accountId, $description, $metadata,
				'spp_' . $orderId . '_' . $accountId . '_psp_' . $index . '_' . $attemptSalt
			);

			$suffix = $pst['transfer_type'] === 'percentage'
				? $pst['transfer_percentage_or_amount'] . '%'
				: '$' . $pst['transfer_percentage_or_amount'] . ' fixed';
			$label  = 'Product #' . $pst['product_id'] . ' Shipping Transfer (' . $suffix . ')';

			$this->recordResult(
				$result, $orderAdapter, $orderId, $accountId, $chargeId, $xferAmt, $label,
				$platformAccountId, $integrationSource, $results,
				[
					'transfer_type'          => $pst['transfer_type'] === 'percentage' ? 1 : 0,
					'transfer_entered_value' => (float) $pst['transfer_percentage_or_amount'],
					'item_total'             => (float) ( $pst['shipping_total'] ?? $xferAmt ),
					'item_transfer_amount'   => (float) $xferAmt,
				]
			);
			++$index;
		}

		// 3.7.5 (Vanessa Provs / profplaza.nl — sc-7219): markTransferProcessed()
		// USED to fire here unconditionally. Vanessa's order 24884 hit this
		// failure mode: every executeTransfer() returned status='failed'
		// (Stripe rejected the destinations), no transfer_log rows landed,
		// but the order was marked done and silently never retried. The
		// next webhook delivery saw is_transfer_process_done=1 at line 45
		// and short-circuited.
		//
		// Legacy parity: class-bsd-split-pay-stripe-connect-woo.php:2475
		// has always gated on `! $any_transfer_failed`. The new-path engine
		// was missing this guard.
		//
		// Fix: only mark done when AT LEAST ONE transfer actually succeeded.
		// A mix of success+failure still marks done (failed sub-transfers
		// stay visible in the Transfers tab + an order note, retry-action
		// re-processes them). A pure all-fail run leaves the flag unset so
		// the next webhook delivery retries.
		$hasAnySuccess = false;
		foreach ( $results as $r ) {
			// 'already_succeeded' (idempotent-retry skip) counts as a success: the money
			// for that destination moved on a prior run, so the order is not all-fail.
			if ( isset( $r['status'] ) && ( $r['status'] === 'success' || $r['status'] === 'already_succeeded' ) ) {
				$hasAnySuccess = true;
				break;
			}
		}
		if ( $hasAnySuccess ) {
			$orderAdapter->markTransferProcessed();
			// audit P2-5 — order-completion observability. Suite spec
			// asserts this fires exactly once per order, regardless of
			// concurrent webhook deliveries.
			if ( function_exists( 'do_action' ) ) {
				do_action( 'pts_increment_counter', 'order_marked_done_all_succeeded' );
			}
		} else {
			self::log( sprintf(
				'Order %d: every transfer attempt failed — NOT marking transfer-processed so the next webhook delivery can retry.',
				$orderId
			) );
			$orderAdapter->addOrderNote(
				'Split Pay: every transfer attempt for this order failed. The done flag is intentionally NOT set so the next webhook delivery (or manual Retry action) can re-attempt. See the per-transfer notes above for error details.'
			);
			// audit P2-5 — retry-gate observability. The 3.7.5 fix
			// (sc-7219) was "don't prematurely consume retry budget on
			// total failure" — the suite regression spec asserts this
			// counter increments rather than order_marked_done.
			if ( function_exists( 'do_action' ) ) {
				do_action( 'pts_increment_counter', 'order_kept_for_retry_partial_failure' );
				// 3.7.5 (CRON-006 / ACTSCH-002): signal that EVERY transfer failed so an
				// automatic retry can be scheduled. Fired ONLY here (all-fail) — never on a
				// mixed success+fail run — because the idempotency salt bumps per run, so a
				// re-run would create FRESH transfers for any already-succeeded destination
				// (double-pay). All-fail means nothing succeeded, so a retry is money-safe.
				do_action( 'spp_order_transfers_all_failed', $orderId );
			}
		}

		return $results;
	}

	public function processRefund(
		OrderAdapterInterface $orderAdapter,
		float $refundTotal,
		float $orderTotal,
		string $integrationSource = 'woocommerce'
	): void {
		$orderId = $orderAdapter->getOrderId();

		if ( $refundTotal <= 0 || $orderTotal <= 0 ) {
			return;
		}

		$refundRatio   = $refundTotal / $orderTotal;
		$isFullRefund  = ( $refundTotal >= $orderTotal );
		$transfers     = $this->logger->getTransfersByOrderId( $orderId, $integrationSource );

		if ( empty( $transfers ) ) {
			$orderAdapter->addOrderNote( 'Split Pay: No transfers found for this order to reverse.' );
			return;
		}

		$reversedCount       = 0;
		$failedCount         = 0;
		$reversedTransferIds = [];

		// 3.7.5 (currency-safe refund): reverse in the order's smallest currency unit.
		// A hardcoded * 100 over-reversed zero-decimal (JPY) and under-reversed three-decimal
		// (KWD) currencies; toStripeAmount() converts per the currency's actual decimals.
		$refund_currency = strtolower( $orderAdapter->getCurrency() ) ?: 'usd';

		foreach ( $transfers as $tr ) {
			$transferId  = $tr['transfer_id'] ?? '';
			$destination = $tr['transfer_destination'] ?? '';

			if ( empty( $transferId ) || strpos( $transferId, 'tr_' ) !== 0 ) {
				continue;
			}

			try {
				if ( $isFullRefund ) {
					// 3.7.5 (sc-DUP-REFUND): deterministic idempotency key so a
					// charge.refunded webhook and a manual refund firing together can't
					// double-reverse the vendor. Keyed by (order, transfer, cents) — a
					// full reversal uses the transfer's own amount in minor units so the
					// key is stable for this exact reversal. Pre-3.7.5 no key was sent
					// and the vendor was reversed twice.
					$fullCents = CurrencyHelper::toStripeAmount( (float) $tr['transfer_amount'], $refund_currency );
					$revKey    = self::reversalIdempotencyKey( $orderId, $transferId, $fullCents );
					$this->stripe->createTransferReversal( $transferId, null, $revKey );
				} else {
					$reversalAmount = CurrencyHelper::toStripeAmount( (float) $tr['transfer_amount'] * $refundRatio, $refund_currency );
					if ( $reversalAmount < 1 ) {
						continue;
					}
					$revKey = self::reversalIdempotencyKey( $orderId, $transferId, $reversalAmount );
					$this->stripe->createTransferReversal( $transferId, $reversalAmount, $revKey );
				}

				++$reversedCount;
				$reversedTransferIds[] = $transferId;
				$orderAdapter->addOrderNote(
					sprintf( 'Split Pay: Transfer %s to %s reversed successfully.', $transferId, $destination )
				);
			} catch ( \Exception $e ) {
				++$failedCount;
				$orderAdapter->addOrderNote(
					sprintf( 'Split Pay: Failed to reverse transfer %s to %s: %s', $transferId, $destination, $e->getMessage() )
				);
				self::log( 'Transfer reversal failed for ' . $transferId . ': ' . $e->getMessage() );
			}
		}

		if ( $reversedCount > 0 || $failedCount > 0 ) {
			$orderAdapter->addOrderNote(
				sprintf(
					'Split Pay Refund Summary: %d transfer(s) reversed, %d failed. Refund ratio: %s%%',
					$reversedCount,
					$failedCount,
					round( $refundRatio * 100, 2 )
				)
			);
		}

		/**
		 * Fires after Split Pay has finished processing a refund/reversal for an order.
		 *
		 * Companion to `spp_before_process_transfers` / `spp_after_process_transfers` for the
		 * refund side of the flow. Fired from BOTH refund paths (TransferEngine::processRefund
		 * and WooCommerceIntegration::handleRefund) with an identical payload shape.
		 *
		 * @param array $payload {
		 *     @type int    $order_id              Order whose transfers were reversed.
		 *     @type float  $refund_total          Refund amount (major units).
		 *     @type float  $order_total           Original order total (major units).
		 *     @type float  $refund_ratio          refund_total / order_total.
		 *     @type bool   $is_full_refund         Whether the whole order was refunded.
		 *     @type int    $reversed_count         Transfers reversed successfully.
		 *     @type int    $failed_count           Transfers that failed to reverse.
		 *     @type string[] $reversed_transfer_ids Stripe transfer ids (tr_…) reversed.
		 *     @type string $integration_source     'woocommerce' | 'fluentcart'.
		 * }
		 */
		if ( function_exists( 'do_action' ) ) {
			do_action(
				'spp_refund_processed',
				array(
					'order_id'              => $orderId,
					'refund_total'          => $refundTotal,
					'order_total'           => $orderTotal,
					'refund_ratio'          => $refundRatio,
					'is_full_refund'        => $isFullRefund,
					'reversed_count'        => $reversedCount,
					'failed_count'          => $failedCount,
					'reversed_transfer_ids' => $reversedTransferIds,
					'integration_source'    => $integrationSource,
				)
			);
		}
	}

	/**
	 * Compute a per-account percentage transfer against the ORIGINAL pool total.
	 * Pure function; safe to unit-test.
	 *
	 * Mirrors legacy class-bsd-...::transfer_controller line 1556-1557 semantics:
	 * each percentage applies independently to the order's eligible global subtotal.
	 *
	 * @param float $originalGlobalAmount  Eligible global subtotal at order start.
	 * @param float $accountPercentage     Percentage configured on the account (0-100).
	 * @param float $scalingFactor         1.0 normally; <1 when sum>100% (see
	 *                                     computePercentageScalingFactor).
	 * @return float Transfer amount in major units (e.g. dollars).
	 */
	public static function computeGlobalPercentageShare(
		float $originalGlobalAmount,
		float $accountPercentage,
		float $scalingFactor = 1.0
	): float {
		if ( $originalGlobalAmount <= 0 || $accountPercentage <= 0 ) {
			return 0.0;
		}
		return $originalGlobalAmount * ( $accountPercentage / 100 ) * $scalingFactor;
	}

	/**
	 * Returns 1.0 when the sum of percentage-typed accounts is <=100; otherwise
	 * returns 100/sum so callers can multiply each percentage factor and have
	 * transfers sum to exactly 100% of the eligible pool.
	 *
	 * @param array  $accounts        Rows from wp_bsd_strp_connected_id.
	 * @param string $typeKey         Field name with the "percentage"/"amount" discriminator.
	 * @param string $percentageKey   Field name with the percentage value.
	 */
	public static function computePercentageScalingFactor(
		array $accounts,
		string $typeKey = 'bsd_spscwt_type',
		string $percentageKey = 'bsd_spscwt_percentage_amount'
	): float {
		$sum = self::sumAccountPercentages( $accounts, $typeKey, $percentageKey );
		if ( $sum > 100.0 && $sum > 0 ) {
			return 100.0 / $sum;
		}
		return 1.0;
	}

	/**
	 * Sum the percentage values of percentage-typed accounts. Helper for the
	 * scaling factor + warning-log paths.
	 */
	public static function sumAccountPercentages(
		array $accounts,
		string $typeKey = 'bsd_spscwt_type',
		string $percentageKey = 'bsd_spscwt_percentage_amount'
	): float {
		$sum = 0.0;
		foreach ( $accounts as $acct ) {
			if ( ( $acct[ $typeKey ] ?? 'percentage' ) === 'percentage' ) {
				$sum += (float) ( $acct[ $percentageKey ] ?? 0 );
			}
		}
		return $sum;
	}

	/**
	 * Minimum transferable amount in Stripe's smallest currency unit (cents).
	 * Stripe rejects transfers below 1 unit; the `spp_minimum_transfer_amount`
	 * filter lets integrators raise the floor (e.g. skip sub-threshold vendor
	 * transfers). Always returns at least 1.
	 *
	 * @param string $accountId Destination connected account.
	 * @param int    $orderId   WC order id.
	 * @return int
	 */
	private function minimumStripeTransferAmount( string $accountId, int $orderId ): int {
		if ( ! function_exists( 'apply_filters' ) ) {
			return 1;
		}
		return max( 1, (int) apply_filters( 'spp_minimum_transfer_amount', 1, $accountId, $orderId ) );
	}

	private function executeTransfer(
		int $amount,
		string $currency,
		string $sourceTransaction,
		string $destination,
		string $description,
		array $metadata,
		string $idempotencyKey
	): array {
		// Derive the WC order id from the idempotency key (always formatted
		// `spp_<orderId>_<accountId>_…`) so the transfer-lifecycle hooks below
		// can pass it as context without changing the method signature.
		$spp_order_id = preg_match( '/^spp_(\d+)_/', $idempotencyKey, $spp_m ) ? (int) $spp_m[1] : 0;

		// 3.7.5 (sc-RETRY-IDEMPOTENCY): if this exact sub-transfer (destination + amount
		// in cents) already succeeded for this order on a prior run, do NOT re-create it.
		// The retry salt bumps per run, so re-calling Stripe would mint a brand-new
		// transfer and pay the vendor twice. Consume one match from the per-run multiset
		// (so two equal-amount sub-transfers to the same destination still net correctly).
		$spp_idem_match = $destination . '|' . $amount;
		if ( ! empty( $this->succeededTransfers[ $spp_idem_match ] ) ) {
			--$this->succeededTransfers[ $spp_idem_match ];
			self::log( sprintf(
				'Skipping transfer to %s (%d) for order %d: already succeeded — not re-attempted on retry (idempotency-safe).',
				$destination, $amount, $spp_order_id
			) );
			return [
				'status' => 'already_succeeded',
				'amount' => $amount,
			];
		}

		// 3.7.5 (sc-ACCT-DEAUTH): refuse to transfer to accounts Stripe
		// has revoked. Without this, every retry would hit Stripe with a
		// no-op-then-fail roundtrip and accumulate failed-status rows.
		// We gate on `defined('SPP_PLUGIN_INCLUDES')` (set by the plugin
		// entry file) rather than `function_exists('get_option')` because
		// Brain\Monkey will register a fake get_option globally to satisfy
		// OTHER tests, polluting that check and triggering "function not
		// mocked" errors in TransferEngine-only unit tests. Plugin-loaded
		// check is the right semantics anyway — we only want the deauth
		// path when SPP itself is bootstrapped, not in isolated unit tests.
		// Behavior coverage of the deauth gate lives in
		// tests/Unit/AccountLifecycleHandlerTest.
		if ( defined( 'SPP_PLUGIN_INCLUDES' )
			&& class_exists( '\\SplitPay\\Integrations\\WooCommerce\\AccountLifecycleHandler' )
			&& \SplitPay\Integrations\WooCommerce\AccountLifecycleHandler::isDeauthorized( $destination ) ) {
			self::log( sprintf(
				'Skipping transfer to %s: account is in spp_deauthorized_accounts (Stripe sent account.application.deauthorized for it).',
				$destination
			) );
			// audit P2-3 — observability counter for the suite's
			// account-deauth spec. Suite's pts-state-helper mu-plugin
			// listens and asserts exactly-one increment per skipped
			// transfer.
			if ( function_exists( 'do_action' ) ) {
				do_action( 'pts_increment_counter', 'transfer_skipped_deauth' );
			}
			return [
				'status'  => 'failed',
				'message' => 'destination account deauthorized by Stripe',
				'amount'  => $amount,
			];
		}

		// 3.7.5 (sc-MODE-GUARD pre-emptive): if we already KNOW the destination
		// account's Stripe mode (from the bsd_account_mode cache) and it differs
		// from the mode of the key this transfer will use, Stripe is GUARANTEED
		// to reject it with "No such destination … a similar object exists in
		// <other> mode". Skip the doomed live API call, record a clear failed
		// result, and fire the same mismatch signal the post-failure path uses,
		// so the admin notice + order note appear without burning a Stripe
		// roundtrip. Unknown account mode ('' — legacy/undetected) falls through
		// to the normal attempt, so there's no behaviour change when we can't be
		// certain. Gated on SPP_PLUGIN_INCLUDES (like the deauth gate above) so
		// DB-less unit tests are unaffected.
		if ( defined( 'SPP_PLUGIN_INCLUDES' ) && $this->stripeMode !== '' ) {
			$destMode = $this->accounts->getAccountMode( $destination );
			if ( $destMode !== '' && $destMode !== $this->stripeMode ) {
				// Synthesize the exact Stripe wording so humanizeTransferError()
				// yields the identical friendly message as a real rejection.
				$synthetic = sprintf(
					"No such destination: '%s'; a similar object exists in %s mode, but a %s mode key was used to make this request.",
					$destination,
					$destMode,
					$this->stripeMode
				);
				$friendlyMessage = self::humanizeTransferError( $synthetic, $destination );

				self::log( sprintf(
					'Pre-emptively skipping transfer to %s for order %d: account is %s-mode but the transfer key is %s-mode — Stripe would reject it. No Stripe call made.',
					$destination, $spp_order_id, $destMode, $this->stripeMode
				) );

				if ( function_exists( 'do_action' ) ) {
					do_action( 'spp_transfer_failed', [
						'order_id'    => $spp_order_id,
						'destination' => $destination,
						'amount'      => $amount,
						'error'       => $friendlyMessage,
					] );
					do_action( 'spp_transfer_mode_mismatch', [
						'order_id'    => $spp_order_id,
						'destination' => $destination,
						'amount'      => $amount,
						'error'       => $friendlyMessage,
					] );
				}

				return [
					'status'  => 'failed',
					'message' => $friendlyMessage,
					'amount'  => $amount,
				];
			}
		}

		// 3.7.5 (sc-XFER-CAP hardening): before transferring, confirm the
		// destination account's `transfers` capability is active. If a Connect
		// account lacks an active `transfers` capability, Stripe can accept the
		// Transfer call but never settle it to the vendor, while the API response
		// still looks like success — so the transfer log records a payout that
		// never reached the vendor. Verifying first turns that silent failure into
		// a clear, actionable failed-result + order note. Gated on
		// SPP_PLUGIN_INCLUDES (DB-less unit tests skip it) and the
		// `spp_check_transfers_capability` filter (default on). We only BLOCK when
		// the capability is DEFINITIVELY not active; any retrieval error or unknown
		// shape falls through to the normal attempt (never block on uncertainty).
		if ( defined( 'SPP_PLUGIN_INCLUDES' )
			&& ( ! function_exists( 'apply_filters' ) || apply_filters( 'spp_check_transfers_capability', true, $destination, $spp_order_id ) ) ) {
			$capState = $this->resolveTransfersCapability( $destination );
			if ( $capState === 'inactive' ) {
				$capMsg = sprintf(
					'Vendor account %s does not have an active Stripe "transfers" capability, so Stripe would accept the payout request but never settle it to the vendor. '
					. 'Ask this vendor to finish Stripe onboarding (enable Transfers) for your platform, then use the "Retry Split Pay Transfers" order action.',
					$destination
				);
				self::log( sprintf(
					'Skipping transfer to %s for order %d: destination "transfers" capability is not active — Stripe would silently not settle it. No transfer created.',
					$destination, $spp_order_id
				) );
				if ( function_exists( 'do_action' ) ) {
					do_action( 'spp_transfer_failed', [
						'order_id'    => $spp_order_id,
						'destination' => $destination,
						'amount'      => $amount,
						'error'       => $capMsg,
					] );
				}
				return [
					'status'  => 'failed',
					'message' => $capMsg,
					'amount'  => $amount,
				];
			}
		}

		// Extensibility (3.7.5): fires per destination immediately BEFORE the
		// Stripe transfer is created (after the deauthorized-account gate).
		if ( function_exists( 'do_action' ) ) {
			do_action( 'spp_before_create_transfer', [
				'order_id'    => $spp_order_id,
				'destination' => $destination,
				'amount'      => $amount,
				'currency'    => $currency,
			] );
		}

		// Extensibility (3.7.5): allow the Stripe idempotency key for this
		// sub-transfer to be customised before the create call. Default returns
		// the key exactly as the engine composed it
		// (`spp_<order>_<account>_<suffix>_<index>_<attempt>`), so with no
		// callback attached the value passed to Stripe is byte-identical to
		// today — the per-order retry-salt idempotency guarantee is preserved.
		// $spp_order_id is parsed from the key above; $destination is the
		// connected account this transfer pays.
		if ( function_exists( 'apply_filters' ) ) {
			$idempotencyKey = (string) apply_filters(
				'spp_idempotency_key',
				$idempotencyKey,
				$spp_order_id,
				$destination
			);
		}

		try {
			$transfer = $this->stripe->createTransfer(
				$amount, $currency, $sourceTransaction, $destination,
				$description, $metadata, $idempotencyKey
			);

			// 3.7.5 (sc-DUP-XFER): Stripe reported the idempotency key is already
			// in use by a concurrent in-flight request — the OTHER webhook delivery
			// is creating this exact transfer, so the money moves exactly once.
			// Treat as already-succeeded: no duplicate log row, but it counts toward
			// the done-flag (recordResult handles the 'already_succeeded' status).
			// Its transfer id belongs to the other request and is not knowable here.
			if ( ! empty( $transfer->{StripeService::IDEMPOTENT_REPLAY_FLAG} ) ) {
				self::log( sprintf(
					'Transfer to %s (%d) for order %d deduped by Stripe (idempotency_key_in_use) — a concurrent delivery is creating it; not re-attempted (idempotency-safe).',
					$destination, $amount, $spp_order_id
				) );
				if ( function_exists( 'do_action' ) ) {
					do_action( 'spp_transfer_succeeded', [
						'order_id'    => $spp_order_id,
						'destination' => $destination,
						'amount'      => $amount,
						'transfer_id' => '',
					] );
				}
				return [
					'status' => 'already_succeeded',
					'amount' => $amount,
				];
			}

			// Extensibility (3.7.5): fires per destination immediately AFTER the
			// Stripe transfer object is created (before destination-charge sync).
			if ( function_exists( 'do_action' ) ) {
				do_action( 'spp_after_create_transfer', [
					'order_id'    => $spp_order_id,
					'destination' => $destination,
					'amount'      => $amount,
					'transfer_id' => $transfer->id ?? '',
				] );
			}

			if ( ! empty( $metadata ) ) {
				$destPayment = $transfer->destination_payment ?? '';
				if ( $destPayment ) {
					try {
						// 3.7.5 (sc-DUP-XFER companion): deterministic idempotency key on
						// the destination-charge metadata update so a replayed webhook
						// delivery can't double-apply it. Derived from the transfer id so
						// it is stable for this exact (transfer, charge) pair.
						$metaIdemKey = 'spp_chmeta_' . ( $transfer->id ?? '' ) . '_' . substr( md5( $destPayment ), 0, 12 );
						$this->stripe->updateChargeMetadata( $destPayment, $metadata, $destination, $metaIdemKey );
					} catch ( \Exception $e ) {
						self::log( 'Failed to update destination charge metadata: ' . $e->getMessage() );
					}
				}
			}

			// Extensibility (3.7.5): fires on a successful transfer only.
			if ( function_exists( 'do_action' ) ) {
				do_action( 'spp_transfer_succeeded', [
					'order_id'    => $spp_order_id,
					'destination' => $destination,
					'amount'      => $amount,
					'transfer_id' => $transfer->id ?? '',
				] );
			}

			return [
				'status'      => 'success',
				'transfer_id' => $transfer->id,
				'amount'      => $amount,
			];
		} catch ( \Exception $e ) {
			// 3.7.5 (sc-DUP-XFER, defense in depth): if the idempotency_key_in_use
			// error reaches the engine (e.g. a StripeService variant that re-throws
			// it), treat it as already-created — a concurrent delivery is creating
			// this exact transfer, so the money moves exactly once. Never record it
			// as a failed transfer (that would wrongly open an auto-retry that could
			// double-pay). StripeService::createTransfer() already absorbs this and
			// returns the replay sentinel on the normal path; this is the backstop.
			if ( self::isIdempotencyKeyInUseError( $e ) ) {
				self::log( sprintf(
					'Transfer to %s (%d) for order %d deduped by Stripe (idempotency_key_in_use, caught in engine) — concurrent delivery owns it; treated as already-created.',
					$destination, $amount, $spp_order_id
				) );
				if ( function_exists( 'do_action' ) ) {
					do_action( 'spp_transfer_succeeded', [
						'order_id'    => $spp_order_id,
						'destination' => $destination,
						'amount'      => $amount,
						'transfer_id' => '',
					] );
				}
				return [
					'status' => 'already_succeeded',
					'amount' => $amount,
				];
			}

			self::log( 'Transfer failed: ' . $e->getMessage() );

			// 3.7.5 (sc-MODE-GUARD): translate Stripe's cryptic live/test "No such
			// destination" rejection into a clear, actionable order note. The vendor's
			// Connect account exists in one Stripe mode but the transfer used the other
			// mode's key — a configuration issue the merchant fixes by re-onboarding the
			// vendor in the matching mode. (Production: Yuliana / levinarf order 2277 —
			// test-mode vendor accounts hit with a live-mode key surfaced only the raw
			// Stripe wording, which read as a plugin bug.)
			$friendlyMessage = self::humanizeTransferError( $e->getMessage(), $destination );

			// Extensibility (3.7.5): fires on a failed transfer attempt only.
			if ( function_exists( 'do_action' ) ) {
				do_action( 'spp_transfer_failed', [
					'order_id'    => $spp_order_id,
					'destination' => $destination,
					'amount'      => $amount,
					'error'       => $e->getMessage(),
				] );
				// Specific signal when the failure is a live/test account mismatch, so a
				// persistent admin notice / integrator can react to a fixable config error.
				if ( $friendlyMessage !== $e->getMessage() ) {
					do_action( 'spp_transfer_mode_mismatch', [
						'order_id'    => $spp_order_id,
						'destination' => $destination,
						'amount'      => $amount,
						'error'       => $e->getMessage(),
					] );
				}
			}

			return [
				'status'  => 'failed',
				'message' => $friendlyMessage,
				'amount'  => $amount,
			];
		}
	}

	/**
	 * Turn Stripe's cryptic live/test "No such destination" rejection into a clear,
	 * actionable message for the order note. When a vendor's Connect account exists in
	 * one Stripe mode but the transfer used the other mode's key, Stripe returns e.g.
	 * "No such destination: 'acct_…'; a similar object exists in test mode, but a live
	 * mode key was used to make this request." That reads as a plugin bug; in reality the
	 * merchant must re-onboard the vendor in the matching mode. Non-mismatch errors pass
	 * through unchanged.
	 *
	 * @since 3.7.5
	 */
	/**
	 * Resolve a destination connected account's `transfers` capability state.
	 * Returns 'active', 'inactive', or 'unknown'. 'unknown' is returned on any
	 * retrieval error or unexpected shape, so callers never block a transfer on
	 * uncertainty (only a DEFINITIVE 'inactive' should gate). 3.7.5 (sc-XFER-CAP).
	 *
	 * @param string $destination Connected account id (acct_…).
	 * @return string 'active'|'inactive'|'unknown'
	 */
	private function resolveTransfersCapability( string $destination ): string {
		if ( $destination === '' ) {
			return 'unknown';
		}
		try {
			$account = $this->stripe->retrieveAccount( $destination );
		} catch ( \Throwable $e ) {
			self::log( 'Could not retrieve destination account to check transfers capability for '
				. $destination . ': ' . $e->getMessage() );
			return 'unknown';
		}
		$caps = $account->capabilities ?? null;
		if ( $caps === null ) {
			return 'unknown';
		}
		// Capabilities may be a StripeObject or an array depending on SDK/shape.
		// PHPStan narrows $caps to StripeObject from the bundled SDK stub, so it
		// flags the is_array() branch as dead and the ['transfers'] offset as
		// always-present — but at runtime an older/different stripe-php (the
		// sc-SDK-COLLISION scenario) can hand back a plain array, so both arms are
		// genuinely reachable. Keep the dual-shape access defensively.
		// @phpstan-ignore-next-line -- defensive: $caps shape (StripeObject|array) is SDK-version-dependent at runtime.
		$transfers = is_array( $caps )
			// @phpstan-ignore-next-line -- defensive: array offset only reached on the runtime array shape PHPStan can't see.
			? ( $caps['transfers'] ?? null )
			: ( $caps->transfers ?? null );
		if ( $transfers === null || $transfers === '' ) {
			// Capability not present at all → cannot prove it's inactive; don't block.
			return 'unknown';
		}
		return $transfers === 'active' ? 'active' : 'inactive';
	}

	/**
	 * Deterministic idempotency key for a transfer reversal, shaped
	 * `spp_rev_{order}_{transferId}_{cents}`. Stable for a given (order, transfer,
	 * reversal-amount) triple so a charge.refunded webhook and a manual refund
	 * reversing the same transfer for the same amount collapse to ONE Stripe
	 * reversal. 3.7.5 (sc-DUP-REFUND).
	 *
	 * @param int    $orderId    WC/FluentCart order id.
	 * @param string $transferId Stripe transfer id (tr_…) being reversed.
	 * @param int    $cents      Reversal amount in the currency's minor units.
	 */
	private static function reversalIdempotencyKey( int $orderId, string $transferId, int $cents ): string {
		return 'spp_rev_' . $orderId . '_' . $transferId . '_' . $cents;
	}

	/**
	 * True when a caught exception is Stripe's `idempotency_key_in_use` (HTTP 409)
	 * — a concurrent request is still creating the same object under this key.
	 * Accepts a generic Throwable (the engine catches \Exception) and probes the
	 * structured Stripe error code, then the message, without hard-depending on a
	 * specific Stripe exception subclass.
	 *
	 * @since 3.7.5
	 */
	private static function isIdempotencyKeyInUseError( \Throwable $e ): bool {
		if ( method_exists( $e, 'getStripeCode' ) ) {
			$code = (string) $e->getStripeCode();
			if ( $code === 'idempotency_key_in_use' ) {
				return true;
			}
		}
		if ( method_exists( $e, 'getError' ) ) {
			$err = $e->getError();
			if ( $err && isset( $err->code ) && (string) $err->code === 'idempotency_key_in_use' ) {
				return true;
			}
		}
		$msg = $e->getMessage();
		return stripos( $msg, 'idempotent' ) !== false && stripos( $msg, 'in-progress' ) !== false;
	}

	private static function humanizeTransferError( string $raw, string $accountId ): string {
		$isMismatch = ( stripos( $raw, 'a similar object exists in' ) !== false )
			|| ( stripos( $raw, 'No such destination' ) !== false
				&& ( stripos( $raw, 'test mode' ) !== false || stripos( $raw, 'live mode' ) !== false ) );
		if ( ! $isMismatch ) {
			return $raw;
		}
		$acctMode = ( stripos( $raw, 'exists in test mode' ) !== false ) ? 'TEST'
			: ( ( stripos( $raw, 'exists in live mode' ) !== false ) ? 'LIVE' : 'a different' );
		$keyMode = ( stripos( $raw, 'a live mode key was used' ) !== false ) ? 'LIVE'
			: ( ( stripos( $raw, 'a test mode key was used' ) !== false ) ? 'TEST' : 'the opposite' );
		return sprintf(
			'Stripe live/test mode mismatch — vendor account %s is a %s-mode Connect account, but '
			. 'this payment was processed with a %s-mode key, so Stripe refused the transfer. This is a '
			. 'configuration issue, not a failed payout: re-onboard this vendor using the %s Stripe Connect '
			. 'link (Split Pay → Vendor Onboarding) so the account matches your store\'s mode, then run the '
			. '"Retry Split Pay Transfers" order action. (Stripe said: %s)',
			$accountId,
			$acctMode,
			$keyMode,
			$keyMode,
			$raw
		);
	}

	private function recordResult(
		array $result,
		OrderAdapterInterface $orderAdapter,
		int $orderId,
		string $accountId,
		string $chargeId,
		float $amount,
		string $label,
		string $platformAccountId,
		string $integrationSource,
		array &$results,
		array $extraColumns = []
	): void {
		// 3.7.5 (sc-RETRY-IDEMPOTENCY): a sub-transfer skipped because it already
		// succeeded on a prior run — do NOT write a duplicate log row and do NOT treat
		// it as a failure. It still counts toward the done-flag (see processTransfers).
		if ( ( $result['status'] ?? '' ) === 'already_succeeded' ) {
			$orderAdapter->addOrderNote( sprintf(
				'Split Pay: %s to %s skipped on retry — already transferred successfully (no duplicate sent).',
				$label, $accountId
			) );
			$results[] = array_merge( $result, [ 'account_id' => $accountId, 'label' => $label ] );
			return;
		}

		if ( $result['status'] === 'success' ) {
			$now = function_exists( 'current_time' ) ? current_time( 'mysql', true ) : gmdate( 'Y-m-d H:i:s' );

			// 3.7.3 (sc-7168): the Transfers admin tab filters
			// WHERE stripe_mode = %s and ORDER BY date_created DESC. Prior
			// releases omitted stripe_mode, date_created, and several audit
			// columns here — rows landed with NULL/zero values and were
			// invisible to merchants. We now populate the full row at the
			// boundary; per-call extra columns (transfer_type,
			// transfer_entered_value, item totals, etc.) merge on top.
			$payload = array_merge(
				[
					'wc_order_id'          => $orderId,
					'transfer_id'          => $result['transfer_id'],
					'transfer_destination' => $accountId,
					'charge_id'            => $chargeId,
					'transfer_amount'      => $amount,
					'integration_source'   => $integrationSource,
					'source_order_id'      => $orderId,
					'stripe_mode'          => $this->stripeMode,
					'charge_amount'        => $this->chargeAmount,
					'charge_date'          => $this->chargeDate,
					'charge_description'   => $this->chargeDescription,
					'date_created'         => $now,
					'date_modified'        => $now,
				],
				$extraColumns
			);

			$this->logger->logTransfer( $payload );

			$note = sprintf(
				'Split Pay: %s - %s to %s (Transfer ID: %s)',
				$label, $this->formatCents( $result['amount'] ), $accountId, $result['transfer_id']
			);
			if ( $platformAccountId ) {
				$note .= ' | Platform: ' . $platformAccountId;
			}
			$orderAdapter->addOrderNote( $note );
		} else {
			$note = sprintf(
				'Split Pay: %s to %s FAILED: %s',
				$label, $accountId, $result['message'] ?? 'Unknown error'
			);
			$orderAdapter->addOrderNote( $note );
		}

		$results[] = array_merge( $result, [
			'account_id' => $accountId,
			'label'      => $label,
		] );
	}

	private function toStripeTransferAmount( float $amount, float $tax, string $transferTaxes, string $currency ): int {
		$total = $transferTaxes === '1' ? $amount + $tax : $amount;
		return CurrencyHelper::toStripeAmount( $total, $currency );
	}

	private function precomputeGlobalTotal( array $globalAccounts, float $globalAmount ): float {
		$total = 0.0;
		foreach ( $globalAccounts as $acct ) {
			$type  = $acct['bsd_spscwt_type'] ?? 'percentage';
			$value = (float) ( $acct['bsd_spscwt_percentage_amount'] ?? 0 );
			if ( $type === 'percentage' ) {
				$total += $globalAmount * ( $value / 100 );
			} else {
				$total += $value;
			}
		}
		return $total;
	}

	private function precomputeGlobalShippingTotal( array $globalAccounts, float $shippingAmount ): float {
		$total = 0.0;
		foreach ( $globalAccounts as $acct ) {
			$type  = $acct['bsd_global_shipping_type'] ?? '';
			$value = (float) ( $acct['bsd_global_shipping_percentage_amount'] ?? 0 );
			if ( $type === 'percentage' ) {
				$total += $shippingAmount * ( $value / 100 );
			} else {
				$total += $value;
			}
		}
		return $total;
	}

	private function computeGlobalTax(
		string $transferTaxes, string $taxType, float $globalTax,
		float $factor, float $xferAmt, float $storeTotalTransferred, int $accountCount
	): float {
		if ( $transferTaxes !== '1' || $globalTax <= 0 ) {
			return 0.0;
		}

		if ( $taxType === 'partial' ) {
			return $globalTax * $factor;
		}

		if ( $taxType === 'all' ) {
			if ( $accountCount === 1 ) {
				return $globalTax;
			}
			if ( $storeTotalTransferred > 0 ) {
				$pct = ( $xferAmt / $storeTotalTransferred ) * 100;
				return ( $globalTax / 100 ) * $pct;
			}
		}

		return 0.0;
	}

	private function computeGlobalTaxFixed(
		string $transferTaxes, string $taxType, float $globalTax,
		float $fixedAmt, float $globalAmount, float $storeTotalTransferred, int $accountCount
	): float {
		if ( $transferTaxes !== '1' || $globalTax <= 0 ) {
			return 0.0;
		}

		if ( $taxType === 'partial' && $globalAmount > 0 ) {
			return ( $fixedAmt / $globalAmount ) * $globalTax;
		}

		if ( $taxType === 'all' ) {
			if ( $storeTotalTransferred > 0 ) {
				$pct = ( $fixedAmt / $storeTotalTransferred ) * 100;
				return ( $globalTax / 100 ) * $pct;
			}
		}

		return 0.0;
	}

	private function computeShippingTax(
		string $transferTaxes, string $taxType, float $totalShippingTax,
		float $factor, float $xferAmt, float $storeTotalTransferred, int $accountCount
	): float {
		if ( $transferTaxes !== '1' || $totalShippingTax <= 0 ) {
			return 0.0;
		}

		if ( $taxType === 'partial' ) {
			return $totalShippingTax * $factor;
		}

		if ( $taxType === 'all' ) {
			if ( $accountCount === 1 ) {
				return $totalShippingTax;
			}
			if ( $storeTotalTransferred > 0 ) {
				$pct = ( $xferAmt / $storeTotalTransferred ) * 100;
				return ( $totalShippingTax / 100 ) * $pct;
			}
		}

		return 0.0;
	}

	private function computeShippingTaxFixed(
		string $transferTaxes, string $taxType, float $totalShippingTax,
		float $fixedAmt, float $storeTotalTransferred, int $accountCount
	): float {
		if ( $transferTaxes !== '1' || $totalShippingTax <= 0 ) {
			return 0.0;
		}

		if ( $taxType === 'partial' && $storeTotalTransferred > 0 ) {
			return ( $fixedAmt / $storeTotalTransferred ) * $totalShippingTax;
		}

		if ( $taxType === 'all' && $storeTotalTransferred > 0 ) {
			$pct = ( $fixedAmt / $storeTotalTransferred ) * 100;
			return ( $totalShippingTax / 100 ) * $pct;
		}

		return 0.0;
	}

	private function sumShippingTax( array $allTaxesArray ): float {
		$total = 0.0;
		foreach ( $allTaxesArray as $tax ) {
			$total += (float) ( $tax['tax_ship_total'] ?? 0 );
		}
		return $total;
	}

	/**
	 * Format an amount (in Stripe's smallest currency unit, e.g. cents) for use
	 * in human-readable order notes. Uses the WooCommerce currency formatter
	 * when available so non-USD currencies render with the correct symbol; falls
	 * back to a plain numeric string otherwise.
	 */
	private function formatCents( int $cents ): string {
		$amount = $cents / 100;

		if ( function_exists( 'wc_price' ) && function_exists( 'get_woocommerce_currency' ) ) {
			return wp_strip_all_tags( wc_price( $amount, [ 'currency' => get_woocommerce_currency() ] ) );
		}

		return number_format( $amount, 2 );
	}

	private static function log( string $message ): void {
		$prefixed = 'SplitPay TransferEngine: ' . $message;
		self::safe_wcs_log( $prefixed );
	}

	/**
	 * See WooCommerceIntegration::safe_wcs_log() for full rationale.
	 *
	 * @since 3.7.3
	 */
	private static function safe_wcs_log( string $message ): void {
		if ( class_exists( '\WC_Stripe_Logger' ) ) {
			if ( method_exists( '\WC_Stripe_Logger', 'log' ) ) {
				\WC_Stripe_Logger::log( $message );
				return;
			}
			if ( method_exists( '\WC_Stripe_Logger', 'debug' ) ) {
				\WC_Stripe_Logger::debug( $message );
				return;
			}
		}
		error_log( $message );
	}
}
