<?php

namespace AsyncAws\Core\Signer;

use AsyncAws\Core\Credentials\Credentials;
use AsyncAws\Core\Exception\InvalidArgument;
use AsyncAws\Core\Request;
use AsyncAws\Core\RequestContext;
use AsyncAws\Core\Stream\ReadOnceResultStream;
use AsyncAws\Core\Stream\RewindableStream;
use AsyncAws\Core\Stream\StringStream;

/**
 * Version4 of signer.
 *
 * @author Jérémy Derussé <jeremy@derusse.com>
 */
class SignerV4 implements Signer
{
    private const ALGORITHM_REQUEST = 'AWS4-HMAC-SHA256';

    private const BLACKLIST_HEADERS = [
        '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,
    ];

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

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

    public function __construct(string $scopeName, string $region)
    {
        $this->scopeName = $scopeName;
        $this->region = $region;
    }

    public function presign(Request $request, Credentials $credentials, RequestContext $context): void
    {
        $now = $context->getCurrentDate() ?? new \DateTimeImmutable();

        // Signer date have to be UTC https://docs.aws.amazon.com/general/latest/gr/sigv4-date-handling.html
        $now = $now->setTimezone(new \DateTimeZone('UTC'));
        $expires = $context->getExpirationDate() ?? $now->add(new \DateInterval('PT1H'));

        $this->handleSignature($request, $credentials, $now, $expires, true);
    }

    public function sign(Request $request, Credentials $credentials, RequestContext $context): void
    {
        $now = $context->getCurrentDate() ?? new \DateTimeImmutable();

        // Signer date have to be UTC https://docs.aws.amazon.com/general/latest/gr/sigv4-date-handling.html
        $now = $now->setTimezone(new \DateTimeZone('UTC'));

        $this->handleSignature($request, $credentials, $now, $now, false);
    }

    protected function buildBodyDigest(Request $request, bool $isPresign): string
    {
        if ($request->hasHeader('x-amz-content-sha256')) {
            /** @var string $hash */
            $hash = $request->getHeader('x-amz-content-sha256');
        } else {
            $body = $request->getBody();
            if ($body instanceof ReadOnceResultStream) {
                $request->setBody($body = RewindableStream::create($body));
            }

            $hash = $request->getBody()->hash();
        }

        if ('UNSIGNED-PAYLOAD' === $hash) {
            $request->setHeader('x-amz-content-sha256', $hash);
        }

        return $hash;
    }

    protected function convertBodyToStream(SigningContext $context): void
    {
        $request = $context->getRequest();
        $request->setBody(StringStream::create($request->getBody()));
    }

    protected function buildCanonicalPath(Request $request): string
    {
        $doubleEncoded = rawurlencode(ltrim($request->getUri(), '/'));

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

    private function handleSignature(Request $request, Credentials $credentials, \DateTimeImmutable $now, \DateTimeImmutable $expires, bool $isPresign): void
    {
        $this->removePresign($request);
        $this->sanitizeHostForHeader($request);
        $this->assignAmzQueryValues($request, $credentials, $isPresign);

        $this->buildTime($request, $now, $expires, $isPresign);
        $credentialScope = $this->buildCredentialString($request, $credentials, $now, $isPresign);
        $context = new SigningContext(
            $request,
            $now,
            implode('/', $credentialScope),
            $this->buildSigningKey($credentials, $credentialScope)
        );
        if ($isPresign) {
            // Should be called before `buildBodyDigest` because this method may alter the body
            $this->convertBodyToQuery($request);
        } else {
            $this->convertBodyToStream($context);
        }

        $bodyDigest = $this->buildBodyDigest($request, $isPresign);

        if ($isPresign) {
            // Should be called after `buildBodyDigest` because this method may remove the header `x-amz-content-sha256`
            $this->convertHeaderToQuery($request);
        }

        $canonicalHeaders = $this->buildCanonicalHeaders($request, $isPresign);
        $canonicalRequest = $this->buildCanonicalRequest($request, $canonicalHeaders, $bodyDigest);
        $stringToSign = $this->buildStringToSign($context->getNow(), $context->getCredentialString(), $canonicalRequest);
        $context->setSignature($signature = $this->buildSignature($stringToSign, $context->getSigningKey()));

        if ($isPresign) {
            $request->setQueryAttribute('X-Amz-Signature', $signature);
        } else {
            $request->setHeader('authorization', \sprintf(
                '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
                self::ALGORITHM_REQUEST,
                $credentials->getAccessKeyId(),
                implode('/', $credentialScope),
                implode(';', array_keys($canonicalHeaders)),
                $signature
            ));
        }
    }

    private function removePresign(Request $request): void
    {
        $request->removeQueryAttribute('X-Amz-Algorithm');
        $request->removeQueryAttribute('X-Amz-Signature');
        $request->removeQueryAttribute('X-Amz-Security-Token');
        $request->removeQueryAttribute('X-Amz-Date');
        $request->removeQueryAttribute('X-Amz-Expires');
        $request->removeQueryAttribute('X-Amz-Credential');
        $request->removeQueryAttribute('X-Amz-SignedHeaders');
    }

    private function sanitizeHostForHeader(Request $request): void
    {
        if (false === $parsedUrl = parse_url($request->getEndpoint())) {
            throw new InvalidArgument(\sprintf('The endpoint "%s" is invalid.', $request->getEndpoint()));
        }

        if (!isset($parsedUrl['host'])) {
            return;
        }

        $host = $parsedUrl['host'];
        if (isset($parsedUrl['port'])) {
            $host .= ':' . $parsedUrl['port'];
        }

        $request->setHeader('host', $host);
    }

    private function assignAmzQueryValues(Request $request, Credentials $credentials, bool $isPresign): void
    {
        if ($isPresign) {
            $request->setQueryAttribute('X-Amz-Algorithm', self::ALGORITHM_REQUEST);
            if (null !== $sessionToken = $credentials->getSessionToken()) {
                $request->setQueryAttribute('X-Amz-Security-Token', $sessionToken);
            }

            return;
        }

        if (null !== $sessionToken = $credentials->getSessionToken()) {
            $request->setHeader('x-amz-security-token', $sessionToken);
        }
    }

    private function buildTime(Request $request, \DateTimeImmutable $now, \DateTimeImmutable $expires, bool $isPresign): void
    {
        if ($isPresign) {
            $duration = $expires->getTimestamp() - $now->getTimestamp();
            if ($duration > 604800) {
                throw new InvalidArgument('The expiration date of presigned URL must be less than one week');
            }
            if ($duration < 0) {
                throw new InvalidArgument('The expiration date of presigned URL must be in the future');
            }

            $request->setQueryAttribute('X-Amz-Date', $now->format('Ymd\THis\Z'));
            $request->setQueryAttribute('X-Amz-Expires', (string) $duration);
        } else {
            $request->setHeader('X-Amz-Date', $now->format('Ymd\THis\Z'));
        }
    }

    /**
     * @return string[]
     */
    private function buildCredentialString(Request $request, Credentials $credentials, \DateTimeImmutable $now, bool $isPresign): array
    {
        $credentialScope = [$now->format('Ymd'), $this->region, $this->scopeName, 'aws4_request'];

        if ($isPresign) {
            $request->setQueryAttribute('X-Amz-Credential', $credentials->getAccessKeyId() . '/' . implode('/', $credentialScope));
        }

        return $credentialScope;
    }

    private function convertHeaderToQuery(Request $request): void
    {
        foreach ($request->getHeaders() as $name => $value) {
            if ('x-amz' === substr($name, 0, 5)) {
                $request->setQueryAttribute($name, $value);
            }

            if (isset(self::BLACKLIST_HEADERS[$name])) {
                $request->removeHeader($name);
            }
        }
        $request->removeHeader('x-amz-content-sha256');
    }

    private function convertBodyToQuery(Request $request): void
    {
        if ('POST' !== $request->getMethod()) {
            return;
        }

        $request->setMethod('GET');
        if ('application/x-www-form-urlencoded' === $request->getHeader('Content-Type')) {
            parse_str($request->getBody()->stringify(), $params);
            foreach ($params as $name => $value) {
                $request->setQueryAttribute($name, $value);
            }
        }

        $request->removeHeader('content-type');
        $request->removeHeader('content-length');
        $request->setBody(StringStream::create(''));
    }

    /**
     * @return array<string, string>
     */
    private function buildCanonicalHeaders(Request $request, bool $isPresign): array
    {
        // Case-insensitively aggregate all of the headers.
        $canonicalHeaders = [];
        foreach ($request->getHeaders() as $key => $value) {
            $key = strtolower($key);
            if (isset(self::BLACKLIST_HEADERS[$key])) {
                continue;
            }

            $canonicalHeaders[$key] = $key . ':' . preg_replace('/\s+/', ' ', $value);
        }
        ksort($canonicalHeaders);

        if ($isPresign) {
            $request->setQueryAttribute('X-Amz-SignedHeaders', implode(';', array_keys($canonicalHeaders)));
        }

        return $canonicalHeaders;
    }

    /**
     * @param array<string, string> $canonicalHeaders
     */
    private function buildCanonicalRequest(Request $request, array $canonicalHeaders, string $bodyDigest): string
    {
        return implode("\n", [
            $request->getMethod(),
            $this->buildCanonicalPath($request),
            $this->buildCanonicalQuery($request),
            implode("\n", array_values($canonicalHeaders)),
            '', // empty line after headers
            implode(';', array_keys($canonicalHeaders)),
            $bodyDigest,
        ]);
    }

    private function buildCanonicalQuery(Request $request): string
    {
        $query = $request->getQuery();

        unset($query['X-Amz-Signature']);
        if (empty($query)) {
            return '';
        }

        uksort($query, static function (string $a, string $b): int {
            return strcmp(rawurlencode($a), rawurlencode($b));
        });
        $encodedQuery = [];
        foreach ($query as $key => $values) {
            if (!\is_array($values)) {
                $encodedQuery[] = rawurlencode($key) . '=' . rawurlencode($values);

                continue;
            }

            // @phpstan-ignore argument.unresolvableType
            sort($values);
            foreach ($values as $value) {
                $encodedQuery[] = rawurlencode($key) . '=' . rawurlencode($value);
            }
        }

        return implode('&', $encodedQuery);
    }

    private function buildStringToSign(\DateTimeImmutable $now, string $credentialString, string $canonicalRequest): string
    {
        return implode("\n", [
            self::ALGORITHM_REQUEST,
            $now->format('Ymd\THis\Z'),
            $credentialString,
            hash('sha256', $canonicalRequest),
        ]);
    }

    /**
     * @param string[] $credentialScope
     */
    private function buildSigningKey(Credentials $credentials, array $credentialScope): string
    {
        $signingKey = 'AWS4' . $credentials->getSecretKey();
        foreach ($credentialScope as $scopePart) {
            $signingKey = hash_hmac('sha256', $scopePart, $signingKey, true);
        }

        return $signingKey;
    }

    private function buildSignature(string $stringToSign, string $signingKey): string
    {
        return hash_hmac('sha256', $stringToSign, $signingKey);
    }
}
