<?php
/**
 * Runtime PayPal credential routing — substitutes per-account credentials
 * into GiveWP's IoC container before each payment request.
 *
 * @since   1.0.0
 * @package DonationRouter
 */

declare( strict_types=1 );
namespace DonationRouter;

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

use Give\PaymentGateways\PayPalCommerce\Models\MerchantDetail;
use Give\PaymentGateways\PayPalCommerce\Repositories\MerchantDetails;

/**
 * Intercepts GiveWP PayPal AJAX actions to substitute the correct PayPal
 * merchant credentials based on the donation form's routing configuration.
 *
 * Integration points (from ARCHITECTURE.md § 3 & § 4):
 *  - MerchantDetail::class singleton → PayPalCommerceGateway, PayPalClient
 *  - MerchantDetails::class singleton → AuthorizationInjector::fetchAccessToken()
 *
 * Both singletons are re-bound (via forgetInstance + singleton) before GiveWP
 * processes each AJAX request. See ADR-006 in resources/plan/ARCHITECTURE.md.
 *
 * Constraints enforced here:
 *  - NEVER throws an unhandled exception — all failures log and fall back to
 *    default credentials.
 *  - NEVER caches credentials in static properties.
 *  - Verifies account is active before substituting; inactive accounts fall back.
 *
 * @since 1.0.0
 */
class PayPal_Credential_Router {

	/**
	 * Donation meta key that records which account processed the payment.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	public const META_PROCESSING_ACCOUNT_ID = '_ador_processing_account_id';

	/**
	 * Donation meta key that records the donor's selected country.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	public const META_DONOR_COUNTRY = '_ador_donor_country';

	/**
	 * GiveWP PayPal Commerce gateway identifier.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	private const GATEWAY_ID = 'paypal-commerce';

	/**
	 * Active routing rules source.
	 *
	 * @since 1.0.0
	 *
	 * @var Country_Routing_Repository_Interface
	 */
	private Country_Routing_Repository_Interface $routing_repo;

	/**
	 * Account data source.
	 *
	 * @since 1.0.0
	 *
	 * @var Account_Repository_Interface
	 */
	private Account_Repository_Interface $account_repo;

	/**
	 * Optional override for the IoC container rebinding step (used in tests).
	 *
	 * When null the router performs the actual give() container rebinding.
	 * Inject a callable in tests to assert rebinding without touching the
	 * GiveWP container.
	 *
	 * Signature: callable(MerchantDetail $detail): void
	 *
	 * @since 1.0.0
	 *
	 * @var callable|null
	 */
	private $rebind_callback;

	/**
	 * Account ID that was last successfully routed within the current request.
	 *
	 * Stored as an instance property (not static) so it is safe to reset
	 * between test cases. Null when no routing has occurred yet.
	 *
	 * @since 1.0.0
	 *
	 * @var int|null
	 */
	private int|null $last_routed_account_id = null;

	/**
	 * Records donations that bypassed routing.
	 *
	 * @since 1.2.0
	 *
	 * @var Fallback_Log
	 */
	private Fallback_Log $fallback_log;

	/**
	 * Initialise repositories and optional IoC rebind callback.
	 *
	 * @since 1.0.0
	 *
	 * @param Country_Routing_Repository_Interface $routing_repo     Active routing rules source.
	 * @param Account_Repository_Interface         $account_repo     Account data source.
	 * @param callable|null                        $rebind_callback  IoC rebind override (tests only).
	 * @param Fallback_Log|null                    $fallback_log     Fallback recorder; defaults to a real one.
	 */
	public function __construct(
		Country_Routing_Repository_Interface $routing_repo,
		Account_Repository_Interface $account_repo,
		?callable $rebind_callback = null,
		?Fallback_Log $fallback_log = null
	) {
		$this->routing_repo    = $routing_repo;
		$this->account_repo    = $account_repo;
		$this->rebind_callback = $rebind_callback;
		$this->fallback_log    = $fallback_log ?? new Fallback_Log();
	}

	/**
	 * Register WordPress hooks.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function register_hooks(): void {
		// Order minting: GiveWP's PayPal SDK calls these AJAX actions to create/
		// approve the order. Priority 1 runs before GiveWP's handlers (priority 10).
		add_action( 'wp_ajax_give_paypal_commerce_create_order', array( $this, 'substitute_for_request' ), 1 );
		add_action( 'wp_ajax_nopriv_give_paypal_commerce_create_order', array( $this, 'substitute_for_request' ), 1 );
		add_action( 'wp_ajax_give_paypal_commerce_approve_order', array( $this, 'substitute_for_request' ), 1 );
		add_action( 'wp_ajax_nopriv_give_paypal_commerce_approve_order', array( $this, 'substitute_for_request' ), 1 );

		/*
		 * Amount patch: covered defensively.
		 *
		 * GiveWP registers `give_paypal_commerce_update_order_amount` (priv and
		 * nopriv), whose handler issues an authenticated OrdersPatchRequest
		 * against an existing order. Its own bundled JS does not call it — the
		 * smart-button flow passes `update_amount=1` to approve_order instead,
		 * which is already hooked above — so this action is currently dormant in
		 * a stock install.
		 *
		 * Hooked anyway, because if anything ever does invoke it (a GiveWP
		 * change, an add-on, a custom form), the patch would otherwise
		 * authenticate with GiveWP's DEFAULT credentials against an order minted
		 * under a per-country merchant, and fail cross-merchant. The handler
		 * requires `give-form-id` in the POST (see
		 * AjaxRequestHandler::validateFrontendRequest()), which is the same field
		 * substitute_for_request() routes on, so no extra plumbing is needed.
		 */
		add_action( 'wp_ajax_give_paypal_commerce_update_order_amount', array( $this, 'substitute_for_request' ), 1 );
		add_action( 'wp_ajax_nopriv_give_paypal_commerce_update_order_amount', array( $this, 'substitute_for_request' ), 1 );

		/*
		 * Capture / verification: on the donation-processing request GiveWP calls
		 * PayPalCommerce::createPayment() → PayPalOrderRepository::getApprovedOrder()
		 * / approveOrder() (authenticated with the container's MerchantDetail).
		 * That request is NOT the create/approve-order AJAX above, so the order —
		 * minted under a per-country merchant — would otherwise be retrieved with
		 * GiveWP's DEFAULT credentials and fail cross-merchant. GiveWP applies this
		 * per-gateway filter immediately before GatewayPaymentController::create()
		 * in both the v3 (DonateController) and v2 (LegacyPaymentGatewayAdapter)
		 * flows, passing the Donation, so rebinding here lands before capture.
		 */
		add_filter( 'givewp_create_payment_gateway_data_' . self::GATEWAY_ID, array( $this, 'route_credentials_for_donation' ), 10, 2 );

		// Record donor country (best effort from POST) on the created donation.
		add_action( 'give_insert_payment', array( $this, 'record_donation_meta' ), 10, 2 );

		// Refund routing — priority 5, before RefundPaymentHandler (default 10).
		add_action( 'give_update_edited_donation', array( $this, 'route_for_refund' ), 5 );
	}

	/**
	 * Rebind PayPal credentials for the donation about to be captured.
	 *
	 * Hooked on `givewp_create_payment_gateway_data_paypal-commerce`, which GiveWP
	 * applies immediately before GatewayPaymentController::create() → the gateway's
	 * createPayment(). Resolving and rebinding here guarantees the account's
	 * credentials are installed before GiveWP's authenticated getApprovedOrder() /
	 * approveOrder() calls in the same request. Also records the processing account
	 * on the donation now — reliable here (we have the donation and its form id),
	 * unlike record_donation_meta() which runs before routing on this request.
	 *
	 * The gateway-data array is returned unmodified.
	 *
	 * @since 1.0.0
	 *
	 * @param  array<string,mixed>             $gateway_data  GiveWP gateway data (passed through untouched).
	 * @param  \Give\Donations\Models\Donation $donation    The donation being processed.
	 *
	 * @return array<string,mixed>
	 */
	public function route_credentials_for_donation( array $gateway_data, \Give\Donations\Models\Donation $donation ): array {
		// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GiveWP's Donation model property.
		$form_id = (int) $donation->formId;
		if ( $form_id <= 0 ) {
			return $gateway_data;
		}

		$this->route_credentials_for_form( $form_id );

		$donation_id = (int) $donation->id;
		if ( $donation_id > 0 && null !== $this->last_routed_account_id ) {
			update_post_meta( $donation_id, self::META_PROCESSING_ACCOUNT_ID, $this->last_routed_account_id );
		}

		return $gateway_data;
	}

	/**
	 * Read form_id from the current AJAX POST and substitute credentials.
	 *
	 * Hooked at priority 1 on PayPal AJAX actions.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function substitute_for_request(): void {
		$form_id = absint( wp_unslash( $_POST['give-form-id'] ?? 0 ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
		if ( $form_id <= 0 ) {
			return;
		}

		$this->route_credentials_for_form( $form_id );
	}

	/**
	 * Substitute PayPal credentials in GiveWP's IoC container for a form.
	 *
	 * On success, fires `ador_credentials_routed`.
	 * On any failure, logs the error and returns without modifying the container
	 * so GiveWP falls back to its default (primary) account.
	 *
	 * @since 1.0.0
	 *
	 * @param int $form_id  GiveWP donation form post ID.
	 *
	 * @return void
	 */
	public function route_credentials_for_form( int $form_id ): void {
		try {
			$routing = $this->routing_repo->find_by_form_id( $form_id );
			$account = $this->account_repo->find_by_id( $routing->account_id );

			if ( ! $account->is_active ) {
				$this->fallback_log->record( $form_id, Fallback_Log::REASON_INACTIVE );
				$this->log_error(
					"Account {$routing->account_id} is inactive — using default credentials.",
					$form_id
				);
				return;
			}

			if ( ! $this->mode_matches( $account ) ) {
				$this->fallback_log->record( $form_id, Fallback_Log::REASON_MODE_MISMATCH );
				$this->log_error(
					"Account {$account->id} is a {$account->mode} account but the site is in {$this->current_mode()} mode — using default credentials.",
					$form_id
				);
				return;
			}

			$merchant_detail = $this->build_merchant_detail( $account );

			$this->apply_container_rebinding( $merchant_detail );

			$this->last_routed_account_id = $account->id;

			/**
			 * Fires when credentials have been successfully substituted for a form.
			 *
			 * @since 1.0.0
			 *
			 * @param int      $form_id    GiveWP donation form post ID.
			 * @param int|null $account_id ID of the PayPal account whose credentials were applied.
			 */
			do_action( 'ador_credentials_routed', $form_id, $account->id );

		} catch ( \Throwable $e ) {
			$this->fallback_log->record(
				$form_id,
				$e instanceof Country_Routing_Not_Found_Exception
					? Fallback_Log::REASON_NO_RULE
					: Fallback_Log::REASON_ERROR
			);
			$this->log_error( $e->getMessage(), $form_id );
		}
	}

	/**
	 * Substitute credentials for a known account without a form routing lookup.
	 *
	 * Used by Webhook_Router after the account has already been resolved from the
	 * webhook URL parameter, bypassing the country-routing table entirely.
	 *
	 * Unlike the payment path, this deliberately does NOT gate on the site's
	 * global mode: the webhook caller already resolved this exact account from the
	 * URL and authenticated the event with a token + signature minted in the
	 * account's OWN environment, so the account's mode governs — a valid webhook
	 * for a sandbox account must still route under that account even while the site
	 * toggle is live (and vice versa). Gating on site mode here would silently
	 * dispatch the event under GiveWP's DEFAULT primary credentials.
	 *
	 * On any failure the method returns silently so GiveWP falls back to its
	 * default account — consistent with route_credentials_for_form().
	 *
	 * @since 1.0.0
	 *
	 * @param PayPal_Account $account       The account whose credentials to apply.
	 * @param string         $access_token  Freshly minted token to install (webhook path); '' keeps the stored snapshot.
	 *
	 * @return void
	 */
	public function route_credentials_for_account( PayPal_Account $account, string $access_token = '' ): void {
		try {
			if ( ! $account->is_active ) {
				$this->log_error( 'Account ' . (int) $account->id . ' is inactive — using default credentials.' );
				return;
			}

			$merchant_detail = $this->build_merchant_detail( $account, $access_token );
			$this->apply_container_rebinding( $merchant_detail );
			$this->last_routed_account_id = $account->id;
		} catch ( \Throwable $e ) {
			$this->log_error( $e->getMessage() );
		}
	}

	/**
	 * Save donation meta immediately after a payment record is created.
	 *
	 * Hooked on `give_insert_payment` (priority 10).
	 *
	 * @since 1.0.0
	 *
	 * @param int                 $payment_id   Newly created donation post ID.
	 * @param array<string,mixed> $payment_data Payment data array as passed to give_insert_payment().
	 *
	 * @return void
	 */
	public function record_donation_meta( int $payment_id, array $payment_data ): void {
		// Account ID: prefer what the credential router routed; fall back to Form_Renderer value.
		$account_id = $this->last_routed_account_id;
		if ( null === $account_id ) {
			$account_id = isset( $payment_data['meta'][ Form_Renderer::FIELD_ACCOUNT_ID ] )
				? (int) $payment_data['meta'][ Form_Renderer::FIELD_ACCOUNT_ID ]
				: 0;
		}

		if ( $account_id > 0 ) {
			update_post_meta( $payment_id, self::META_PROCESSING_ACCOUNT_ID, $account_id );
		}

		// Donor country.
		$country = $payment_data['meta'][ Form_Renderer::FIELD_DONOR_COUNTRY ] ?? '';
		if ( '' === $country ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Missing
			$country = strtoupper( sanitize_key( (string) wp_unslash( $_POST[ Form_Renderer::FIELD_DONOR_COUNTRY ] ?? '' ) ) );
		}

		if ( '' !== $country ) {
			update_post_meta( $payment_id, self::META_DONOR_COUNTRY, $country );
		}
	}

	/**
	 * Substitute credentials before GiveWP processes a refund.
	 *
	 * Hooked on `give_update_edited_donation` at priority 5 (before
	 * RefundPaymentHandler which runs at default priority 10).
	 *
	 * If the original processing account has been deactivated the refund is
	 * blocked with a clear admin error and a redirect; the operator must
	 * reactivate the account first.
	 *
	 * @since 1.0.0
	 *
	 * @param int $donation_id  Donation post ID.
	 *
	 * @return void
	 */
	public function route_for_refund( int $donation_id ): void {
		$new_status = sanitize_key( (string) wp_unslash( $_POST['give-payment-status'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
		if ( 'refunded' !== $new_status ) {
			return;
		}

		if ( self::GATEWAY_ID !== give_get_payment_gateway( $donation_id ) ) {
			return;
		}

		$account_id = (int) get_post_meta( $donation_id, self::META_PROCESSING_ACCOUNT_ID, true );
		if ( $account_id <= 0 ) {
			return;
		}

		try {
			$account = $this->account_repo->find_by_id( $account_id );

			if ( ! $account->is_active ) {
				give_set_error(
					'ador_inactive_account_refund',
					__(
						'Refund blocked: the PayPal account that processed this donation is deactivated. Reactivate the account in GiveWP → PayPal Accounts before issuing the refund.',
						'alamin-donation-router'
					)
				);

				wp_safe_redirect(
					admin_url(
						'edit.php?post_type=give_forms&page=give-payment-history&view=view-payment-details&id=' . $donation_id . '&ador-error=inactive-account'
					)
				);
				exit();
			}

			// GiveWP's RefundPaymentHandler authenticates against the base URL of the
			// site's CURRENT mode. Installing a live account's credentials while the
			// site is in sandbox mode (or vice versa) would send them to the wrong
			// PayPal API and fail with an opaque auth error. Block with a clear,
			// actionable message instead, matching the inactive-account guard above.
			if ( ! $this->mode_matches( $account ) ) {
				give_set_error(
					'ador_mode_mismatch_refund',
					sprintf(
						/* translators: 1: account environment (live/sandbox), 2: GiveWP's current mode. */
						__(
							'Refund blocked: this donation was processed by a %1$s PayPal account, but GiveWP is currently in %2$s mode. Switch GiveWP to %1$s mode before issuing the refund.',
							'alamin-donation-router'
						),
						$account->mode,
						$this->current_mode()
					)
				);

				wp_safe_redirect(
					admin_url(
						'edit.php?post_type=give_forms&page=give-payment-history&view=view-payment-details&id=' . $donation_id . '&ador-error=mode-mismatch'
					)
				);
				exit();
			}

			$merchant_detail = $this->build_merchant_detail( $account );
			$this->apply_container_rebinding( $merchant_detail );

		} catch ( \Throwable $e ) {
			$this->log_error( $e->getMessage(), 0, $donation_id );
		}
	}

	/**
	 * The PayPal environment the site is currently operating in.
	 *
	 * @since 1.1.0
	 *
	 * @return string  'sandbox' when GiveWP test mode is on, otherwise 'live'.
	 */
	private function current_mode(): string {
		return give_is_test_mode() ? 'sandbox' : 'live';
	}

	/**
	 * Whether an account's mode matches the site's current mode.
	 *
	 * Routing an account whose mode differs from the active environment would
	 * install credentials for the wrong PayPal API (live vs sandbox), so such
	 * an account is skipped in favour of GiveWP's default.
	 *
	 * @since 1.1.0
	 *
	 * @param PayPal_Account $account  The candidate account.
	 *
	 * @return bool
	 */
	private function mode_matches( PayPal_Account $account ): bool {
		return $account->mode === $this->current_mode();
	}

	/**
	 * Build a GiveWP MerchantDetail from a PayPal_Account's stored credentials.
	 *
	 * @since 1.0.0
	 *
	 * @param PayPal_Account $account       The account to build from.
	 * @param string         $access_token  A live token to install over the stored snapshot; '' keeps the snapshot.
	 *
	 * @return MerchantDetail
	 *
	 * @throws \RuntimeException  When the account is missing clientId or clientSecret.
	 */
	private function build_merchant_detail( PayPal_Account $account, string $access_token = '' ): MerchantDetail {
		$creds = $account->get_credentials();

		if ( empty( $creds['clientId'] ) || empty( $creds['clientSecret'] ) ) {
			throw new \RuntimeException(
				'Account ' . (int) $account->id . ' has incomplete credentials (missing clientId or clientSecret).'
			);
		}

		$token = is_array( $creds['token'] ?? null )
			? $creds['token']
			: array(
				'accessToken' => '',
				'expiresIn'   => 0,
				'tokenType'   => 'Bearer',
			);

		// A freshly minted token (webhook path) replaces the stored snapshot, which
		// expires within hours of connect/import. GiveWP's PaymentCaptureRefunded
		// reads MerchantDetail->accessToken directly (no re-mint), so a stale token
		// would 401 and silently fail to record the refund.
		if ( '' !== $access_token ) {
			$token['accessToken'] = $access_token;
		}

		return MerchantDetail::fromArray(
			array(
				'merchantId'             => $account->merchant_id,
				'merchantIdInPayPal'     => $creds['merchantIdInPayPal'] ?? $account->merchant_id,
				'clientId'               => (string) $creds['clientId'],
				'clientSecret'           => (string) $creds['clientSecret'],
				'token'                  => $token,
				'accountIsReady'         => $creds['accountIsReady'] ?? true,
				'supportsCustomPayments' => $creds['supportsCustomPayments'] ?? true,
				'accountCountry'         => $account->account_country,
			)
		);
	}

	/**
	 * Rebind give() container singletons with the account-specific credentials.
	 *
	 * When a rebind_callback was injected (tests) it is called instead.
	 *
	 * @since 1.0.0
	 *
	 * @param MerchantDetail $detail  The credential object to install.
	 *
	 * @return void
	 */
	private function apply_container_rebinding( MerchantDetail $detail ): void {
		if ( null !== $this->rebind_callback ) {
			( $this->rebind_callback )( $detail );
			return;
		}

		// Drop cached singletons so the next resolution uses our closures.
		give()->forgetInstance( MerchantDetail::class );
		give()->forgetInstance( MerchantDetails::class );

		give()->singleton( MerchantDetail::class, static fn() => $detail );
		give()->singleton(
			MerchantDetails::class,
			static fn() => new Merchant_Details_Overrider( $detail )
		);
	}

	/**
	 * Write a structured error to the PHP error log.
	 *
	 * @since 1.0.0
	 *
	 * @param string $message      Error description.
	 * @param int    $form_id      Related form ID (0 if unknown).
	 * @param int    $donation_id  Related donation ID (0 if unknown).
	 *
	 * @return void
	 */
	private function log_error( string $message, int $form_id = 0, int $donation_id = 0 ): void {
		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		error_log(
			sprintf(
				'[ADOR] Credential routing failed. form_id=%d donation_id=%d error=%s',
				$form_id,
				$donation_id,
				$message
			)
		);
	}
}
