// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package sas

import (
	"bytes"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/internal/exported"
)

// BlobSignatureValues is used to generate a Shared Access Signature (SAS) for an Azure Storage container or blob.
// For more information on creating service sas, see https://docs.microsoft.com/rest/api/storageservices/constructing-a-service-sas
// For more information on creating user delegation sas, see https://docs.microsoft.com/rest/api/storageservices/create-user-delegation-sas
type BlobSignatureValues struct {
	Version                      string    `param:"sv"`  // If not specified, this defaults to Version
	Protocol                     Protocol  `param:"spr"` // See the Protocol* constants
	StartTime                    time.Time `param:"st"`  // Not specified if IsZero
	ExpiryTime                   time.Time `param:"se"`  // Not specified if IsZero
	SnapshotTime                 time.Time
	Permissions                  string  `param:"sp"` // Create by initializing ContainerPermissions or BlobPermissions and then call String()
	IPRange                      IPRange `param:"sip"`
	Identifier                   string  `param:"si"`
	ContainerName                string
	BlobName                     string // Use "" to create a Container SAS
	Directory                    string // Not nil for a directory SAS (ie sr=d)
	CacheControl                 string // rscc
	ContentDisposition           string // rscd
	ContentEncoding              string // rsce
	ContentLanguage              string // rscl
	ContentType                  string // rsct
	BlobVersion                  string // sr=bv
	AuthorizedObjectID           string // saoid
	UnauthorizedObjectID         string // suoid
	CorrelationID                string // scid
	EncryptionScope              string `param:"ses"`
	SignedDelegatedUserObjectID  string // sduoid
	SignedRequestHeaders         map[string]string
	SignedRequestQueryParameters map[string]string
}

func getDirectoryDepth(path string) string {
	if path == "" {
		return ""
	}

	trimmed := strings.Trim(path, "/")
	if trimmed == "" {
		return "0"
	}

	return fmt.Sprint(strings.Count(trimmed, "/") + 1)
}

// formatSignedRequestHeaders builds both the comma-separated header names for the srh query parameter
// and the canonicalized string for the stringToSign from a map of header name to header value.
// Canonicalized format: headerName_1:headerValue_1\nheaderName_2:headerValue_2\n (trailing \n on each pair)
func formatSignedRequestHeaders(headers map[string]string) (names string, canonicalized string) {
	if len(headers) == 0 {
		return "", ""
	}

	keys := make([]string, 0, len(headers))
	var sb strings.Builder

	for key, value := range headers {
		keys = append(keys, key)
		sb.WriteString(key)
		sb.WriteByte(':')
		sb.WriteString(value)
		sb.WriteByte('\n')
	}

	return strings.Join(keys, ","), sb.String()
}

// formatSignedRequestQueryParameters builds both the comma-separated query parameter names for the srq query parameter
// and the canonicalized string for the stringToSign from a map of query parameter name to value.
// Canonicalized format: \nqueryParam_1:queryParamValue_1\nqueryParam_2:queryParamValue_2 (prefix \n on each pair)
func formatSignedRequestQueryParameters(params map[string]string) (names string, canonicalized string) {
	if len(params) == 0 {
		return "", ""
	}

	keys := make([]string, 0, len(params))
	var sb strings.Builder

	for key, value := range params {
		keys = append(keys, key)
		sb.WriteByte('\n')
		sb.WriteString(key)
		sb.WriteByte(':')
		sb.WriteString(value)
	}

	return strings.Join(keys, ","), sb.String()
}

// SignWithSharedKey uses an account's SharedKeyCredential to sign this signature values to produce the proper SAS query parameters.
func (v BlobSignatureValues) SignWithSharedKey(sharedKeyCredential *SharedKeyCredential) (QueryParameters, error) {
	if v.Identifier == "" && (v.ExpiryTime.IsZero() || v.Permissions == "") {
		return QueryParameters{}, errors.New("service SAS is missing at least one of these: ExpiryTime or Permissions")
	}

	// Parse the resource
	resource := "c"
	if !v.SnapshotTime.IsZero() {
		resource = "bs"
	} else if v.BlobVersion != "" {
		resource = "bv"
	} else if v.Directory != "" {
		resource = "d"
		v.BlobName = ""
	} else if v.BlobName == "" {
		// do nothing
	} else {
		resource = "b"
	}

	// make sure the permission characters are in the correct order
	if resource == "c" {
		perms, err := parseContainerPermissions(v.Permissions)
		if err != nil {
			return QueryParameters{}, err
		}
		v.Permissions = perms.String()
	} else {
		perms, err := parseBlobPermissions(v.Permissions)
		if err != nil {
			return QueryParameters{}, err
		}
		v.Permissions = perms.String()
	}

	if v.Version == "" {
		v.Version = Version
	}
	startTime, expiryTime, snapshotTime := formatTimesForSigning(v.StartTime, v.ExpiryTime, v.SnapshotTime)

	signedIdentifier := v.Identifier

	// String to sign: http://msdn.microsoft.com/en-us/library/azure/dn140255.aspx
	stringToSign := strings.Join([]string{
		v.Permissions,
		startTime,
		expiryTime,
		getCanonicalName(sharedKeyCredential.AccountName(), v.ContainerName, v.BlobName, v.Directory),
		signedIdentifier,
		v.IPRange.String(),
		string(v.Protocol),
		v.Version,
		resource,
		snapshotTime, // signed timestamp
		v.EncryptionScope,
		v.CacheControl,       // rscc
		v.ContentDisposition, // rscd
		v.ContentEncoding,    // rsce
		v.ContentLanguage,    // rscl
		v.ContentType},       // rsct
		"\n")

	signature, err := exported.ComputeHMACSHA256(sharedKeyCredential, stringToSign)
	if err != nil {
		return QueryParameters{}, err
	}

	p := QueryParameters{
		// Common SAS parameters
		version:         v.Version,
		protocol:        v.Protocol,
		startTime:       v.StartTime,
		expiryTime:      v.ExpiryTime,
		permissions:     v.Permissions,
		ipRange:         v.IPRange,
		encryptionScope: v.EncryptionScope,

		// Container/Blob-specific SAS parameters
		resource:             resource,
		identifier:           v.Identifier,
		cacheControl:         v.CacheControl,
		contentDisposition:   v.ContentDisposition,
		contentEncoding:      v.ContentEncoding,
		contentLanguage:      v.ContentLanguage,
		contentType:          v.ContentType,
		snapshotTime:         v.SnapshotTime,
		signedDirectoryDepth: getDirectoryDepth(v.Directory),
		authorizedObjectID:   v.AuthorizedObjectID,
		unauthorizedObjectID: v.UnauthorizedObjectID,
		correlationID:        v.CorrelationID,
		// Calculated SAS signature
		signature: signature,
	}

	return p, nil
}

// SignWithUserDelegation uses an account's UserDelegationCredential to sign this signature values to produce the proper SAS query parameters.
func (v BlobSignatureValues) SignWithUserDelegation(userDelegationCredential *UserDelegationCredential) (QueryParameters, error) {
	if userDelegationCredential == nil {
		return QueryParameters{}, fmt.Errorf("cannot sign SAS query without User Delegation Key")
	}

	if v.ExpiryTime.IsZero() || v.Permissions == "" {
		return QueryParameters{}, errors.New("user delegation SAS is missing at least one of these: ExpiryTime or Permissions")
	}

	// Parse the resource
	resource := "c"
	if !v.SnapshotTime.IsZero() {
		resource = "bs"
	} else if v.BlobVersion != "" {
		resource = "bv"
	} else if v.Directory != "" {
		resource = "d"
		v.BlobName = ""
	} else if v.BlobName == "" {
		// do nothing
	} else {
		resource = "b"
	}
	// make sure the permission characters are in the correct order
	if resource == "c" {
		perms, err := parseContainerPermissions(v.Permissions)
		if err != nil {
			return QueryParameters{}, err
		}
		v.Permissions = perms.String()
	} else {
		perms, err := parseBlobPermissions(v.Permissions)
		if err != nil {
			return QueryParameters{}, err
		}
		v.Permissions = perms.String()
	}

	if v.Version == "" {
		v.Version = Version
	}
	startTime, expiryTime, snapshotTime := formatTimesForSigning(v.StartTime, v.ExpiryTime, v.SnapshotTime)

	udk := exported.GetUDKParams(userDelegationCredential)

	udkStart, udkExpiry, _ := formatTimesForSigning(*udk.SignedStart, *udk.SignedExpiry, time.Time{})

	var signedDelegatedUserTenantID string
	if udk.SignedDelegatedUserTenantID != nil {
		signedDelegatedUserTenantID = *udk.SignedDelegatedUserTenantID
	}

	srhNames, srhCanonicalized := formatSignedRequestHeaders(v.SignedRequestHeaders)
	srqNames, srqCanonicalized := formatSignedRequestQueryParameters(v.SignedRequestQueryParameters)

	stringToSign := strings.Join([]string{
		v.Permissions,
		startTime,
		expiryTime,
		getCanonicalName(exported.GetAccountName(userDelegationCredential), v.ContainerName, v.BlobName, v.Directory),
		*udk.SignedOID,
		*udk.SignedTID,
		udkStart,
		udkExpiry,
		*udk.SignedService,
		*udk.SignedVersion,
		v.AuthorizedObjectID,
		v.UnauthorizedObjectID,
		v.CorrelationID,
		signedDelegatedUserTenantID,
		v.SignedDelegatedUserObjectID,
		v.IPRange.String(),
		string(v.Protocol),
		v.Version,
		resource,
		snapshotTime, // signed timestamp
		v.EncryptionScope,
		srhCanonicalized,
		srqCanonicalized,
		v.CacheControl,       // rscc
		v.ContentDisposition, // rscd
		v.ContentEncoding,    // rsce
		v.ContentLanguage,    // rscl
		v.ContentType},       // rsct
		"\n")

	signature, err := exported.ComputeUDCHMACSHA256(userDelegationCredential, stringToSign)
	if err != nil {
		return QueryParameters{}, err
	}

	p := QueryParameters{
		// Common SAS parameters
		version:         v.Version,
		protocol:        v.Protocol,
		startTime:       v.StartTime,
		expiryTime:      v.ExpiryTime,
		permissions:     v.Permissions,
		ipRange:         v.IPRange,
		encryptionScope: v.EncryptionScope,

		// Container/Blob-specific SAS parameters
		resource:                     resource,
		identifier:                   v.Identifier,
		cacheControl:                 v.CacheControl,
		contentDisposition:           v.ContentDisposition,
		contentEncoding:              v.ContentEncoding,
		contentLanguage:              v.ContentLanguage,
		contentType:                  v.ContentType,
		snapshotTime:                 v.SnapshotTime,
		signedDirectoryDepth:         getDirectoryDepth(v.Directory),
		authorizedObjectID:           v.AuthorizedObjectID,
		unauthorizedObjectID:         v.UnauthorizedObjectID,
		correlationID:                v.CorrelationID,
		signedDelegatedUserObjectID:  v.SignedDelegatedUserObjectID,
		signedRequestHeaders:         srhNames,
		signedRequestQueryParameters: srqNames,
		// Calculated SAS signature
		signature: signature,
	}

	// User delegation SAS specific parameters
	p.signedOID = *udk.SignedOID
	p.signedTID = *udk.SignedTID
	p.signedStart = *udk.SignedStart
	p.signedExpiry = *udk.SignedExpiry
	p.signedService = *udk.SignedService
	p.signedVersion = *udk.SignedVersion
	p.signedDelegatedUserTenantID = signedDelegatedUserTenantID

	return p, nil
}

// getCanonicalName computes the canonical name for a container or blob resource for SAS signing.
func getCanonicalName(account string, containerName string, blobName string, directoryName string) string {
	// Container: "/blob/account/containername"
	// Blob:      "/blob/account/containername/blobname"
	elements := []string{"/blob/", account, "/", containerName}
	if blobName != "" {
		elements = append(elements, "/", strings.ReplaceAll(blobName, "\\", "/"))
	} else if directoryName != "" {
		elements = append(elements, "/", directoryName)
	}
	return strings.Join(elements, "")
}

// ContainerPermissions type simplifies creating the permissions string for an Azure Storage container SAS.
// Initialize an instance of this type and then call its String method to set BlobSignatureValues' Permissions field.
// All permissions descriptions can be found here: https://docs.microsoft.com/en-us/rest/api/storageservices/create-service-sas#permissions-for-a-directory-container-or-blob
type ContainerPermissions struct {
	Read, Add, Create, Write, Delete, DeletePreviousVersion, List, Tag, FilterByTags, Move, SetImmutabilityPolicy bool
	Execute, ModifyOwnership, ModifyPermissions                                                                   bool // Meant for hierarchical namespace accounts
}

// String produces the SAS permissions string for an Azure Storage container.
// Call this method to set BlobSignatureValues' Permissions field.
func (p *ContainerPermissions) String() string {
	var b bytes.Buffer
	if p.Read {
		b.WriteRune('r')
	}
	if p.Add {
		b.WriteRune('a')
	}
	if p.Create {
		b.WriteRune('c')
	}
	if p.Write {
		b.WriteRune('w')
	}
	if p.Delete {
		b.WriteRune('d')
	}
	if p.DeletePreviousVersion {
		b.WriteRune('x')
	}
	if p.List {
		b.WriteRune('l')
	}
	if p.Tag {
		b.WriteRune('t')
	}
	if p.FilterByTags {
		b.WriteRune('f')
	}
	if p.Move {
		b.WriteRune('m')
	}
	if p.Execute {
		b.WriteRune('e')
	}
	if p.ModifyOwnership {
		b.WriteRune('o')
	}
	if p.ModifyPermissions {
		b.WriteRune('p')
	}
	if p.SetImmutabilityPolicy {
		b.WriteRune('i')
	}
	return b.String()
}

// Parse initializes ContainerPermissions' fields from a string.
func parseContainerPermissions(s string) (ContainerPermissions, error) {
	p := ContainerPermissions{} // Clear the flags
	for _, r := range s {
		switch r {
		case 'r':
			p.Read = true
		case 'a':
			p.Add = true
		case 'c':
			p.Create = true
		case 'w':
			p.Write = true
		case 'd':
			p.Delete = true
		case 'x':
			p.DeletePreviousVersion = true
		case 'l':
			p.List = true
		case 't':
			p.Tag = true
		case 'f':
			p.FilterByTags = true
		case 'm':
			p.Move = true
		case 'e':
			p.Execute = true
		case 'o':
			p.ModifyOwnership = true
		case 'p':
			p.ModifyPermissions = true
		case 'i':
			p.SetImmutabilityPolicy = true
		default:
			return ContainerPermissions{}, fmt.Errorf("invalid permission: '%v'", r)
		}
	}
	return p, nil
}

// BlobPermissions type simplifies creating the permissions string for an Azure Storage blob SAS.
// Initialize an instance of this type and then call its String method to set BlobSignatureValues' Permissions field.
type BlobPermissions struct {
	Read, Add, Create, Write, Delete, DeletePreviousVersion, PermanentDelete, List, Tag, Move, Execute, Ownership, Permissions, SetImmutabilityPolicy bool
}

// String produces the SAS permissions string for an Azure Storage blob.
// Call this method to set BlobSignatureValues' Permissions field.
func (p *BlobPermissions) String() string {
	var b bytes.Buffer
	if p.Read {
		b.WriteRune('r')
	}
	if p.Add {
		b.WriteRune('a')
	}
	if p.Create {
		b.WriteRune('c')
	}
	if p.Write {
		b.WriteRune('w')
	}
	if p.Delete {
		b.WriteRune('d')
	}
	if p.DeletePreviousVersion {
		b.WriteRune('x')
	}
	if p.PermanentDelete {
		b.WriteRune('y')
	}
	if p.List {
		b.WriteRune('l')
	}
	if p.Tag {
		b.WriteRune('t')
	}
	if p.Move {
		b.WriteRune('m')
	}
	if p.Execute {
		b.WriteRune('e')
	}
	if p.Ownership {
		b.WriteRune('o')
	}
	if p.Permissions {
		b.WriteRune('p')
	}
	if p.SetImmutabilityPolicy {
		b.WriteRune('i')
	}
	return b.String()
}

// Parse initializes BlobPermissions' fields from a string.
func parseBlobPermissions(s string) (BlobPermissions, error) {
	p := BlobPermissions{} // Clear the flags
	for _, r := range s {
		switch r {
		case 'r':
			p.Read = true
		case 'a':
			p.Add = true
		case 'c':
			p.Create = true
		case 'w':
			p.Write = true
		case 'd':
			p.Delete = true
		case 'x':
			p.DeletePreviousVersion = true
		case 'y':
			p.PermanentDelete = true
		case 'l':
			p.List = true
		case 't':
			p.Tag = true
		case 'm':
			p.Move = true
		case 'e':
			p.Execute = true
		case 'o':
			p.Ownership = true
		case 'p':
			p.Permissions = true
		case 'i':
			p.SetImmutabilityPolicy = true
		default:
			return BlobPermissions{}, fmt.Errorf("invalid permission: '%v'", r)
		}
	}
	return p, nil
}
