<?php
/**
 * Mints short-lived PayPal REST access tokens from an account's own credentials.
 *
 * @since   1.1.0
 * @package DonationRouter
 */

declare( strict_types=1 );
namespace DonationRouter;

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

/**
 * Fetches OAuth 2.0 access tokens via the client-credentials grant using a
 * PayPal_Account's stored clientId/clientSecret, in that account's own
 * environment (live or sandbox) — never the global site mode.
 *
 * Tokens are cached in a transient until shortly before they expire so repeated
 * API calls (webhook registration, deletion) reuse a single token.
 *
 * @since 1.1.0
 */
class PayPal_Access_Token_Client {

	use Encryption;

	/**
	 * PayPal sandbox REST base URL.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	private const SANDBOX_BASE = 'https://api-m.sandbox.paypal.com';

	/**
	 * PayPal live REST base URL.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	private const LIVE_BASE = 'https://api-m.paypal.com';

	/**
	 * Transient key prefix for cached access tokens.
	 *
	 * @since 1.1.0
	 *
	 * @var string
	 */
	private const CACHE_PREFIX = 'ador_at_';

	/**
	 * The PayPal REST base URL for an account's environment.
	 *
	 * @since 1.1.0
	 *
	 * @param  PayPal_Account $account  The account whose environment to resolve.
	 *
	 * @return string
	 */
	public static function base_url_for( PayPal_Account $account ): string {
		return 'sandbox' === $account->mode ? self::SANDBOX_BASE : self::LIVE_BASE;
	}

	/**
	 * Return a valid access token for the account, minting a fresh one when the
	 * cached token is missing or expired.
	 *
	 * @since 1.1.0
	 *
	 * @param  PayPal_Account $account  The account to authenticate as.
	 *
	 * @return string  A bearer access token.
	 *
	 * @throws \RuntimeException  When credentials are missing or PayPal rejects them.
	 */
	public function get_token( PayPal_Account $account ): string {
		$account_id = (int) $account->id;
		$cache_key  = self::CACHE_PREFIX . $account_id;

		$cached = get_transient( $cache_key );
		if ( is_string( $cached ) && '' !== $cached ) {
			try {
				return $this->decrypt( $cached );
			} catch ( Credential_Decryption_Exception $e ) {
				// Salt rotated or cache corrupted — discard and mint a fresh token.
				delete_transient( $cache_key );
			}
		}

		$creds     = $account->get_credentials();
		$client_id = (string) ( $creds['clientId'] ?? '' );
		$secret    = (string) ( $creds['clientSecret'] ?? '' );

		if ( '' === $client_id || '' === $secret ) {
			throw new \RuntimeException(
				esc_html( 'Account ' . $account_id . ' has no clientId/clientSecret to authenticate with PayPal.' )
			);
		}

		$response = wp_remote_post(
			self::base_url_for( $account ) . '/v1/oauth2/token',
			array(
				'headers' => array(
					'Accept'        => 'application/json',
					// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
					'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $secret ),
				),
				'body'    => 'grant_type=client_credentials',
				'timeout' => 15,
			)
		);

		if ( is_wp_error( $response ) ) {
			throw new \RuntimeException( esc_html( 'PayPal token request failed: ' . $response->get_error_message() ) );
		}

		$status  = (int) wp_remote_retrieve_response_code( $response );
		$parsed  = json_decode( (string) wp_remote_retrieve_body( $response ), true );
		$token   = is_array( $parsed ) && isset( $parsed['access_token'] ) ? (string) $parsed['access_token'] : '';
		$expires = is_array( $parsed ) && isset( $parsed['expires_in'] ) ? (int) $parsed['expires_in'] : 0;

		// A 2xx with an access token is the only success. A non-2xx (e.g. 401
		// invalid_client on rotated credentials) carries error/error_description in
		// the body; surface it so the operator can tell a permanent auth failure
		// from a transient network blip instead of a generic message.
		if ( $status < 200 || $status >= 300 || '' === $token ) {
			$reason = '';
			if ( is_array( $parsed ) ) {
				$name   = isset( $parsed['error'] ) ? (string) $parsed['error'] : '';
				$desc   = isset( $parsed['error_description'] ) ? (string) $parsed['error_description'] : '';
				$reason = trim( $name . ( '' !== $desc ? ': ' . $desc : '' ) );
			}

			throw new \RuntimeException(
				esc_html(
					sprintf(
						'PayPal token request for account %d failed (HTTP %d)%s',
						$account_id,
						$status,
						'' !== $reason ? ' — ' . $reason : '. Check its credentials.'
					)
				)
			);
		}

		// Cache until ~60s before expiry so we never serve a token about to lapse,
		// encrypted at rest so a database-only compromise cannot lift a usable
		// bearer token from wp_options / the object cache.
		$ttl = $expires > 120 ? $expires - 60 : 60;
		set_transient( $cache_key, $this->encrypt( $token ), $ttl );

		return $token;
	}
}
