/*
Copyright 2020 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

/*
Package aksk providers functions for sign http request before request cloud.
*/
package aksk

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/textproto"
	"net/url"
	"regexp"
	"sort"
	"strings"
	"time"
)

// caseInsencitiveStringArray represents string case insensitive sorting operations
type caseInsencitiveStringArray []string

// noEscape specifies whether the character should be encoded or not
var noEscape [256]bool

func init() {
	// refer to https://docs.oracle.com/javase/7/docs/api/java/net/URLEncoder.html
	for i := 0; i < len(noEscape); i++ {
		noEscape[i] = (i >= 'A' && i <= 'Z') ||
			(i >= 'a' && i <= 'z') ||
			(i >= '0' && i <= '9') ||
			i == '.' ||
			i == '-' ||
			i == '_' ||
			i == '~' //java-sdk-core 3.0.1 HttpUtils.urlEncode
	}
}

/*
SignOptions represents the options during signing http request, it is concurrency safely.
Sample code:

	client := &http.Client{
		Timeout: time.Duration(3 * time.Second),
	}
	req, err := http.NewRequest("POST", "https://30030113-3657-4fb6-a7ef-90764239b038.apigw.xxx.yyy.com/app1?name=value",bytes.NewBuffer([]byte("demo")))

	signOptions := signer.SignOptions{
		AccessKey: "------------",
		SecretKey: "------------",
	}

	signer.Sign(req, signOptions)
	resp, err := client.Do(req)
*/
type SignOptions struct {
	AccessKey           string //Access Key
	SecretKey           string //Secret key
	RegionName          string // Region name
	ServiceName         string // Service Name
	EnableCacheSignKey  bool   // Cache sign key for one day or not cache, cache is disabled by default
	encodeUrl           bool   //internal use
	SignAlgorithm       string //The algorithm used for sign, the default value is "SDK-HMAC-SHA256" if you don't set its value
	TimeOffsetInseconds int64  // TimeOffsetInseconds is used for adjust x-sdk-date if set its value
}

// StringBuilder wraps bytes.Buffer to implement a high performance string builder.
type StringBuilder struct {
	builder bytes.Buffer //string storage
}

// reqSignParams represents the option values used for signing http request.
type reqSignParams struct {
	SignOptions
	RequestTime time.Time
	Req         *http.Request
}

// signKeyCacheEntry represents the cache entry of sign key.
type signKeyCacheEntry struct {
	Key                    []byte // sign key
	NumberOfDaysSinceEpoch int64  // number of days since epoch
}

// SignAlgorithmHMACSHA256 The default sign algorithm
const SignAlgorithmHMACSHA256 = "SDK-HMAC-SHA256"

// ContentSha256HeaderKey The header key of content hash value
const ContentSha256HeaderKey = "x-sdk-content-sha256"

// A regular for searching empty string
var spaceRegexp = regexp.MustCompile(`\s+`)

// cache sign key
var cache = NewCache(300)

// Sign manipulates the http.Request instance with some required authentication headers for SK/SK auth.
func Sign(req *http.Request, signOptions SignOptions) {
	signOptions.AccessKey = strings.TrimSpace(signOptions.AccessKey)
	signOptions.SecretKey = strings.TrimSpace(signOptions.SecretKey)
	signOptions.encodeUrl = true

	signParams := reqSignParams{
		SignOptions: signOptions,
		RequestTime: time.Now(),
		Req:         req,
	}

	//t, _ := time.Parse(time.RFC3339, "2018-04-15T04:28:22+00:00")
	//signParams.RequestTime = t

	if signParams.SignAlgorithm == "" {
		signParams.SignAlgorithm = SignAlgorithmHMACSHA256
	}

	addRequiredHeaders(req, signParams.getFormattedSigningDateTime())
	contentSha256 := ""

	if v, ok := req.Header[textproto.CanonicalMIMEHeaderKey(ContentSha256HeaderKey)]; !ok {
		contentSha256 = calculateContentHash(req)
	} else {
		contentSha256 = v[0]
	}

	canonicalRequest := createCanonicalRequest(signParams, contentSha256)

	/*fmt.Println("canonicalRequest: " + canonicalRequest)
	fmt.Println("*****")*/

	strToSign := createStringToSign(canonicalRequest, signParams)
	signKey := deriveSigningKey(signParams)
	signature := computeSignature(strToSign, signKey, signParams.SignAlgorithm)

	req.Header.Set("Authorization", buildAuthorizationHeader(signParams, signature))
}

// deriveSigningKey returns a sign key from cache, or build it and insert it into cache
func deriveSigningKey(signParam reqSignParams) []byte {
	if signParam.EnableCacheSignKey {
		cacheKey := strings.Join([]string{signParam.SecretKey,
			signParam.RegionName,
			signParam.ServiceName,
		}, "-")

		cacheData := cache.Get(cacheKey)

		if cacheData != "" {
			var signKey signKeyCacheEntry
			json.Unmarshal([]byte(cacheData), &signKey)

			if signKey.NumberOfDaysSinceEpoch == signParam.getDaysSinceEpon() {
				return signKey.Key
			}
		}

		signKey := buildSignKey(signParam)
		signKeyStr, _ := json.Marshal(signKeyCacheEntry{
			Key:                    signKey,
			NumberOfDaysSinceEpoch: signParam.getDaysSinceEpon(),
		})
		cache.Add(cacheKey, string(signKeyStr))
		return signKey
	}

	return buildSignKey(signParam)
}

func buildSignKey(signParam reqSignParams) []byte {
	var kSecret StringBuilder
	kSecret.Write("SDK").Write(signParam.SecretKey)

	kDate := computeSignature(signParam.getFormattedSigningDate(), kSecret.GetBytes(), signParam.SignAlgorithm)
	kRegion := computeSignature(signParam.RegionName, kDate, signParam.SignAlgorithm)
	kService := computeSignature(signParam.ServiceName, kRegion, signParam.SignAlgorithm)
	return computeSignature("sdk_request", kService, signParam.SignAlgorithm)
}

// HmacSha256 implements the  Keyed-Hash Message Authentication Code computation.
func HmacSha256(data string, key []byte) []byte {
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(data))
	return mac.Sum(nil)
}

// HashSha256 is a wrapper for sha256 implementation.
func HashSha256(msg []byte) []byte {
	sh256 := sha256.New()
	sh256.Write(msg)

	return sh256.Sum(nil)
}

// buildAuthorizationHeader builds the authentication header value
func buildAuthorizationHeader(signParam reqSignParams, signature []byte) string {
	var signingCredentials StringBuilder
	signingCredentials.Write(signParam.AccessKey).Write("/").Write(signParam.getScope())

	credential := "Credential=" + signingCredentials.ToString()
	signerHeaders := "SignedHeaders=" + getSignedHeadersString(signParam.Req)
	signatureHeader := "Signature=" + hex.EncodeToString(signature)

	return signParam.SignAlgorithm + " " + strings.Join([]string{
		credential,
		signerHeaders,
		signatureHeader,
	}, ", ")
}

// computeSignature computers the signature with the specified algorithm
// and it only supports SDK-HMAC-SHA256 in currently
func computeSignature(signData string, key []byte, algorithm string) []byte {
	if algorithm == SignAlgorithmHMACSHA256 {
		return HmacSha256(signData, key)
	}
	log.Fatalf("Unsupported algorithm %s, please use %s and try again", algorithm, SignAlgorithmHMACSHA256)
	return nil
}

// createStringToSign build the need to be signed string
func createStringToSign(canonicalRequest string, signParams reqSignParams) string {
	return strings.Join([]string{signParams.SignAlgorithm,
		signParams.getFormattedSigningDateTime(),
		signParams.getScope(),
		hex.EncodeToString(HashSha256([]byte(canonicalRequest))),
	}, "\n")
}

// getCanonicalizedResourcePath builds the valid url path for signing
func getCanonicalizedResourcePath(signParas reqSignParams) string {
	urlStr := signParas.Req.URL.Path
	if !strings.HasPrefix(urlStr, "/") {
		urlStr = "/" + urlStr
	}

	if !strings.HasSuffix(urlStr, "/") {
		urlStr = urlStr + "/"
	}

	if signParas.encodeUrl {
		urlStr = urlEncode(urlStr, true)
	}

	if urlStr == "" {
		urlStr = "/"
	}

	return urlStr
}

// urlEncode encodes url path and url querystring according to the following rules:
// The alphanumeric characters "a" through "z", "A" through "Z" and "0" through "9" remain the same.
//The special characters ".", "-", "*", and "_" remain the same.
//The space character " " is converted into a plus sign "%20".
//All other characters are unsafe and are first converted into one or more bytes using some encoding scheme.
func urlEncode(url string, urlPath bool) string {
	var buf bytes.Buffer
	for i := 0; i < len(url); i++ {
		c := url[i]
		if noEscape[c] || (c == '/' && urlPath) {
			buf.WriteByte(c)
		} else {
			fmt.Fprintf(&buf, "%%%02X", c)
		}
	}

	return buf.String()
}

// encodeQueryString build and encode querystring to a string for signing
func encodeQueryString(queryValues url.Values) string {
	var encodedVals = make(map[string]string, len(queryValues))
	var keys = make([]string, len(queryValues))

	i := 0

	for k := range queryValues {
		keys[i] = urlEncode(k, false)
		encodedVals[keys[i]] = k
		i++
	}

	caseInsensitiveSort(keys)

	var queryStr StringBuilder
	for i, k := range keys {
		if i > 0 {
			queryStr.Write("&")
		}

		queryStr.Write(k).Write("=").Write(urlEncode(queryValues.Get(encodedVals[k]), false))
	}

	return queryStr.ToString()
}

// getCanonicalizedQueryString return empty string if in POST method and content is nil, otherwise returns sorted,encoded querystring
func getCanonicalizedQueryString(signParas reqSignParams) string {
	if usePayloadForQueryParameters(signParas.Req) {
		return ""
	}
	return encodeQueryString(signParas.Req.URL.Query())
}

// createCanonicalRequest builds canonical string depends the official document  for signing
func createCanonicalRequest(signParas reqSignParams, contentSha256 string) string {
	return strings.Join([]string{signParas.Req.Method,
		getCanonicalizedResourcePath(signParas),
		getCanonicalizedQueryString(signParas),
		getCanonicalizedHeaderString(signParas.Req),
		getSignedHeadersString(signParas.Req),
		contentSha256,
	}, "\n")
}

// calculateContentHash computes the content hash value
func calculateContentHash(req *http.Request) string {
	encodeParas := ""

	//post and content is null use queryString as content -- according to document
	if usePayloadForQueryParameters(req) {
		encodeParas = req.URL.Query().Encode()
	} else {
		if req.Body == nil {
			encodeParas = ""
		} else {
			readBody, _ := ioutil.ReadAll(req.Body)
			req.Body = ioutil.NopCloser(bytes.NewBuffer(readBody))
			encodeParas = string(readBody)
		}
	}

	return hex.EncodeToString(HashSha256([]byte(encodeParas)))
}

// usePayloadForQueryParameters specifies use querystring or not as content for compute content hash
func usePayloadForQueryParameters(req *http.Request) bool {
	if strings.ToLower(req.Method) != "post" {
		return false
	}

	return req.Body == nil
}

// getCanonicalizedHeaderString converts header map to a string for signing
func getCanonicalizedHeaderString(req *http.Request) string {
	var headers StringBuilder

	keys := make([]string, 0)
	for k := range req.Header {
		keys = append(keys, strings.TrimSpace(k))
	}

	caseInsensitiveSort(keys)

	for _, k := range keys {
		k = strings.ToLower(k)
		newKey := spaceRegexp.ReplaceAllString(k, " ")
		headers.Write(newKey)
		headers.Write(":")

		val := req.Header.Get(k)
		val = spaceRegexp.ReplaceAllString(val, " ")
		headers.Write(val)

		headers.Write("\n")
	}

	return headers.ToString()
}

// getSignedHeadersString builds the string for AuthorizationHeader and signing
func getSignedHeadersString(req *http.Request) string {
	var headers StringBuilder

	keys := make([]string, 0)
	for k := range req.Header {
		keys = append(keys, strings.TrimSpace(k))
	}

	caseInsensitiveSort(keys)

	for idx, k := range keys {

		if idx > 0 {
			headers.Write(";")
		}

		headers.Write(strings.ToLower(k))
	}

	return headers.ToString()
}

// addRequiredHeaders adds the required heads to http.request instance
func addRequiredHeaders(req *http.Request, timeStr string) {
	// golang handls port by default
	req.Header.Add("Host", req.URL.Host)
	req.Header.Add("X-Sdk-Date", timeStr)
}

func (s caseInsencitiveStringArray) Len() int {
	return len(s)
}
func (s caseInsencitiveStringArray) Swap(i, j int) {
	s[i], s[j] = s[j], s[i]
}
func (s caseInsencitiveStringArray) Less(i, j int) bool {
	return strings.ToLower(s[i]) < strings.ToLower(s[j])
}

func caseInsensitiveSort(strSlice []string) {
	sort.Sort(caseInsencitiveStringArray(strSlice))
}

func (signParas *reqSignParams) getSigningDateTimeMilli() int64 {
	return (signParas.RequestTime.UTC().Unix() - signParas.TimeOffsetInseconds) * 1000
}

func (signParas *reqSignParams) getSigningDateTime() time.Time {
	return time.Unix(signParas.getSigningDateTimeMilli()/1000, 0)
}

func (signParas *reqSignParams) getDaysSinceEpon() int64 {
	return signParas.getSigningDateTimeMilli() / 1000 / 3600 / 24
}

func (signParas *reqSignParams) getFormattedSigningDate() string {
	return signParas.getSigningDateTime().UTC().Format("20060102")
}
func (signParas *reqSignParams) getFormattedSigningDateTime() string {
	return signParas.getSigningDateTime().UTC().Format("20060102T150405Z")
}

func (signParas *reqSignParams) getScope() string {
	return strings.Join([]string{signParas.getFormattedSigningDate(),
		signParas.RegionName,
		signParas.ServiceName,
		"sdk_request",
	}, "/")
}

// Write ...
func (buff *StringBuilder) Write(s string) *StringBuilder {
	buff.builder.WriteString(s)
	return buff
}

// ToString ...
func (buff *StringBuilder) ToString() string {
	return buff.builder.String()
}

// GetBytes ...
func (buff *StringBuilder) GetBytes() []byte {
	return []byte(buff.ToString())
}
