<?php

namespace SplitPay\Integrations\WooCommerce;

use SplitPay\Contracts\GatewayAdapterInterface;
use SplitPay\Contracts\IntegrationInterface;
use SplitPay\Contracts\OrderAdapterInterface;
use SplitPay\Contracts\ProductAdapterInterface;
use SplitPay\Integrations\WooCommerce\Adapters\WCOrderAdapter;
use SplitPay\Integrations\WooCommerce\Adapters\WCProductAdapter;
use SplitPay\Integrations\WooCommerce\Gateways\WCStripeGateway;
use SplitPay\Integrations\WooCommerce\Gateways\WooStripePaymentGateway;

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

class WooCommerceIntegration implements IntegrationInterface {

	/** @var GatewayAdapterInterface[]|null */
	private ?array $gatewayCache = null;

	public function getId(): string {
		return 'woocommerce';
	}

	public function getName(): string {
		return 'WooCommerce';
	}

	public function getDescription(): string {
		return 'Split payments for WooCommerce orders using Stripe Connect.';
	}

	public function getVersion(): string {
		return defined( 'WC_VERSION' ) ? WC_VERSION : '';
	}

	public function getPluginUrl(): string {
		/*
		 * 3.7.5 (PT-2): the Integrations tab "View Docs" button consumes
		 * getPluginUrl() — previously pointed at woocommerce.com, which
		 * sent customers to a third-party site instead of Split Pay's
		 * own integrations documentation on docs.splitpayplugin.com.
		 * Overridable via the `spp_integration_docs_url` filter (also
		 * receives the integration id as the second argument) so any
		 * future integration-specific subpath can replace this default.
		 */
		return (string) apply_filters(
			'spp_integration_docs_url',
			'https://docs.splitpayplugin.com/features/integrations-and-compatibility',
			$this->getId()
		);
	}

	public function isInstalled(): bool {
		return defined( 'WP_PLUGIN_DIR' )
			&& file_exists( WP_PLUGIN_DIR . '/woocommerce/woocommerce.php' );
	}

	public function isActive(): bool {
		if ( ! class_exists( 'WooCommerce' ) ) {
			return false;
		}

		foreach ( $this->getGatewayAdapters() as $gateway ) {
			if ( $gateway->isActive() ) {
				return true;
			}
		}

		return false;
	}

	public function getStatusDetails(): array {
		if ( ! $this->isInstalled() ) {
			return [
				'integration' => $this->getId(),
				'installed'   => false,
				'active'      => false,
				'gateways'    => [],
			];
		}

		$gateways = [];
		foreach ( $this->getAllGatewayCandidates() as $gw ) {
			$gateways[ $gw->getId() ] = array_merge(
				[ 'active' => $gw->isActive() ],
				$gw->isActive() ? $gw->getStatusDetails() : [ 'label' => 'Inactive' ]
			);
		}

		return [
			'integration'   => $this->getId(),
			'installed'     => true,
			'active'        => $this->isActive(),
			'version'       => $this->getVersion(),
			'gateways'      => $gateways,
			'configure_url' => admin_url( 'admin.php?page=bsd-split-pay-stripe-connect-woo-settings&tab=stripe-configuration' ),
		];
	}

	public function getOrderAdapter( $orderData ): OrderAdapterInterface {
		if ( $orderData instanceof \WC_Order ) {
			return new WCOrderAdapter( $orderData );
		}

		if ( is_int( $orderData ) || is_numeric( $orderData ) ) {
			$order = wc_get_order( (int) $orderData );
			if ( ! $order ) {
				throw new \InvalidArgumentException(
					'Cannot create order adapter: WC_Order not found for ID ' . esc_html( $orderData )
				);
			}
			return new WCOrderAdapter( $order );
		}

		throw new \InvalidArgumentException(
			'getOrderAdapter expects a WC_Order instance or numeric order ID.'
		);
	}

	public function getProductAdapter(): ProductAdapterInterface {
		return new WCProductAdapter();
	}

	/** @return GatewayAdapterInterface[] */
	public function getGatewayAdapters(): array {
		if ( $this->gatewayCache !== null ) {
			return $this->gatewayCache;
		}

		$this->gatewayCache = [];
		foreach ( $this->getAllGatewayCandidates() as $gw ) {
			if ( $gw->isActive() ) {
				$this->gatewayCache[] = $gw;
			}
		}

		return $this->gatewayCache;
	}

	public function registerHooks(): void {
		$this->registerWebhookHooks();
		$this->registerCoreHooks();
	}

	public function registerAdminUI(): void {
		// 3.7.5: de-duplicate the product-editor Split Pay UI.
		// The legacy BSD_SCSP_Main_Plugin::run() binds the SAME hooks to the
		// SAME tab/partial/meta keys at include time and defines
		// SPP_LEGACY_PRODUCT_UI_BOUND. When that owner is present we must NOT
		// re-bind these callbacks here, or the panel partial renders twice
		// (duplicate %/fixed fields) AND two save handlers fire on
		// woocommerce_process_product_meta (the silent product-meta erasure).
		// Legacy remains canonical for maximum backwards compatibility; this
		// path takes over only on a future build where legacy no longer binds.
		if ( defined( 'SPP_LEGACY_PRODUCT_UI_BOUND' ) && SPP_LEGACY_PRODUCT_UI_BOUND ) {
			return;
		}

		add_filter( 'woocommerce_product_data_tabs', [ $this, 'addProductDataTab' ] );
		add_action( 'woocommerce_product_data_panels', [ $this, 'renderProductDataFields' ] );

		if ( $this->isPremium() ) {
			add_action( 'woocommerce_process_product_meta', [ $this, 'saveProductDataFields' ] );
			add_action( 'woocommerce_product_after_variable_attributes', [ $this, 'renderVariableProductFields' ], 10, 3 );
			add_action( 'woocommerce_save_product_variation', [ $this, 'saveVariableProductFields' ], 10, 2 );
		}
	}

	/* ---------------------------------------------------------------
	 *  Webhook registration
	 * ------------------------------------------------------------- */

	private function registerWebhookHooks(): void {
		// NOTE (3.7.0): For the official WooCommerce Stripe Payment Gateway, the
		// legacy BSD_SCSP_Main_Plugin::check_for_webhook (also hooked to
		// woocommerce_api_wc_stripe at priority 9) registers first and exits the
		// request before any other priority-9 handler runs. The Split Pay
		// WebhookHandler class therefore remains a fallback that is not wired up
		// at runtime in this release. Cleanup of the legacy transfer_controller
		// path and a full move to WebhookHandler+TransferEngine is tracked for
		// 3.8.0. Payment Plugins for Stripe has its own dedicated hook below
		// which always routes through TransferEngine.

		$paymentPlugins = new WooStripePaymentGateway();
		if ( $paymentPlugins->isActive() ) {
			add_action(
				'wc_stripe_webhook_payment_intent_succeeded',
				[ $this, 'handlePaymentPluginsWebhook' ],
				10,
				2
			);
		}
	}

	/**
	 * Webhook callback for Payment Plugins for Stripe WooCommerce.
	 *
	 * @param \WC_Order            $order  The WooCommerce order.
	 * @param \Stripe\PaymentIntent $intent The Stripe PaymentIntent object.
	 */
	public function handlePaymentPluginsWebhook( $order, $intent ): void {
		if ( ! $order instanceof \WC_Order ) {
			return;
		}

		$adapter = new WCOrderAdapter( $order );
		if ( $adapter->isTransferProcessed() ) {
			return;
		}

		$gateway  = new WooStripePaymentGateway();
		$chargeId = $gateway->resolveChargeId( $intent );

		if ( empty( $chargeId ) ) {
			$this->log( 'Payment Plugins webhook: could not resolve charge ID for order ' . $order->get_id() );
			return;
		}

		$secretKey = method_exists( $gateway, 'getTransferSecretKey' ) ? $gateway->getTransferSecretKey() : '';
		if ( empty( $secretKey ) ) {
			$secretKey = $gateway->getSecretKey();
		}
		if ( empty( $secretKey ) ) {
			$this->log( 'Payment Plugins webhook: no API key for order ' . $order->get_id() );
			return;
		}

		try {
			if ( class_exists( '\SplitPay\Core\TransferEngine' ) ) {
				$stripe         = new \SplitPay\Core\StripeService( $secretKey );
				$logger         = new \SplitPay\Core\TransferLogger();
				$connectedAccts = new \SplitPay\Core\ConnectedAccounts();
				$engine         = new \SplitPay\Core\TransferEngine( $stripe, $logger, $connectedAccts );
				$engine->processTransfers( $adapter, $gateway, 'woocommerce', $chargeId );
			}
		} catch ( \Exception $e ) {
			$this->log( 'Payment Plugins webhook exception for order ' . $order->get_id() . ': ' . $e->getMessage() );
		}
	}

	/* ---------------------------------------------------------------
	 *  Core WooCommerce hooks
	 * ------------------------------------------------------------- */

	private function registerCoreHooks(): void {
		add_action( 'before_woocommerce_init', [ $this, 'declareHposCompatibility' ] );
		add_action( 'woocommerce_payment_complete', [ $this, 'handlePaymentComplete' ], 20, 1 );
		add_action( 'woocommerce_order_status_completed', [ $this, 'handleOrderCompleted' ], 20, 1 );
		add_action( 'woocommerce_order_refunded', [ $this, 'handleRefund' ], 10, 2 );
		add_filter( 'woocommerce_order_actions', [ $this, 'addRetryOrderAction' ], 10, 2 );
		add_action( 'woocommerce_order_action_spp_retry_transfers', [ $this, 'retryTransfers' ] );
		// 3.7.5 (CRON-006 / ACTSCH-002): automatic retry. Triggered ONLY by the engine's
		// all-fail signal (`spp_order_transfers_all_failed`) — never per-failed-transfer —
		// so an auto-retry can never re-pay an already-succeeded destination (the
		// idempotency salt bumps per run). Schedules a deduped, capped Action Scheduler
		// job. The manual "Retry Split Pay Transfers" order action above stays available.
		add_action( 'spp_order_transfers_all_failed', [ $this, 'scheduleAutoRetry' ], 10, 1 );
		add_action( 'spp_retry_transfer', [ $this, 'retryTransfersById' ], 10, 1 );
		add_action( 'woocommerce_admin_order_data_after_order_details', [ $this, 'renderDelayedTransferNotice' ], 10, 1 );
	}

	public function renderDelayedTransferNotice( $order ): void {
		if ( ! $order instanceof \WC_Order ) {
			return;
		}
		$pending = $order->get_meta( '_spp_pending_delayed_transfer', true );
		if ( empty( $pending ) ) {
			return;
		}
		echo '<p class="form-field form-field-wide" style="background:#fff8e5;border-left:4px solid #ffb900;padding:8px 12px;margin-top:8px;">';
		echo '<strong>' . esc_html__( 'Split Pay:', 'bsd-split-pay-stripe-connect-woo' ) . '</strong> ';
		echo esc_html__( 'Transfer pending — will release when this order is marked Completed.', 'bsd-split-pay-stripe-connect-woo' );
		echo '</p>';
	}

	public function declareHposCompatibility(): void {
		if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) && defined( 'SPP_PLUGIN_MAIN_FILE_PATH' ) ) {
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
				'custom_order_tables',
				SPP_PLUGIN_MAIN_FILE_PATH,
				true
			);
		}
	}

	/* ---------------------------------------------------------------
	 *  Payment Complete -- universal transfer trigger for all WC Stripe gateways
	 * ------------------------------------------------------------- */

	public function handlePaymentComplete( int $order_id ): void {
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return;
		}

		$payment_method = $order->get_payment_method();
		$is_stripe      = ( $payment_method === 'stripe' || strpos( $payment_method, 'stripe_' ) === 0 );
		if ( ! $is_stripe ) {
			return;
		}

		$already_done = $order->get_meta( 'is_transfer_process_done', true );
		if ( ! empty( $already_done ) ) {
			return;
		}

		if ( $this->shouldDelayTransfer( $order ) ) {
			$this->stampDelayedTransfer( $order );
			return;
		}

		$gateway = $this->resolveGatewayForOrder( $order );
		if ( ! $gateway ) {
			return;
		}

		$secretKey = method_exists( $gateway, 'getTransferSecretKey' ) ? $gateway->getTransferSecretKey() : '';
		if ( empty( $secretKey ) ) {
			$secretKey = $gateway->getSecretKey();
		}
		if ( empty( $secretKey ) ) {
			$order->add_order_note( __( 'Split Pay: No Stripe API key available for transfers.', 'bsd-split-pay-stripe-connect-woo' ) );
			return;
		}

		$charge_id = $this->resolveChargeIdForOrder( $order );

		try {
			$adapter        = new WCOrderAdapter( $order );
			$stripeService  = new \SplitPay\Core\StripeService( $secretKey );
			$logger         = new \SplitPay\Core\TransferLogger();
			$connectedAccts = new \SplitPay\Core\ConnectedAccounts();
			$engine         = new \SplitPay\Core\TransferEngine( $stripeService, $logger, $connectedAccts );

			$engine->processTransfers( $adapter, $gateway, 'woocommerce', $charge_id ?: null );
		} catch ( \Throwable $e ) {
			$order->add_order_note( 'Split Pay: Unexpected error — ' . $e->getMessage() );
			$this->log( 'handlePaymentComplete error for order ' . $order_id . ': ' . $e->getMessage() );
		}
	}

	private function resolveChargeIdForOrder( \WC_Order $order ): string {
		$charge_id = (string) $order->get_transaction_id();
		if ( $charge_id === '' ) {
			$charge_id = (string) $order->get_meta( '_stripe_charge_id', true );
		}
		if ( $charge_id === '' ) {
			$charge_id = (string) $order->get_meta( '_transaction_id', true );
		}
		return $charge_id;
	}

	private function shouldDelayTransfer( \WC_Order $order ): bool {
		if ( ! \SplitPay\Core\UpgradeCTA::isPremium() ) {
			return false;
		}
		if ( get_option( 'spp_delayed_transfers_enabled', '0' ) !== '1' ) {
			return false;
		}
		$status = $order->get_status();
		return $status !== 'completed';
	}

	private function stampDelayedTransfer( \WC_Order $order ): void {
		$charge_id = $this->resolveChargeIdForOrder( $order );
		$order->update_meta_data( '_spp_pending_delayed_transfer', '1' );
		if ( $charge_id !== '' ) {
			$order->update_meta_data( '_spp_pending_charge_id', $charge_id );
		}
		$order->add_order_note(
			__( 'Split Pay: Transfer pending — will release when order is marked Completed.', 'bsd-split-pay-stripe-connect-woo' )
		);
		$order->save();
	}

	public function handleOrderCompleted( int $order_id ): void {
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return;
		}

		$pending = $order->get_meta( '_spp_pending_delayed_transfer', true );
		if ( empty( $pending ) ) {
			return;
		}

		$already_done = $order->get_meta( 'is_transfer_process_done', true );
		if ( ! empty( $already_done ) ) {
			$order->delete_meta_data( '_spp_pending_delayed_transfer' );
			$order->delete_meta_data( '_spp_pending_charge_id' );
			$order->save();
			return;
		}

		$gateway = $this->resolveGatewayForOrder( $order );
		if ( ! $gateway ) {
			return;
		}

		$secretKey = method_exists( $gateway, 'getTransferSecretKey' ) ? $gateway->getTransferSecretKey() : '';
		if ( empty( $secretKey ) ) {
			$secretKey = $gateway->getSecretKey();
		}
		if ( empty( $secretKey ) ) {
			$order->add_order_note( __( 'Split Pay: No Stripe API key available for delayed transfer.', 'bsd-split-pay-stripe-connect-woo' ) );
			return;
		}

		$charge_id = (string) $order->get_meta( '_spp_pending_charge_id', true );
		if ( $charge_id === '' ) {
			$charge_id = $this->resolveChargeIdForOrder( $order );
		}

		try {
			$adapter        = new WCOrderAdapter( $order );
			$stripeService  = new \SplitPay\Core\StripeService( $secretKey );
			$logger         = new \SplitPay\Core\TransferLogger();
			$connectedAccts = new \SplitPay\Core\ConnectedAccounts();
			$engine         = new \SplitPay\Core\TransferEngine( $stripeService, $logger, $connectedAccts );

			$engine->processTransfers( $adapter, $gateway, 'woocommerce', $charge_id ?: null );

			$order->delete_meta_data( '_spp_pending_delayed_transfer' );
			$order->delete_meta_data( '_spp_pending_charge_id' );
			$order->save();
		} catch ( \Throwable $e ) {
			$order->add_order_note( 'Split Pay: Delayed transfer error — ' . $e->getMessage() );
			$this->log( 'handleOrderCompleted error for order ' . $order_id . ': ' . $e->getMessage() );
		}
	}

	/* ---------------------------------------------------------------
	 *  Retry / Refund
	 * ------------------------------------------------------------- */

	public function addRetryOrderAction( array $actions, $order = null ): array {
		if ( ! $order ) {
			return $actions;
		}

		$payment_method = $order->get_payment_method();
		$is_stripe      = ( $payment_method === 'stripe' || strpos( $payment_method, 'stripe_' ) === 0 );

		if ( $is_stripe ) {
			$status = $order->get_status();
			if ( $status === 'processing' || $status === 'completed' ) {
				$actions['spp_retry_transfers'] = __( 'Retry Split Pay Transfers', 'bsd-split-pay-stripe-connect-woo' );
			}
		}

		return $actions;
	}

	/**
	 * Schedule an automatic, deduped Action Scheduler retry for an order whose transfers
	 * ALL failed. Hooked to `spp_order_transfers_all_failed` (never to a per-transfer
	 * failure) so it can never re-pay an already-succeeded destination. Attempts are
	 * capped per order (default 3); the manual order action remains the fallback. No-op
	 * if Action Scheduler isn't loaded.
	 *
	 * @param int $order_id WC order id (from spp_order_transfers_all_failed).
	 */
	public function scheduleAutoRetry( $order_id ): void {
		if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_has_scheduled_action' ) ) {
			return; // Action Scheduler unavailable — manual retry still works.
		}
		$order_id = (int) $order_id;
		if ( $order_id <= 0 || ! function_exists( 'wc_get_order' ) ) {
			return;
		}
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return;
		}
		$max      = (int) apply_filters( 'spp_max_auto_retry_attempts', 3, $order_id );
		$attempts = (int) $order->get_meta( '_spp_auto_retry_attempts' );
		if ( $attempts >= $max ) {
			return; // Stop auto-retrying; leave it for a manual retry.
		}
		if ( as_has_scheduled_action( 'spp_retry_transfer', [ $order_id ], 'spp' ) ) {
			return; // One pending auto-retry per order (dedup across multiple failed sub-transfers).
		}
		$min_const = defined( 'MINUTE_IN_SECONDS' ) ? MINUTE_IN_SECONDS : 60;
		$delay     = (int) apply_filters( 'spp_auto_retry_delay', 5 * $min_const, $order_id, $attempts );
		as_schedule_single_action( time() + $delay, 'spp_retry_transfer', [ $order_id ], 'spp' );
	}

	/**
	 * Action Scheduler callback for the automatic retry — runs the same retry path
	 * as the manual order action, incrementing the per-order attempt counter first.
	 *
	 * @param int $order_id WC order id.
	 */
	public function retryTransfersById( $order_id ): void {
		if ( ! function_exists( 'wc_get_order' ) ) {
			return;
		}
		$order = wc_get_order( (int) $order_id );
		if ( ! $order ) {
			return;
		}
		$order->update_meta_data( '_spp_auto_retry_attempts', (int) $order->get_meta( '_spp_auto_retry_attempts' ) + 1 );
		$order->save();
		$this->retryTransfers( $order );
	}

	public function retryTransfers( \WC_Order $order ): void {
		$order_id = $order->get_id();

		$order->delete_meta_data( 'is_transfer_process_done' );
		$order->delete_meta_data( 'spp_has_failed_transfers' );
		$order->save();

		// 3.7.5 (sc-DUP-XFER): a GENUINE retry is the ONLY place the per-order
		// Stripe idempotency salt advances. TransferEngine no longer bumps it per
		// processTransfers() call (that caused order 2288's double-pay), so we bump
		// it here — at the exact moment the done-flag is cleared for a retry — to
		// give every sub-transfer a FRESH idempotency key. Stripe v1 replays a
		// cached FAILURE for a reused key, so a previously-failed sub-transfer needs
		// a new key to actually re-execute; the ledger dedup in the engine still
		// skips any destination that already succeeded, so this can't re-pay them.
		if ( function_exists( '\\BSD_Split_Pay_Stripe_Connect_Woo\\spp_bump_idem_attempt' ) ) {
			\BSD_Split_Pay_Stripe_Connect_Woo\spp_bump_idem_attempt( $order_id );
		}

		$order->add_order_note(
			__( 'Split Pay: Transfer retry initiated. The plugin will re-attempt transfers for this order.', 'bsd-split-pay-stripe-connect-woo' )
		);

		$gateway = $this->resolveGatewayForOrder( $order );
		if ( ! $gateway ) {
			$order->add_order_note(
				__( 'Split Pay: Cannot retry transfers -- no active gateway found.', 'bsd-split-pay-stripe-connect-woo' )
			);
			return;
		}

		$isTest    = $gateway->isTestMode();
		$secretKey = method_exists( $gateway, 'getTransferSecretKey' ) ? $gateway->getTransferSecretKey() : '';
		if ( empty( $secretKey ) ) {
			$secretKey = $gateway->getSecretKey();
		}
		if ( empty( $secretKey ) ) {
			$order->add_order_note(
				__( 'Split Pay: Cannot retry transfers -- API key not configured.', 'bsd-split-pay-stripe-connect-woo' )
			);
			return;
		}

		try {
			$stripe = new \SplitPay\Core\StripeService( $secretKey );

			$pi_id = $order->get_meta( '_stripe_intent_id', true );
			if ( empty( $pi_id ) ) {
				$pi_id = $order->get_transaction_id();
			}

			if ( empty( $pi_id ) ) {
				$order->add_order_note(
					__( 'Split Pay: Could not retry -- no Stripe payment intent found for this order.', 'bsd-split-pay-stripe-connect-woo' )
				);
				return;
			}

			$pi      = $stripe->retrievePaymentIntent( $pi_id );
			$adapter = new WCOrderAdapter( $order );

			if ( class_exists( '\SplitPay\Core\TransferEngine' ) ) {
				$logger         = new \SplitPay\Core\TransferLogger();
				$connectedAccts = new \SplitPay\Core\ConnectedAccounts();
				$engine         = new \SplitPay\Core\TransferEngine( $stripe, $logger, $connectedAccts );
				$engine->processTransfers( $adapter, $gateway, 'woocommerce', $pi->latest_charge ?? '' );
			}

			$order->add_order_note(
				__( 'Split Pay: Transfer retry completed. Check order notes above for individual transfer results.', 'bsd-split-pay-stripe-connect-woo' )
			);
		} catch ( \Exception $e ) {
			$order->add_order_note(
				sprintf(
					/* translators: %s = error message from the failed transfer retry. */
					__( 'Split Pay: Transfer retry failed: %s', 'bsd-split-pay-stripe-connect-woo' ),
					$e->getMessage()
				)
			);
			$this->log( 'Transfer retry failed for order ' . $order_id . ': ' . $e->getMessage() );
		}
	}

	public function handleRefund( int $order_id, int $refund_id ): void {
		global $wpdb;

		$order  = wc_get_order( $order_id );
		$refund = wc_get_order( $refund_id );
		if ( ! $order || ! $refund ) {
			return;
		}

		$refund_total = abs( $refund->get_total() );
		$order_total  = abs( $order->get_total() );
		if ( $refund_total <= 0 || $order_total <= 0 ) {
			return;
		}

		$refund_ratio = $refund_total / $order_total;

		$table_name = $wpdb->prefix . SPP_TRANSFER_LOG_TABLE;
		$transfers  = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT transfer_id, transfer_amount, transfer_destination FROM {$table_name}
				 WHERE wc_order_id = %d AND transfer_id IS NOT NULL AND transfer_id != ''",
				$order_id
			),
			ARRAY_A
		);

		if ( empty( $transfers ) ) {
			$order->add_order_note(
				__( 'Split Pay: No transfers found for this order to reverse.', 'bsd-split-pay-stripe-connect-woo' )
			);
			return;
		}

		$gateway = $this->resolveGatewayForOrder( $order );
		if ( ! $gateway ) {
			$order->add_order_note(
				__( 'Split Pay: Could not reverse transfers -- no active gateway found.', 'bsd-split-pay-stripe-connect-woo' )
			);
			return;
		}

		$isTest    = $gateway->isTestMode();
		$secretKey = method_exists( $gateway, 'getTransferSecretKey' ) ? $gateway->getTransferSecretKey() : '';
		if ( empty( $secretKey ) ) {
			$secretKey = $gateway->getSecretKey();
		}
		if ( empty( $secretKey ) ) {
			$order->add_order_note(
				__( 'Split Pay: Could not reverse transfers -- API key not configured.', 'bsd-split-pay-stripe-connect-woo' )
			);
			return;
		}

		$stripe         = new \SplitPay\Core\StripeService( $secretKey );
		$is_full_refund        = ( $refund_total >= $order_total );
		$reversed_count        = 0;
		$failed_count          = 0;
		$reversed_transfer_ids = array();

		// 3.7.5 (sc-DUP-REFUND): serialize this refund against any concurrent
		// transfer/refund for the SAME order using the same per-order MySQL named
		// lock the transfer engine uses ('spp_xfer_<orderId>'). A charge.refunded
		// webhook and a manual refund could otherwise both enter this loop and
		// double-reverse. GET_LOCK is connection-scoped and survives across
		// PHP-FPM workers, so genuinely concurrent deliveries serialize. The
		// deterministic reversal idempotency key below is the second line of
		// defence (Stripe-enforced exactly-once).
		$spp_refund_lock = 'spp_xfer_' . $order_id;
		$spp_got_lock    = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $spp_refund_lock ) );
		if ( 1 !== $spp_got_lock ) {
			$this->log( sprintf(
				'handleRefund order %d: another worker holds the order lock — skipping this refund delivery (the other worker is handling it).',
				$order_id
			) );
			return;
		}

		try {
		foreach ( $transfers as $tr ) {
			$transfer_id = $tr['transfer_id'];
			if ( empty( $transfer_id ) || strpos( $transfer_id, 'tr_' ) !== 0 ) {
				continue;
			}

			try {
				$refund_currency = strtolower( $order->get_currency() ) ?: 'usd';
				if ( $is_full_refund ) {
					$full_cents  = \SplitPay\Core\CurrencyHelper::toStripeAmount( (float) $tr['transfer_amount'], $refund_currency );
					$spp_rev_key = 'spp_rev_' . $order_id . '_' . $transfer_id . '_' . $full_cents;
					$stripe->createTransferReversal( $transfer_id, null, $spp_rev_key );
				} else {
					$reversal_amount = \SplitPay\Core\CurrencyHelper::toStripeAmount( (float) $tr['transfer_amount'] * $refund_ratio, $refund_currency );
					if ( $reversal_amount < 1 ) {
						continue;
					}
					$spp_rev_key = 'spp_rev_' . $order_id . '_' . $transfer_id . '_' . $reversal_amount;
					$stripe->createTransferReversal( $transfer_id, $reversal_amount, $spp_rev_key );
				}

				$reversed_count++;
				$reversed_transfer_ids[] = $transfer_id;

				$order->add_order_note(
					sprintf(
						/* translators: %1$s = Stripe transfer ID, %2$s = destination connected account ID. */
						__( 'Split Pay: Transfer %1$s to %2$s reversed successfully.', 'bsd-split-pay-stripe-connect-woo' ),
						$transfer_id,
						$tr['transfer_destination']
					)
				);
			} catch ( \Exception $e ) {
				$failed_count++;
				$order->add_order_note(
					sprintf(
						/* translators: %1$s = Stripe transfer ID, %2$s = destination connected account ID, %3$s = error message. */
						__( 'Split Pay: Failed to reverse transfer %1$s to %2$s: %3$s', 'bsd-split-pay-stripe-connect-woo' ),
						$transfer_id,
						$tr['transfer_destination'],
						$e->getMessage()
					)
				);
				$this->log( 'Transfer reversal failed for ' . $transfer_id . ': ' . $e->getMessage() );
			}
		}

		if ( $reversed_count > 0 || $failed_count > 0 ) {
			$order->add_order_note(
				sprintf(
					/* translators: %1$d = number of transfers reversed, %2$d = number of transfers that failed, %3$s = refund ratio percentage. */
					__( 'Split Pay Refund Summary: %1$d transfer(s) reversed, %2$d failed. Refund ratio: %3$s%%', 'bsd-split-pay-stripe-connect-woo' ),
					$reversed_count,
					$failed_count,
					round( $refund_ratio * 100, 2 )
				)
			);
		}

		/**
		 * Fires after Split Pay has finished processing a refund/reversal for an order.
		 * Identical payload shape to TransferEngine::processRefund() — see its docblock.
		 */
		do_action(
			'spp_refund_processed',
			array(
				'order_id'              => $order_id,
				'refund_total'          => $refund_total,
				'order_total'           => $order_total,
				'refund_ratio'          => $refund_ratio,
				'is_full_refund'        => $is_full_refund,
				'reversed_count'        => $reversed_count,
				'failed_count'          => $failed_count,
				'reversed_transfer_ids' => $reversed_transfer_ids,
				'integration_source'    => 'woocommerce',
			)
		);
		} finally {
			// 3.7.5 (sc-DUP-REFUND): always release the per-order lock, on every exit
			// path (including a thrown exception inside the reversal loop).
			$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $spp_refund_lock ) );
		}
	}

	/* ---------------------------------------------------------------
	 *  Product data tabs
	 * ------------------------------------------------------------- */

	public function addProductDataTab( array $tabs ): array {
		/**
		 * Filter the CSS classes applied to the Split Pay product data tab in
		 * the WooCommerce product editor. Defaults to ['show_if_simple'].
		 *
		 * Add additional show_if_<type> classes (e.g. show_if_variable,
		 * show_if_pcfw_travel) to opt non-simple product types into the tab.
		 *
		 * @since 3.7.0
		 *
		 * @param string[]              $classes  Default tab CSS classes.
		 * @param WooCommerceIntegration $this    The integration instance.
		 */
		$classes = apply_filters( 'spp_product_data_tab_classes', [ 'show_if_simple' ], $this );

		$tabs['stripe-connect-split-pay-tab'] = [
			'label'  => __( 'Split Pay Plugin', 'bsd-split-pay-stripe-connect-woo' ),
			'target' => 'stripe_connect_split_pay_product_data',
			// Defensive: third-party filters on spp_product_data_tab_classes may
			// return non-array; PHPStan narrows on the literal default but the
			// filter contract is effectively mixed.
			// @phpstan-ignore-next-line -- defensive is_array() guard.
			'class'  => is_array( $classes ) ? array_values( $classes ) : [ 'show_if_simple' ],
		];
		return $tabs;
	}

	public function renderProductDataFields(): void {
		if ( defined( 'SPP_PLUGIN_DIR' ) ) {
			ob_start();
			require SPP_PLUGIN_DIR . '/includes/admin/partials/bsd-simple-product-table.php';
			// Trusted template markup (form fields); its dynamic values are escaped
			// at the point of output inside the partial. It contains
			// <select>/<option>/<input> that wp_kses_post() would strip and break
			// the product editor, so the assembled output is already safe.
			echo ob_get_clean(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}
	}

	public function saveProductDataFields( int $post_id ): void {
		// Nonce verification is performed upstream by WooCommerce in
		// WC_AJAX::save_product_meta() and WC_Meta_Box_Product_Data::save(),
		// both of which check the woocommerce_save_data nonce before firing the
		// woocommerce_process_product_meta action that calls this method.
		//
		// 3.7.4 form-presence gate (sc-7216). See the long-form comment on
		// BSD_SCSP_Main_Plugin::bsd_product_data_fields_save() in
		// includes/class-bsd-split-pay-stripe-connect-woo.php. The namespaced
		// WCProductAdapter::saveProductSplitPaySettings() is safer than the
		// legacy handler (it never writes when $_POST is null for the array
		// field), BUT it still calls delete_post_meta() on $types / $ship_types
		// being empty(), which fires for both "intentional clear" and
		// "field absent from $_POST". Adding the marker gate here makes both
		// handlers behave identically: if our tab didn't render, neither
		// handler touches the meta. Belt-and-suspenders consistent.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		if ( empty( $_POST['bsd_spp_product_tab_rendered'] ) ) {
			return;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$adapter  = new WCProductAdapter();
		$settings = $this->extractProductSettingsFromPost( $post_id );
		$adapter->saveProductSplitPaySettings( $post_id, $settings );
	}

	public function renderVariableProductFields( int $loop, array $variation_data, \WP_Post $variation ): void {
		if ( defined( 'SPP_PLUGIN_DIR' ) ) {
			ob_start();
			require SPP_PLUGIN_DIR . '/includes/admin/partials/bsd-variable-product-table.php';
			// Trusted template markup (form fields); its dynamic values are escaped
			// at the point of output inside the partial. It contains
			// <select>/<option>/<input> that wp_kses_post() would strip and break
			// the product editor, so the assembled output is already safe.
			echo ob_get_clean(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}
	}

	public function saveVariableProductFields( int $variation_id, int $i ): void {
		// 3.7.4 form-presence gate per variation (sc-7216). The variation save
		// hook receives a $_POST that holds every variation's fields keyed by
		// $variation_id. If our partial didn't render for THIS variation
		// (because the SPP tab wasn't expanded for it, or the per-variation
		// fields didn't survive max_input_vars truncation), the marker is
		// absent for that id and we leave the variation's SPP meta alone.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		if ( empty( $_POST['bsd_spp_variation_tab_rendered'][ $variation_id ] ) ) {
			return;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$adapter  = new WCProductAdapter();
		$settings = $this->extractVariationSettingsFromPost( $variation_id );
		$adapter->saveProductSplitPaySettings( $variation_id, $settings );
	}

	/* ---------------------------------------------------------------
	 *  Private helpers
	 * ------------------------------------------------------------- */

	/** @return GatewayAdapterInterface[] All gateway adapters including inactive ones. */
	public function getAllGatewayCandidates(): array {
		return [
			new WCStripeGateway(),
			new WooStripePaymentGateway(),
		];
	}

	private function resolveGatewayForOrder( \WC_Order $order ): ?GatewayAdapterInterface {
		$payment_method = $order->get_payment_method();

		/**
		 * Gateway IDs registered by Payment Plugins for Stripe WooCommerce (the
		 * `woo-stripe-payment` extension by paymentplugins.com).
		 *
		 * Pre-3.7.5 this list contained only the 4 card / express-checkout IDs
		 * (`stripe_cc`, `stripe_applepay`, `stripe_googlepay`, `stripe_payment_request`).
		 * Payment Plugins has registered many more gateways since at least 3.3.x
		 * (`stripe_klarna`, `stripe_ach`, `stripe_link_checkout`, `stripe_paybybank`,
		 * `stripe_cashapp`, etc.) and 4.0.0 adds further BNPL and local-payment IDs.
		 * The audit at `plugin-testing-suite/reports/spp-pp4/2026-05-28/RELEASE-AUDIT-PP4.md`
		 * captured the full 49-ID inventory empirically against PP 4.0.0-rc.2 on
		 * 2026-05-28.
		 *
		 * Without this expanded list, orders paid via any other Payment Plugins
		 * gateway would silently match `WCStripeGateway` (the official-WC-Stripe
		 * adapter) via the `$adapters[0]` fallthrough below, which on installs
		 * with only Payment Plugins configured leads to SPP calling Stripe with
		 * an empty secret key and transfers failing silently.
		 *
		 * The `spp_recognized_stripe_gateway_ids` filter lets site owners append
		 * additional Payment Plugins gateway IDs without a plugin update — e.g.
		 * if a future Payment Plugins release adds a new local-payment-method
		 * gateway before SPP can ship an update covering it.
		 */
		$pp_gateway_ids = apply_filters(
			'spp_recognized_stripe_gateway_ids',
			array(
				// Card + express checkout — the historical 4 SPP shipped pre-3.7.5
				'stripe_cc',
				'stripe_applepay',
				'stripe_googlepay',
				'stripe_payment_request',
				// Link Express Checkout (new gateway in PP 3.3.89)
				'stripe_link_checkout',
				'stripe_link',
				// Bank debits
				'stripe_ach',
				'stripe_us_bank_account',
				'stripe_sepa',
				'stripe_sepa_debit',
				'stripe_acss_debit',
				'stripe_bacs_debit',
				'stripe_au_becs_debit',
				'stripe_becs',
				// BNPL
				'stripe_klarna',
				'stripe_afterpay',
				'stripe_afterpay_clearpay',
				'stripe_affirm',
				'stripe_zip',
				'stripe_billie',
				// Wallets + alt
				'stripe_amazon_pay',
				'stripe_amazonpay',
				'stripe_cashapp',
				'stripe_paybybank',
				'stripe_upm',
				'stripe_revolut',
				// EU local methods
				'stripe_bancontact',
				'stripe_ideal',
				'stripe_eps',
				'stripe_p24',
				'stripe_multibanco',
				'stripe_mobilepay',
				'stripe_swish',
				'stripe_twint',
				'stripe_satispay',
				'stripe_blik',
				'stripe_giropay',
				'stripe_sofort',
				// APAC + LATAM local methods
				'stripe_alipay',
				'stripe_wechat',
				'stripe_wechat_pay',
				'stripe_grabpay',
				'stripe_fpx',
				'stripe_paynow',
				'stripe_promptpay',
				'stripe_konbini',
				'stripe_oxxo',
				'stripe_boleto',
			)
		);

		foreach ( $this->getGatewayAdapters() as $gw ) {
			if ( $payment_method === 'stripe' && $gw instanceof WCStripeGateway ) {
				return $gw;
			}
			if ( in_array( $payment_method, $pp_gateway_ids, true )
				&& $gw instanceof WooStripePaymentGateway
			) {
				return $gw;
			}
		}

		$adapters = $this->getGatewayAdapters();
		return ! empty( $adapters ) ? $adapters[0] : null;
	}

	private function extractProductSettingsFromPost( int $post_id ): array {
		return [
			'connected_accounts'   => isset( $_POST['_bsd_spscwt_product_connected_account'] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['_bsd_spscwt_product_connected_account'] ) )
				: null,
			'transfer_types'       => isset( $_POST['_bsd_spscwt_product_type'] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['_bsd_spscwt_product_type'] ) )
				: null,
			'transfer_percentages' => isset( $_POST['_stripe_connect_split_pay_transfer_percentage'] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['_stripe_connect_split_pay_transfer_percentage'] ) )
				: null,
			'transfer_amounts'     => isset( $_POST['_bsd_spscwt_product_amount'] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['_bsd_spscwt_product_amount'] ) )
				: null,
			'shipping_types'       => isset( $_POST['bsd_spscwt_shipping_type'] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['bsd_spscwt_shipping_type'] ) )
				: null,
			'shipping_percentages' => isset( $_POST['bsd_prod_shipping_percentage'] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['bsd_prod_shipping_percentage'] ) )
				: null,
			'shipping_amounts'     => isset( $_POST['bsd_prod_shipping_amount'] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['bsd_prod_shipping_amount'] ) )
				: null,
		];
	}

	private function extractVariationSettingsFromPost( int $variation_id ): array {
		return [
			'connected_accounts'   => isset( $_POST['_bsd_spscwt_product_connected_account'][ $variation_id ] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['_bsd_spscwt_product_connected_account'][ $variation_id ] ) )
				: null,
			'transfer_types'       => isset( $_POST['_bsd_spscwt_product_type'][ $variation_id ] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['_bsd_spscwt_product_type'][ $variation_id ] ) )
				: null,
			'transfer_percentages' => isset( $_POST['_stripe_connect_split_pay_transfer_percentage'][ $variation_id ] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['_stripe_connect_split_pay_transfer_percentage'][ $variation_id ] ) )
				: null,
			'transfer_amounts'     => isset( $_POST['_bsd_spscwt_product_amount'][ $variation_id ] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['_bsd_spscwt_product_amount'][ $variation_id ] ) )
				: null,
			'shipping_types'       => isset( $_POST['bsd_spscwt_shipping_type'][ $variation_id ] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['bsd_spscwt_shipping_type'][ $variation_id ] ) )
				: null,
			'shipping_percentages' => isset( $_POST['bsd_prod_shipping_percentage'][ $variation_id ] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['bsd_prod_shipping_percentage'][ $variation_id ] ) )
				: null,
			'shipping_amounts'     => isset( $_POST['bsd_prod_shipping_amount'][ $variation_id ] )
				? array_map( 'sanitize_text_field', wp_unslash( $_POST['bsd_prod_shipping_amount'][ $variation_id ] ) )
				: null,
		];
	}

	private function isPremium(): bool {
		if ( function_exists( 'BSD_Split_Pay_Stripe_Connect_Woo\bsdwcscsp_fs' ) ) {
			$fs = \BSD_Split_Pay_Stripe_Connect_Woo\bsdwcscsp_fs();
			return $fs->is__premium_only() && $fs->can_use_premium_code();
		}
		return false;
	}

	private function log( string $message ): void {
		$prefix = '[Split Pay WooCommerceIntegration] ';
		self::safe_wcs_log( $prefix . $message );
	}

	/**
	 * Mirror of BSD_SCSP_Main_Plugin::safe_wcs_log() (legacy main class). Inlined
	 * per namespaced class so the new architecture doesn't depend on the legacy
	 * class load order. Guards \WC_Stripe_Logger::log() against sites running
	 * Payment Plugins' woo-stripe-payment, which declares \WC_Stripe_Logger
	 * WITHOUT a static ::log() method, causing a fatal under the 3.7.0-3.7.2
	 * pattern that also blocks the woocommerce_payment_complete catch path
	 * and prevents the checkout redirect (resulting in duplicate customer
	 * orders). Falls back to ::debug() (which Payment Plugins ships and which
	 * is also the deprecated-since-WC-Stripe-9.7.0 replacement), then to PHP
	 * error_log() so we never lose the message.
	 *
	 * @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;
			}
		}
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( $message ); } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
	}
}
