<?php

namespace SplitPay\Core;

use Stripe\Charge;
use Stripe\PaymentIntent;
use Stripe\StripeClient;
use Stripe\Transfer;
use Stripe\TransferReversal;
use Stripe\Account;
use Stripe\Exception\ApiErrorException;

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

class StripeService {

	/**
	 * Sentinel returned by createTransfer()/createTransferReversal() when Stripe
	 * reports the idempotency key is already in use by a concurrent in-flight
	 * request (`idempotency_key_in_use`). The first of two near-simultaneous
	 * webhook deliveries is creating the transfer; this delivery must treat that
	 * as "already created" (the money is moving exactly once) rather than a
	 * failure. The transfer/reversal id is unknown here (it belongs to the other
	 * request), so the id is empty and this flag is set; callers (TransferEngine)
	 * recognise it and skip writing a duplicate log row while still counting it
	 * as a success for the done-flag. 3.7.5 (sc-DUP-XFER).
	 */
	public const IDEMPOTENT_REPLAY_FLAG = 'spp_idempotent_replay';

	private string $secretKey;
	private StripeClient $client;

	public function __construct( string $secretKey ) {
		$this->secretKey = $secretKey;
		// 3.7.5 (hardening): Stripe-recommended automatic retries on transient
		// network/5xx errors. Safe ONLY because every money-moving call below now
		// carries a deterministic idempotency key, so an SDK retry can never
		// double-create.
		//
		// CONFIGURED PER-CLIENT, NOT GLOBALLY. The earlier shape called
		// \Stripe\Stripe::setMaxNetworkRetries()/setApiVersion() — process-global
		// statics that would silently change retry + API-version behaviour for
		// EVERY other plugin sharing stripe-php in the same WP request (the official
		// WooCommerce Stripe gateway, other Gaucho plugins). Passing the config to
		// THIS StripeClient instead scopes it to SPP's own calls. `stripe_version`
		// already defaults per-client to the bundled SDK's pinned version
		// (\Stripe\Util\ApiVersion::CURRENT), so we don't need to set it — that
		// default alone prevents account-default-version drift for our requests.
		//
		// 3.7.5 (sc-SDK-COLLISION, found on Cloudways staging): `max_network_retries`
		// is a VALID StripeClient config key only on newer stripe-php (we bundle
		// 19.4.0, which accepts it). But WordPress loads ONE \Stripe\StripeClient per
		// request and the FIRST plugin to register the class wins — on a site that also
		// runs an older Stripe plugin (e.g. Payment Plugins' woo-stripe-payment bundles
		// stripe-php 13.18.0), THAT older class is what `new StripeClient()` constructs,
		// and 13.x's BaseStripeClient::DEFAULT_CONFIG does NOT include
		// `max_network_retries` — so passing it throws
		// `InvalidArgumentException: Found unknown key(s) in configuration array:
		// 'max_network_retries'` and EVERY SPP transfer fatals. We therefore only pass
		// the key when the actually-loaded SDK supports it; otherwise we construct
		// without it (retries default to 0 — the deterministic idempotency keys keep
		// every money call exactly-once-safe regardless of retry count). We never fall
		// back to the global setMaxNetworkRetries() static (that would re-introduce the
		// cross-plugin mutation the per-client config was added to avoid).
		$this->client = new StripeClient( self::buildClientConfig( $this->secretKey ) );
	}

	/**
	 * Build the StripeClient config array for the ACTUALLY-LOADED stripe-php version.
	 * Always includes api_key; includes max_network_retries only when the loaded
	 * SDK's StripeClient accepts it as a config key (see sc-SDK-COLLISION note in
	 * __construct). Static + side-effect-free so it is unit-testable.
	 *
	 * @return array<string,mixed>
	 */
	public static function buildClientConfig( string $secretKey ): array {
		$config = [ 'api_key' => $secretKey ];
		if ( self::supportsPerClientMaxRetries() ) {
			$config['max_network_retries'] = 2;
		}
		return $config;
	}

	/**
	 * True when the loaded stripe-php's StripeClient accepts `max_network_retries`
	 * as a constructor config key. Detected from BaseStripeClient::DEFAULT_CONFIG
	 * (the authoritative allow-list the SDK validates incoming config against).
	 * Falls back to false (the safe choice — omit the key) if that constant can't
	 * be read on an unexpected SDK shape.
	 */
	private static function supportsPerClientMaxRetries(): bool {
		if ( ! class_exists( '\\Stripe\\BaseStripeClient' ) ) {
			// Pre-StripeClient SDKs (stripe-php < 7) have no per-client config at all.
			return false;
		}
		if ( ! defined( '\\Stripe\\BaseStripeClient::DEFAULT_CONFIG' ) ) {
			return false;
		}
		$default = \Stripe\BaseStripeClient::DEFAULT_CONFIG;
		// Defensive: PHPStan narrows DEFAULT_CONFIG to array from the bundled SDK's
		// stub, but the whole point of this probe is that a DIFFERENT stripe-php
		// version may win the per-request autoload at runtime (the Cloudways
		// staging collision, sc-SDK-COLLISION). The is_array() guard stays so a
		// malformed/older constant shape degrades to "unsupported" rather than fatals.
		// @phpstan-ignore-next-line -- defensive: constant shape is SDK-version-dependent at runtime.
		return is_array( $default ) && array_key_exists( 'max_network_retries', $default );
	}

	public function createTransfer(
		int $amount,
		string $currency,
		string $sourceTransaction,
		string $destination,
		string $description = '',
		array $metadata = [],
		string $idempotencyKey = ''
	): Transfer {
		$params = [
			'amount'             => $amount,
			'currency'           => $currency,
			'source_transaction' => $sourceTransaction,
			'destination'        => $destination,
		];

		if ( $description !== '' ) {
			$params['description'] = $description;
		}

		if ( ! empty( $metadata ) ) {
			$params['metadata'] = $metadata;
		}

		$opts = [];
		if ( $idempotencyKey !== '' ) {
			$opts['idempotency_key'] = $idempotencyKey;
		}

		try {
			return $this->client->transfers->create( $params, $opts );
		} catch ( ApiErrorException $e ) {
			// 3.7.5 (sc-DUP-XFER / YA-8 done right): a concurrent same-key request
			// is still in flight → Stripe returns `idempotency_key_in_use`. This is
			// NOT an error to surface: the other webhook delivery is creating this
			// exact transfer, so the money moves exactly once. Return the replay
			// sentinel so TransferEngine records it as already-created (no duplicate
			// log row, counts toward the done-flag) instead of a failed transfer
			// that would wrongly trigger an auto-retry. This is precisely the case
			// the per-call idempotency salt was bolted on to dodge — handling it at
			// the source lets the salt stay stable across a double-fire.
			if ( self::isIdempotencyKeyInUse( $e ) ) {
				return Transfer::constructFrom( [
					'id'                          => '',
					'amount'                      => $amount,
					'destination'                 => $destination,
					self::IDEMPOTENT_REPLAY_FLAG  => true,
				] );
			}
			throw $e;
		}
	}

	public function createTransferReversal( string $transferId, ?int $amount = null, string $idempotencyKey = '' ): TransferReversal {
		$params = [];

		if ( $amount !== null ) {
			$params['amount'] = $amount;
		}

		$opts = [];
		if ( $idempotencyKey !== '' ) {
			$opts['idempotency_key'] = $idempotencyKey;
		}

		try {
			return $this->client->transfers->createReversal( $transferId, $params, $opts );
		} catch ( ApiErrorException $e ) {
			// 3.7.5 (sc-DUP-REFUND): a concurrent same-key reversal is in flight
			// (charge.refunded webhook + manual refund firing together) → the other
			// request is reversing this transfer, so it is reversed exactly once.
			// Return the replay sentinel so the caller does not double-count or
			// surface a spurious failure.
			if ( self::isIdempotencyKeyInUse( $e ) ) {
				return TransferReversal::constructFrom( [
					'id'                          => '',
					'transfer'                    => $transferId,
					'amount'                      => $amount ?? 0,
					self::IDEMPOTENT_REPLAY_FLAG  => true,
				] );
			}
			throw $e;
		}
	}

	/**
	 * True when a Stripe API exception is the `idempotency_key_in_use` error
	 * (HTTP 409) — a concurrent request is still creating the same object under
	 * this idempotency key. Checks the structured error code first, then falls
	 * back to a message match for SDK builds that don't populate the code.
	 *
	 * @since 3.7.5
	 */
	private static function isIdempotencyKeyInUse( ApiErrorException $e ): bool {
		$code = method_exists( $e, 'getStripeCode' ) ? (string) $e->getStripeCode() : '';
		if ( $code === '' ) {
			$err = method_exists( $e, 'getError' ) ? $e->getError() : null;
			if ( $err && isset( $err->code ) ) {
				$code = (string) $err->code;
			}
		}
		if ( $code === 'idempotency_key_in_use' ) {
			return true;
		}
		return stripos( $e->getMessage(), 'idempotent' ) !== false
			&& stripos( $e->getMessage(), 'in-progress' ) !== false;
	}

	public function retrievePaymentIntent( string $id ): PaymentIntent {
		return $this->client->paymentIntents->retrieve( $id );
	}

	public function retrieveCharge( string $id ): Charge {
		return $this->client->charges->retrieve( $id );
	}

	public function retrieveAccount( string $id = 'self' ): Account {
		if ( $id === 'self' ) {
			return $this->client->accounts->retrieve();
		}

		return $this->client->accounts->retrieve( $id );
	}

	public function updateChargeMetadata( string $chargeId, array $metadata, string $stripeAccount = '', string $idempotencyKey = '' ): Charge {
		$opts = [];

		if ( $stripeAccount !== '' ) {
			$opts['stripe_account'] = $stripeAccount;
		}

		// 3.7.5 (sc-DUP-XFER companion): a deterministic idempotency key makes a
		// replayed webhook delivery's metadata write a safe no-op replay instead of
		// a second mutation.
		if ( $idempotencyKey !== '' ) {
			$opts['idempotency_key'] = $idempotencyKey;
		}

		return $this->client->charges->update( $chargeId, [ 'metadata' => $metadata ], $opts );
	}
}
