<?php

namespace Stripe\StripeTaxForWooCommerce\SDK\lib;

/**
 * Class ApiRequestor.
 */
class ApiRequestor {

	/**
	 * @var null|string
	 */
	private $_apiKey;

	/**
	 * @var string
	 */
	private $_apiBase;

	/**
	 * @var HttpClient\ClientInterface
	 */
	private static $_httpClient;
	/**
	 * @var HttpClient\StreamingClientInterface
	 */
	private static $_streamingHttpClient;

	/**
	 * @var RequestTelemetry
	 */
	private static $requestTelemetry;

	private static $OPTIONS_KEYS = array( 'api_key', 'idempotency_key', 'stripe_account', 'stripe_version', 'api_base' );

	/**
	 * ApiRequestor constructor.
	 *
	 * @param null|string $apiKey
	 * @param null|string $apiBase
	 */
	public function __construct( $apiKey = null, $apiBase = null ) {
		$this->_apiKey = $apiKey;
		if ( ! $apiBase ) {
			$apiBase = Stripe::$apiBase;
		}
		$this->_apiBase = $apiBase;
	}

	/**
	 * Creates a telemetry json blob for use in 'X-Stripe-Client-Telemetry' headers.
	 *
	 * @static
	 *
	 * @param RequestTelemetry $requestTelemetry
	 *
	 * @return string
	 */
	private static function _telemetryJson( $requestTelemetry ) {
		$payload = array(
			'last_request_metrics' => array(
				'request_id'          => $requestTelemetry->requestId,
				'request_duration_ms' => $requestTelemetry->requestDuration,
			),
		);
		if ( \count( $requestTelemetry->usage ) > 0 ) {
			$payload['last_request_metrics']['usage'] = $requestTelemetry->usage;
		}

		$result = \json_encode( $payload );
		if ( false !== $result ) {
			return $result;
		}
		Stripe::getLogger()->error( 'Serializing telemetry payload failed!' );

		return '{}';
	}

	/**
	 * @static
	 *
	 * @param ApiResource|array|bool|mixed $d
	 *
	 * @return ApiResource|array|mixed|string
	 */
	private static function _encodeObjects( $d ) {
		if ( $d instanceof ApiResource ) {
			return Util\Util::utf8( $d->id );
		}
		if ( true === $d ) {
			return 'true';
		}
		if ( false === $d ) {
			return 'false';
		}
		if ( \is_array( $d ) ) {
			$res = array();
			foreach ( $d as $k => $v ) {
				$res[ $k ] = self::_encodeObjects( $v );
			}

			return $res;
		}

		return Util\Util::utf8( $d );
	}

	/**
	 * @param 'delete'|'get'|'post' $method
	 * @param string                $url
	 * @param null|array            $params
	 * @param null|array            $headers
	 * @param string[]              $usage
	 *
	 * @throws Exception\ApiErrorException
	 *
	 * @return array tuple containing (ApiReponse, API key)
	 */
	public function request( $method, $url, $params = null, $headers = null, $usage = array() ) {
		$params                                    = $params ?: array();
		$headers                                   = $headers ?: array();
		list($rbody, $rcode, $rheaders, $myApiKey) =
		$this->_requestRaw( $method, $url, $params, $headers, $usage );
		$json                                      = $this->_interpretResponse( $rbody, $rcode, $rheaders );
		$resp                                      = new ApiResponse( $rbody, $rcode, $rheaders, $json );

		return array( $resp, $myApiKey );
	}

	/**
	 * @param 'delete'|'get'|'post' $method
	 * @param string                $url
	 * @param callable              $readBodyChunkCallable
	 * @param null|array            $params
	 * @param null|array            $headers
	 * @param string[]              $usage
	 *
	 * @throws Exception\ApiErrorException
	 */
	public function requestStream( $method, $url, $readBodyChunkCallable, $params = null, $headers = null, $usage = array() ) {
		$params                                    = $params ?: array();
		$headers                                   = $headers ?: array();
		list($rbody, $rcode, $rheaders, $myApiKey) =
		$this->_requestRawStreaming( $method, $url, $params, $headers, $usage, $readBodyChunkCallable );
		if ( $rcode >= 300 ) {
			$this->_interpretResponse( $rbody, $rcode, $rheaders );
		}
	}

	/**
	 * @param string $rbody a JSON string
	 * @param int    $rcode
	 * @param array  $rheaders
	 * @param array  $resp
	 *
	 * @throws Exception\UnexpectedValueException
	 * @throws Exception\ApiErrorException
	 */
	public function handleErrorResponse( $rbody, $rcode, $rheaders, $resp ) {
		if ( ! \is_array( $resp ) || ! isset( $resp['error'] ) ) {
			$msg = "Invalid response object from API: {$rbody} "
				. "(HTTP response code was {$rcode})";

			throw new Exception\UnexpectedValueException( $msg );
		}

		$errorData = $resp['error'];

		$error = null;
		if ( \is_string( $errorData ) ) {
			$error = self::_specificOAuthError( $rbody, $rcode, $rheaders, $resp, $errorData );
		}
		if ( ! $error ) {
			$error = self::_specificAPIError( $rbody, $rcode, $rheaders, $resp, $errorData );
		}

		throw $error;
	}

	/**
	 * @static
	 *
	 * @param string $rbody
	 * @param int    $rcode
	 * @param array  $rheaders
	 * @param array  $resp
	 * @param array  $errorData
	 *
	 * @return Exception\ApiErrorException
	 */
	private static function _specificAPIError( $rbody, $rcode, $rheaders, $resp, $errorData ) {
		$msg         = isset( $errorData['message'] ) ? $errorData['message'] : null;
		$param       = isset( $errorData['param'] ) ? $errorData['param'] : null;
		$code        = isset( $errorData['code'] ) ? $errorData['code'] : null;
		$type        = isset( $errorData['type'] ) ? $errorData['type'] : null;
		$declineCode = isset( $errorData['decline_code'] ) ? $errorData['decline_code'] : null;

		switch ( $rcode ) {
			case 400:
				// 'rate_limit' code is deprecated, but left here for backwards compatibility
				// for API versions earlier than 2015-09-08
				if ( 'rate_limit' === $code ) {
					return Exception\RateLimitException::factory( $msg, $rcode, $rbody, $resp, $rheaders, $code, $param );
				}
				if ( 'idempotency_error' === $type ) {
					return Exception\IdempotencyException::factory( $msg, $rcode, $rbody, $resp, $rheaders, $code );
				}

				// no break
			case 404:
				return Exception\InvalidRequestException::factory( $msg, $rcode, $rbody, $resp, $rheaders, $code, $param );

			case 401:
				return Exception\AuthenticationException::factory( $msg, $rcode, $rbody, $resp, $rheaders, $code );

			case 402:
				return Exception\CardException::factory( $msg, $rcode, $rbody, $resp, $rheaders, $code, $declineCode, $param );

			case 403:
				return Exception\PermissionException::factory( $msg, $rcode, $rbody, $resp, $rheaders, $code );

			case 429:
				return Exception\RateLimitException::factory( $msg, $rcode, $rbody, $resp, $rheaders, $code, $param );

			default:
				return Exception\UnknownApiErrorException::factory( $msg, $rcode, $rbody, $resp, $rheaders, $code );
		}
	}

	/**
	 * @static
	 *
	 * @param bool|string $rbody
	 * @param int         $rcode
	 * @param array       $rheaders
	 * @param array       $resp
	 * @param string      $errorCode
	 *
	 * @return Exception\OAuth\OAuthErrorException
	 */
	private static function _specificOAuthError( $rbody, $rcode, $rheaders, $resp, $errorCode ) {
		$description = isset( $resp['error_description'] ) ? $resp['error_description'] : $errorCode;

		switch ( $errorCode ) {
			case 'invalid_client':
				return Exception\OAuth\InvalidClientException::factory( $description, $rcode, $rbody, $resp, $rheaders, $errorCode );

			case 'invalid_grant':
				return Exception\OAuth\InvalidGrantException::factory( $description, $rcode, $rbody, $resp, $rheaders, $errorCode );

			case 'invalid_request':
				return Exception\OAuth\InvalidRequestException::factory( $description, $rcode, $rbody, $resp, $rheaders, $errorCode );

			case 'invalid_scope':
				return Exception\OAuth\InvalidScopeException::factory( $description, $rcode, $rbody, $resp, $rheaders, $errorCode );

			case 'unsupported_grant_type':
				return Exception\OAuth\UnsupportedGrantTypeException::factory( $description, $rcode, $rbody, $resp, $rheaders, $errorCode );

			case 'unsupported_response_type':
				return Exception\OAuth\UnsupportedResponseTypeException::factory( $description, $rcode, $rbody, $resp, $rheaders, $errorCode );

			default:
				return Exception\OAuth\UnknownOAuthErrorException::factory( $description, $rcode, $rbody, $resp, $rheaders, $errorCode );
		}
	}

	/**
	 * @static
	 *
	 * @param null|array $appInfo
	 *
	 * @return null|string
	 */
	private static function _formatAppInfo( $appInfo ) {
		if ( null !== $appInfo ) {
			$string = $appInfo['name'];
			if ( null !== $appInfo['version'] ) {
				$string .= '/' . $appInfo['version'];
			}
			if ( null !== $appInfo['url'] ) {
				$string .= ' (' . $appInfo['url'] . ')';
			}

			return $string;
		}

		return null;
	}

	/**
	 * @static
	 *
	 * @param string $disableFunctionsOutput - String value of the 'disable_function' setting, as output by \ini_get('disable_functions')
	 * @param string $functionName - Name of the function we are interesting in seeing whether or not it is disabled
	 *
	 * @return bool
	 */
	private static function _isDisabled( $disableFunctionsOutput, $functionName ) {
		$disabledFunctions = \explode( ',', $disableFunctionsOutput );
		foreach ( $disabledFunctions as $disabledFunction ) {
			if ( \trim( $disabledFunction ) === $functionName ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * @static
	 *
	 * @param string $apiKey
	 * @param null   $clientInfo
	 *
	 * @return array
	 */
	private static function _defaultHeaders( $apiKey, $clientInfo = null ) {
		$uaString = 'Stripe/v1 PhpBindings/' . Stripe::VERSION;

		$langVersion    = \PHP_VERSION;
		$uname_disabled = self::_isDisabled( \ini_get( 'disable_functions' ), 'php_uname' );
		$uname          = $uname_disabled ? '(disabled)' : \php_uname();

		$appInfo = Stripe::getAppInfo();
		$ua      = array(
			'bindings_version' => Stripe::VERSION,
			'lang'             => 'php',
			'lang_version'     => $langVersion,
			'publisher'        => 'stripe',
			'uname'            => $uname,
		);
		if ( $clientInfo ) {
			$ua = \array_merge( $clientInfo, $ua );
		}
		if ( null !== $appInfo ) {
			$uaString         .= ' ' . self::_formatAppInfo( $appInfo );
			$ua['application'] = $appInfo;
		}

		return array(
			'X-Stripe-Client-User-Agent' => \json_encode( $ua ),
			'User-Agent'    => $uaString,
			'Authorization' => 'Bearer ' . $apiKey,
			'Stripe-Version' => Stripe::getApiVersion(),
		);
	}

	private function _prepareRequest( $method, $url, $params, $headers ) {
		$myApiKey = $this->_apiKey;
		if ( ! $myApiKey ) {
			$myApiKey = Stripe::$apiKey;
		}

		if ( ! $myApiKey ) {
			$msg = 'No API key provided.  (HINT: set your API key using '
				. '"Stripe::setApiKey(<API-KEY>)".  You can generate API keys from '
				. 'the Stripe web interface.  See https://stripe.com/api for '
				. 'details, or email support@stripe.com if you have any questions.';

			throw new Exception\AuthenticationException( $msg );
		}

		// Clients can supply arbitrary additional keys to be included in the
		// X-Stripe-Client-User-Agent header via the optional getUserAgentInfo()
		// method
		$clientUAInfo = null;
		if ( \method_exists( $this->httpClient(), 'getUserAgentInfo' ) ) {
			$clientUAInfo = $this->httpClient()->getUserAgentInfo();
		}

		if ( $params && \is_array( $params ) ) {
			$optionKeysInParams = \array_filter(
				self::$OPTIONS_KEYS,
				function ( $key ) use ( $params ) {
					return \array_key_exists( $key, $params );
				}
			);
			if ( \count( $optionKeysInParams ) > 0 ) {
				$message = \sprintf(
					'Options found in $params: %s. Options should '
					. 'be passed in their own array after $params. (HINT: pass an '
					. 'empty array to $params if you do not have any.)',
					\implode( ', ', $optionKeysInParams )
				);
				\trigger_error( $message, \E_USER_WARNING );
			}
		}

		$absUrl         = $this->_apiBase . $url;
		$params         = self::_encodeObjects( $params );
		$defaultHeaders = $this->_defaultHeaders( $myApiKey, $clientUAInfo );

		if ( Stripe::$accountId ) {
			$defaultHeaders['Stripe-Account'] = Stripe::$accountId;
		}

		if ( Stripe::$enableTelemetry && null !== self::$requestTelemetry ) {
			$defaultHeaders['X-Stripe-Client-Telemetry'] = self::_telemetryJson( self::$requestTelemetry );
		}

		$hasFile = false;
		foreach ( $params as $k => $v ) {
			if ( \is_resource( $v ) ) {
				$hasFile      = true;
				$params[ $k ] = self::_processResourceParam( $v );
			} elseif ( $v instanceof \CURLFile ) {
				$hasFile = true;
			}
		}

		if ( $hasFile ) {
			$defaultHeaders['Content-Type'] = 'multipart/form-data';
		} else {
			$defaultHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
		}

		$combinedHeaders = \array_merge( $defaultHeaders, $headers );
		$rawHeaders      = array();

		foreach ( $combinedHeaders as $header => $value ) {
			$rawHeaders[] = $header . ': ' . $value;
		}

		return array( $absUrl, $rawHeaders, $params, $hasFile, $myApiKey );
	}

	/**
	 * @param 'delete'|'get'|'post' $method
	 * @param string                $url
	 * @param array                 $params
	 * @param array                 $headers
	 * @param string[]              $usage
	 *
	 * @throws Exception\AuthenticationException
	 * @throws Exception\ApiConnectionException
	 *
	 * @return array
	 */
	private function _requestRaw( $method, $url, $params, $headers, $usage ) {
		list($absUrl, $rawHeaders, $params, $hasFile, $myApiKey) = $this->_prepareRequest( $method, $url, $params, $headers );

		$requestStartMs = Util\Util::currentTimeMillis();

		list($rbody, $rcode, $rheaders) = $this->httpClient()->request(
			$method,
			$absUrl,
			$rawHeaders,
			$params,
			$hasFile
		);

		if ( isset( $rheaders['request-id'] )
		&& \is_string( $rheaders['request-id'] )
		&& '' !== $rheaders['request-id'] ) {
			self::$requestTelemetry = new RequestTelemetry(
				$rheaders['request-id'],
				Util\Util::currentTimeMillis() - $requestStartMs,
				$usage
			);
		}

		return array( $rbody, $rcode, $rheaders, $myApiKey );
	}

	/**
	 * @param 'delete'|'get'|'post' $method
	 * @param string                $url
	 * @param array                 $params
	 * @param array                 $headers
	 * @param string[]              $usage
	 * @param callable              $readBodyChunkCallable
	 *
	 * @throws Exception\AuthenticationException
	 * @throws Exception\ApiConnectionException
	 *
	 * @return array
	 */
	private function _requestRawStreaming( $method, $url, $params, $headers, $usage, $readBodyChunkCallable ) {
		list($absUrl, $rawHeaders, $params, $hasFile, $myApiKey) = $this->_prepareRequest( $method, $url, $params, $headers );

		$requestStartMs = Util\Util::currentTimeMillis();

		list($rbody, $rcode, $rheaders) = $this->streamingHttpClient()->requestStream(
			$method,
			$absUrl,
			$rawHeaders,
			$params,
			$hasFile,
			$readBodyChunkCallable
		);

		if ( isset( $rheaders['request-id'] )
		&& \is_string( $rheaders['request-id'] )
		&& '' !== $rheaders['request-id'] ) {
			self::$requestTelemetry = new RequestTelemetry(
				$rheaders['request-id'],
				Util\Util::currentTimeMillis() - $requestStartMs
			);
		}

		return array( $rbody, $rcode, $rheaders, $myApiKey );
	}

	/**
	 * @param resource $resource
	 *
	 * @throws Exception\InvalidArgumentException
	 *
	 * @return \CURLFile|string
	 */
	private function _processResourceParam( $resource ) {
		if ( 'stream' !== \get_resource_type( $resource ) ) {
			throw new Exception\InvalidArgumentException(
				'Attempted to upload a resource that is not a stream'
			);
		}

		$metaData = \stream_get_meta_data( $resource );
		if ( 'plainfile' !== $metaData['wrapper_type'] ) {
			throw new Exception\InvalidArgumentException(
				'Only plainfile resource streams are supported'
			);
		}

		// We don't have the filename or mimetype, but the API doesn't care
		return new \CURLFile( $metaData['uri'] );
	}

	/**
	 * @param string $rbody
	 * @param int    $rcode
	 * @param array  $rheaders
	 *
	 * @throws Exception\UnexpectedValueException
	 * @throws Exception\ApiErrorException
	 *
	 * @return array
	 */
	private function _interpretResponse( $rbody, $rcode, $rheaders ) {
		$resp      = \json_decode( $rbody, true );
		$jsonError = \json_last_error();
		if ( null === $resp && \JSON_ERROR_NONE !== $jsonError ) {
			$msg = "Invalid response body from API: {$rbody} "
				. "(HTTP response code was {$rcode}, json_last_error() was {$jsonError})";

			throw new Exception\UnexpectedValueException( $msg, $rcode );
		}

		if ( $rcode < 200 || $rcode >= 300 ) {
			$this->handleErrorResponse( $rbody, $rcode, $rheaders, $resp );
		}

		return $resp;
	}

	/**
	 * @static
	 *
	 * @param HttpClient\ClientInterface $client
	 */
	public static function setHttpClient( $client ) {
		self::$_httpClient = $client;
	}

	/**
	 * @static
	 *
	 * @param HttpClient\StreamingClientInterface $client
	 */
	public static function setStreamingHttpClient( $client ) {
		self::$_streamingHttpClient = $client;
	}

	/**
	 * @static
	 *
	 * Resets any stateful telemetry data
	 */
	public static function resetTelemetry() {
		self::$requestTelemetry = null;
	}

	/**
	 * @return HttpClient\ClientInterface
	 */
	private function httpClient() {
		if ( ! self::$_httpClient ) {
			self::$_httpClient = HttpClient\CurlClient::instance();
		}

		return self::$_httpClient;
	}

	/**
	 * @return HttpClient\StreamingClientInterface
	 */
	private function streamingHttpClient() {
		if ( ! self::$_streamingHttpClient ) {
			self::$_streamingHttpClient = HttpClient\CurlClient::instance();
		}

		return self::$_streamingHttpClient;
	}
}
