<?php

namespace Aws\Credentials;

use Aws\Exception\CredentialsException;
use Aws\Exception\InvalidJsonException;
use Aws\Sdk;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Promise;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\ResponseInterface;
use function Aws\default_http_handler;
use function Aws\default_user_agent;

/**
 * Credential provider that provides credentials from the EC2 metadata server.
 */
class InstanceProfileProvider {
	const SERVER_URI = 'http://169.254.169.254/latest/';
	const CRED_PATH = 'meta-data/iam/security-credentials/';

	const ENV_DISABLE = 'AWS_EC2_METADATA_DISABLED';

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

	/** @var callable */
	private $client;

	/** @var int */
	private $retries;

	/** @var int */
	private $attempts;

	/**
	 * The constructor accepts the following options:
	 *
	 * - timeout: Connection timeout, in seconds.
	 * - profile: Optional EC2 profile name, if known.
	 * - retries: Optional number of retries to be attempted.
	 *
	 * @param array $config Configuration options.
	 */
	public function __construct( array $config = [] ) {
		$this->timeout  = isset( $config['timeout'] ) ? $config['timeout'] : 1.0;
		$this->profile  = isset( $config['profile'] ) ? $config['profile'] : null;
		$this->retries  = isset( $config['retries'] ) ? $config['retries'] : 3;
		$this->attempts = 0;
		$this->client   = isset( $config['client'] )
			? $config['client'] // internal use only
			: default_http_handler();
	}

	/**
	 * Loads instance profile credentials.
	 *
	 * @return PromiseInterface
	 */
	public function __invoke() {
		return Promise\coroutine( function () {
			if ( ! $this->profile ) {
				$this->profile = ( yield $this->request( self::CRED_PATH ) );
			}
			$result = null;
			while ( $result == null ) {
				try {
					$json   = ( yield $this->request( self::CRED_PATH . $this->profile ) );
					$result = $this->decodeResult( $json );
				} catch ( InvalidJsonException $e ) {
					if ( $this->attempts < $this->retries ) {
						sleep( pow( 1.2, $this->attempts ) );
					} else {
						throw new CredentialsException(
							'Invalid JSON Response, retries exhausted.'
						);
					}
				} catch ( RequestException $e ) {
					if ( $this->attempts < $this->retries ) {
						sleep( pow( 1.2, $this->attempts ) );
					} else {
						throw new CredentialsException(
							'Networking error, retries exhausted.'
						);
					}
				}
				$this->attempts ++;
			}
			yield new Credentials(
				$result['AccessKeyId'],
				$result['SecretAccessKey'],
				$result['Token'],
				strtotime( $result['Expiration'] )
			);
		} );
	}

	/**
	 * @param string $url
	 *
	 * @return PromiseInterface Returns a promise that is fulfilled with the
	 *                          body of the response as a string.
	 */
	private function request( $url ) {
		$disabled = getenv( self::ENV_DISABLE ) ?: false;
		if ( strcasecmp( $disabled, 'true' ) === 0 ) {
			throw new CredentialsException(
				$this->createErrorMessage( 'EC2 metadata server access disabled' )
			);
		}

		$fn        = $this->client;
		$request   = new Request( 'GET', self::SERVER_URI . $url );
		$userAgent = 'aws-sdk-php/' . Sdk::VERSION;
		if ( defined( 'HHVM_VERSION' ) ) {
			$userAgent .= ' HHVM/' . HHVM_VERSION;
		}
		$userAgent .= ' ' . default_user_agent();
		$request   = $request->withHeader( 'User-Agent', $userAgent );

		return $fn( $request, [ 'timeout' => $this->timeout ] )
			->then( function ( ResponseInterface $response ) {
				return (string) $response->getBody();
			} )->otherwise( function ( array $reason ) {
				$reason = $reason['exception'];
				if ( $reason instanceof RequestException ) {
					throw $reason;
				}
				$msg = $reason->getMessage();
				throw new CredentialsException(
					$this->createErrorMessage( $msg )
				);
			} );
	}

	private function createErrorMessage( $previous ) {
		return "Error retrieving credentials from the instance profile "
		       . "metadata server. ({$previous})";
	}

	private function decodeResult( $response ) {
		$result = json_decode( $response, true );

		if ( json_last_error() > 0 ) {
			throw new InvalidJsonException();
		}

		if ( $result['Code'] !== 'Success' ) {
			throw new CredentialsException( 'Unexpected instance profile '
			                                . 'response code: ' . $result['Code'] );
		}

		return $result;
	}
}
