package azure_storage

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/xml"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	regexp "github.com/wasilibs/go-re2"

	"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
	"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)

type Scanner struct {
	client *http.Client
	detectors.DefaultMultiPartCredentialProvider
}

var _ detectors.Detector = (*Scanner)(nil)

var (
	defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses

	namePat = regexp.MustCompile(`(?i:Account[_.-]?Name|Storage[_.-]?(?:Account|Name))(?:.|\s){0,20}?\b([a-z0-9]{3,24})\b|([a-z0-9]{3,24})(?i:\.blob\.core\.windows\.net)`) // Names can only be lowercase alphanumeric.
	keyPat  = regexp.MustCompile(`(?i:(?:Access|Account|Storage)[_.-]?Key)(?:.|\s){0,25}?([a-zA-Z0-9+\/-]{86,88}={0,2})`)

	// https://learn.microsoft.com/en-us/azure/storage/common/storage-use-emulator
	testNames = map[string]struct{}{
		"devstoreaccount1": {},
		"storagesample":    {},
	}
	testKeys = map[string]struct{}{
		"Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==": {},
	}
)

func (s Scanner) Keywords() []string {
	return []string{
		"DefaultEndpointsProtocol=http", "EndpointSuffix", "core.windows.net",
		"AccountName", "Account_Name", "Account.Name", "Account-Name",
		"StorageAccount", "Storage_Account", "Storage.Account", "Storage-Account",
		"AccountKey", "Account_Key", "Account.Key", "Account-Key",
	}
}

func (s Scanner) Type() detector_typepb.DetectorType {
	return detector_typepb.DetectorType_AzureStorage
}

func (s Scanner) Description() string {
	return "Azure Storage is a Microsoft-managed cloud service that provides storage that is highly available, secure, durable, scalable, and redundant. Azure Storage Account keys can be used to access and manage data within storage accounts."
}

func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
	dataStr := string(data)

	// Deduplicate results.
	names := make(map[string]struct{})
	for _, matches := range namePat.FindAllStringSubmatch(dataStr, -1) {
		var name string
		if matches[1] != "" {
			name = matches[1]
		} else {
			name = matches[2]
		}
		if _, ok := testNames[name]; ok {
			continue
		}
		names[name] = struct{}{}
	}
	if len(names) == 0 {
		return results, nil
	}

	keys := make(map[string]struct{})
	for _, matches := range keyPat.FindAllStringSubmatch(dataStr, -1) {
		key := matches[1]
		if _, ok := testKeys[key]; ok {
			continue
		}
		keys[key] = struct{}{}
	}
	if len(keys) == 0 {
		return results, nil
	}

	// Check results.
	for name := range names {
		var s1 detectors.Result
		for key := range keys {
			s1 = detectors.Result{
				DetectorType: s.Type(),
				Raw:          []byte(key),
				RawV2:        []byte(fmt.Sprintf(`{"accountName":"%s","accountKey":"%s"}`, name, key)),
				ExtraData: map[string]string{
					"Account_name": name,
				},
			}

			if verify {
				client := s.client
				if client == nil {
					client = defaultClient
				}

				isVerified, verificationErr := s.verifyMatch(ctx, client, name, key, s1.ExtraData)
				s1.Verified = isVerified
				s1.SetVerificationError(verificationErr, key)
			}

			results = append(results, s1)
			if s1.Verified {
				break
			}
		}
	}

	return results, nil
}

type storageResponse struct {
	Containers struct {
		Container []container `xml:"Container"`
	} `xml:"Containers"`
}

type container struct {
	Name string `xml:"Name"`
}

func (s Scanner) verifyMatch(ctx context.Context, client *http.Client, name string, key string, extraData map[string]string) (bool, error) {
	// https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key
	now := time.Now().UTC().Format(http.TimeFormat)
	stringToSign := "GET\n\n\n\n\n\n\n\n\n\n\n\nx-ms-date:" + now + "\nx-ms-version:2019-12-12\n/" + name + "/\ncomp:list"
	accountKeyBytes, _ := base64.StdEncoding.DecodeString(key)
	h := hmac.New(sha256.New, accountKeyBytes)
	h.Write([]byte(stringToSign))
	signature := base64.StdEncoding.EncodeToString(h.Sum(nil))

	url := "https://" + name + ".blob.core.windows.net/?comp=list"
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return false, err
	}

	req.Header.Set("x-ms-date", now)
	req.Header.Set("x-ms-version", "2019-12-12")
	req.Header.Set("Authorization", "SharedKey "+name+":"+signature)

	res, err := client.Do(req)
	if err != nil {
		// If the host is not found, we can assume that the accountName is not valid
		if strings.Contains(err.Error(), "no such host") {
			return false, nil
		}
		return false, err
	}
	defer func() {
		_, _ = io.Copy(io.Discard, res.Body)
		_ = res.Body.Close()
	}()

	switch res.StatusCode {
	case http.StatusOK:
		// parse response
		response := storageResponse{}
		if err := xml.NewDecoder(res.Body).Decode(&response); err != nil {
			return false, err
		}

		// update the extra data with container names only
		if len(response.Containers.Container) > 0 {
			var b strings.Builder
			for i, c := range response.Containers.Container {
				if i > 0 {
					b.WriteString(", ")
				}
				b.WriteString(c.Name)
			}
			extraData["container_names"] = b.String()
		}

		return true, nil
	case http.StatusForbidden:
		// 403 if account id or key is invalid, or if the account is disabled
		return false, nil
	default:
		return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
	}
}
