<?php

namespace Aws\Signature;

use Aws\Credentials\CredentialsInterface;
use Aws\Exception\CouldNotCreateChecksumException;
use DateTimeInterface;
use Exception;
use GuzzleHttp\Psr7;
use InvalidArgumentException;
use Psr\Http\Message\RequestInterface;

/**
 * Signature Version 4
 * @link http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
 */
class SignatureV4 implements SignatureInterface {
	use SignatureTrait;
	const ISO8601_BASIC = 'Ymd\THis\Z';
	const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
	const AMZ_CONTENT_SHA256_HEADER = 'X-Amz-Content-Sha256';

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

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

	/** @var bool */
	private $unsigned;

	/**
	 * @param string $service Service name to use when signing
	 * @param string $region Region name to use when signing
	 * @param array $options Array of configuration options used when signing
	 *      - unsigned-body: Flag to make request have unsigned payload.
	 *        Unsigned body is used primarily for streaming requests.
	 */
	public function __construct( $service, $region, array $options = [] ) {
		$this->service  = $service;
		$this->region   = $region;
		$this->unsigned = isset( $options['unsigned-body'] ) ? $options['unsigned-body'] : false;
	}

	/**
	 * Converts a POST request to a GET request by moving POST fields into the
	 * query string.
	 *
	 * Useful for pre-signing query protocol requests.
	 *
	 * @param RequestInterface $request Request to clone
	 *
	 * @return RequestInterface
	 * @throws InvalidArgumentException if the method is not POST
	 */
	public static function convertPostToGet( RequestInterface $request ) {
		if ( $request->getMethod() !== 'POST' ) {
			throw new InvalidArgumentException( 'Expected a POST request but '
			                                     . 'received a ' . $request->getMethod() . ' request.' );
		}

		$sr = $request->withMethod( 'GET' )
		              ->withBody( Psr7\stream_for( '' ) )
		              ->withoutHeader( 'Content-Type' )
		              ->withoutHeader( 'Content-Length' );

		// Move POST fields to the query if they are present
		if ( $request->getHeaderLine( 'Content-Type' ) === 'application/x-www-form-urlencoded' ) {
			$body = (string) $request->getBody();
			$sr   = $sr->withUri( $sr->getUri()->withQuery( $body ) );
		}

		return $sr;
	}

	public function signRequest(
		RequestInterface $request,
		CredentialsInterface $credentials
	) {
		$ldt                             = gmdate( self::ISO8601_BASIC );
		$sdt                             = substr( $ldt, 0, 8 );
		$parsed                          = $this->parseRequest( $request );
		$parsed['headers']['X-Amz-Date'] = [ $ldt ];

		if ( $token = $credentials->getSecurityToken() ) {
			$parsed['headers']['X-Amz-Security-Token'] = [ $token ];
		}
		$cs      = $this->createScope( $sdt, $this->region, $this->service );
		$payload = $this->getPayload( $request );

		if ( $payload == self::UNSIGNED_PAYLOAD ) {
			$parsed['headers'][ self::AMZ_CONTENT_SHA256_HEADER ] = [ $payload ];
		}

		$context                            = $this->createContext( $parsed, $payload );
		$toSign                             = $this->createStringToSign( $ldt, $cs, $context['creq'] );
		$signingKey                         = $this->getSigningKey(
			$sdt,
			$this->region,
			$this->service,
			$credentials->getSecretKey()
		);
		$signature                          = hash_hmac( 'sha256', $toSign, $signingKey );
		$parsed['headers']['Authorization'] = [
			"AWS4-HMAC-SHA256 "
			. "Credential={$credentials->getAccessKeyId()}/{$cs}, "
			. "SignedHeaders={$context['headers']}, Signature={$signature}"
		];

		return $this->buildRequest( $parsed );
	}

	private function parseRequest( RequestInterface $request ) {
		// Clean up any previously set headers.
		/** @var RequestInterface $request */
		$request = $request
			->withoutHeader( 'X-Amz-Date' )
			->withoutHeader( 'Date' )
			->withoutHeader( 'Authorization' );
		$uri     = $request->getUri();

		return [
			'method'  => $request->getMethod(),
			'path'    => $uri->getPath(),
			'query'   => Psr7\parse_query( $uri->getQuery() ),
			'uri'     => $uri,
			'headers' => $request->getHeaders(),
			'body'    => $request->getBody(),
			'version' => $request->getProtocolVersion()
		];
	}

	protected function getPayload( RequestInterface $request ) {
		if ( $this->unsigned && $request->getUri()->getScheme() == 'https' ) {
			return self::UNSIGNED_PAYLOAD;
		}
		// Calculate the request signature payload
		if ( $request->hasHeader( self::AMZ_CONTENT_SHA256_HEADER ) ) {
			// Handle streaming operations (e.g. Glacier.UploadArchive)
			return $request->getHeaderLine( self::AMZ_CONTENT_SHA256_HEADER );
		}

		if ( ! $request->getBody()->isSeekable() ) {
			throw new CouldNotCreateChecksumException( 'sha256' );
		}

		try {
			return Psr7\hash( $request->getBody(), 'sha256' );
		} catch ( Exception $e ) {
			throw new CouldNotCreateChecksumException( 'sha256', $e );
		}
	}

	/**
	 * @param array $parsedRequest
	 * @param string $payload Hash of the request payload
	 *
	 * @return array Returns an array of context information
	 */
	private function createContext( array $parsedRequest, $payload ) {
		$blacklist = $this->getHeaderBlacklist();

		// Normalize the path as required by SigV4
		$canon = $parsedRequest['method'] . "\n"
		         . $this->createCanonicalizedPath( $parsedRequest['path'] ) . "\n"
		         . $this->getCanonicalizedQuery( $parsedRequest['query'] ) . "\n";

		// Case-insensitively aggregate all of the headers.
		$aggregate = [];
		foreach ( $parsedRequest['headers'] as $key => $values ) {
			$key = strtolower( $key );
			if ( ! isset( $blacklist[ $key ] ) ) {
				foreach ( $values as $v ) {
					$aggregate[ $key ][] = $v;
				}
			}
		}

		ksort( $aggregate );
		$canonHeaders = [];
		foreach ( $aggregate as $k => $v ) {
			if ( count( $v ) > 0 ) {
				sort( $v );
			}
			$canonHeaders[] = $k . ':' . preg_replace( '/\s+/', ' ', implode( ',', $v ) );
		}

		$signedHeadersString = implode( ';', array_keys( $aggregate ) );
		$canon               .= implode( "\n", $canonHeaders ) . "\n\n"
		                        . $signedHeadersString . "\n"
		                        . $payload;

		return [ 'creq' => $canon, 'headers' => $signedHeadersString ];
	}

	/**
	 * The following headers are not signed because signing these headers
	 * would potentially cause a signature mismatch when sending a request
	 * through a proxy or if modified at the HTTP client level.
	 *
	 * @return array
	 */
	private function getHeaderBlacklist() {
		return [
			'cache-control'         => true,
			'content-type'          => true,
			'content-length'        => true,
			'expect'                => true,
			'max-forwards'          => true,
			'pragma'                => true,
			'range'                 => true,
			'te'                    => true,
			'if-match'              => true,
			'if-none-match'         => true,
			'if-modified-since'     => true,
			'if-unmodified-since'   => true,
			'if-range'              => true,
			'accept'                => true,
			'authorization'         => true,
			'proxy-authorization'   => true,
			'from'                  => true,
			'referer'               => true,
			'user-agent'            => true,
			'x-amzn-trace-id'       => true,
			'aws-sdk-invocation-id' => true,
			'aws-sdk-retry'         => true,
		];
	}

	protected function createCanonicalizedPath( $path ) {
		$doubleEncoded = rawurlencode( ltrim( $path, '/' ) );

		return '/' . str_replace( '%2F', '/', $doubleEncoded );
	}

	private function getCanonicalizedQuery( array $query ) {
		unset( $query['X-Amz-Signature'] );

		if ( ! $query ) {
			return '';
		}

		$qs = '';
		ksort( $query );
		foreach ( $query as $k => $v ) {
			if ( ! is_array( $v ) ) {
				$qs .= rawurlencode( $k ) . '=' . rawurlencode( $v ) . '&';
			} else {
				sort( $v );
				foreach ( $v as $value ) {
					$qs .= rawurlencode( $k ) . '=' . rawurlencode( $value ) . '&';
				}
			}
		}

		return substr( $qs, 0, - 1 );
	}

	private function createStringToSign( $longDate, $credentialScope, $creq ) {
		$hash = hash( 'sha256', $creq );

		return "AWS4-HMAC-SHA256\n{$longDate}\n{$credentialScope}\n{$hash}";
	}

	private function buildRequest( array $req ) {
		if ( $req['query'] ) {
			$req['uri'] = $req['uri']->withQuery( Psr7\build_query( $req['query'] ) );
		}

		return new Psr7\Request(
			$req['method'],
			$req['uri'],
			$req['headers'],
			$req['body'],
			$req['version']
		);
	}

	public function presign(
		RequestInterface $request,
		CredentialsInterface $credentials,
		$expires,
		array $options = []
	) {

		$startTimestamp = isset( $options['start_time'] )
			? $this->convertToTimestamp( $options['start_time'], null )
			: time();

		$expiresTimestamp = $this->convertToTimestamp( $expires, $startTimestamp );

		$parsed     = $this->createPresignedRequest( $request, $credentials );
		$payload    = $this->getPresignedPayload( $request );
		$httpDate   = gmdate( self::ISO8601_BASIC, $startTimestamp );
		$shortDate  = substr( $httpDate, 0, 8 );
		$scope      = $this->createScope( $shortDate, $this->region, $this->service );
		$credential = $credentials->getAccessKeyId() . '/' . $scope;
		if ( $credentials->getSecurityToken() ) {
			unset( $parsed['headers']['X-Amz-Security-Token'] );
		}
		$parsed['query']['X-Amz-Algorithm']     = 'AWS4-HMAC-SHA256';
		$parsed['query']['X-Amz-Credential']    = $credential;
		$parsed['query']['X-Amz-Date']          = gmdate( 'Ymd\THis\Z', $startTimestamp );
		$parsed['query']['X-Amz-SignedHeaders'] = implode( ';', $this->getPresignHeaders( $parsed['headers'] ) );
		$parsed['query']['X-Amz-Expires']       = $this->convertExpires( $expiresTimestamp, $startTimestamp );
		$context                                = $this->createContext( $parsed, $payload );
		$stringToSign                           = $this->createStringToSign( $httpDate, $scope, $context['creq'] );
		$key                                    = $this->getSigningKey(
			$shortDate,
			$this->region,
			$this->service,
			$credentials->getSecretKey()
		);
		$parsed['query']['X-Amz-Signature']     = hash_hmac( 'sha256', $stringToSign, $key );

		return $this->buildRequest( $parsed );
	}

	private function convertToTimestamp( $dateValue, $relativeTimeBase = null ) {
		if ( $dateValue instanceof DateTimeInterface ) {
			$timestamp = $dateValue->getTimestamp();
		} elseif ( ! is_numeric( $dateValue ) ) {
			$timestamp = strtotime( $dateValue,
				$relativeTimeBase === null ? time() : $relativeTimeBase
			);
		} else {
			$timestamp = $dateValue;
		}

		return $timestamp;
	}

	private function createPresignedRequest(
		RequestInterface $request,
		CredentialsInterface $credentials
	) {
		$parsedRequest = $this->parseRequest( $request );

		// Make sure to handle temporary credentials
		if ( $token = $credentials->getSecurityToken() ) {
			$parsedRequest['headers']['X-Amz-Security-Token'] = [ $token ];
		}

		return $this->moveHeadersToQuery( $parsedRequest );
	}

	private function moveHeadersToQuery( array $parsedRequest ) {
		foreach ( $parsedRequest['headers'] as $name => $header ) {
			$lname = strtolower( $name );
			if ( substr( $lname, 0, 5 ) == 'x-amz' ) {
				$parsedRequest['query'][ $name ] = $header;
			}
			$blacklist = $this->getHeaderBlacklist();
			if ( isset( $blacklist[ $lname ] )
			     || $lname === strtolower( self::AMZ_CONTENT_SHA256_HEADER )
			) {
				unset( $parsedRequest['headers'][ $name ] );
			}
		}

		return $parsedRequest;
	}

	protected function getPresignedPayload( RequestInterface $request ) {
		return $this->getPayload( $request );
	}

	/**
	 * Get the headers that were used to pre-sign the request.
	 * Used for the X-Amz-SignedHeaders header.
	 *
	 * @param array $headers
	 *
	 * @return array
	 */
	private function getPresignHeaders( array $headers ) {
		$presignHeaders = [];
		$blacklist      = $this->getHeaderBlacklist();
		foreach ( $headers as $name => $value ) {
			$lName = strtolower( $name );
			if ( ! isset( $blacklist[ $lName ] )
			     && $name !== self::AMZ_CONTENT_SHA256_HEADER
			) {
				$presignHeaders[] = $lName;
			}
		}

		return $presignHeaders;
	}

	private function convertExpires( $expiresTimestamp, $startTimestamp ) {
		$duration = $expiresTimestamp - $startTimestamp;

		// Ensure that the duration of the signature is not longer than a week
		if ( $duration > 604800 ) {
			throw new InvalidArgumentException( 'The expiration date of a '
			                                     . 'signature version 4 presigned URL must be less than one '
			                                     . 'week' );
		}

		return $duration;
	}
}
