/*
Copyright (c) Edgeless Systems GmbH

SPDX-License-Identifier: AGPL-3.0-only
*/

// package rules is used find and modify Bazel rules in WORKSPACE and bzl files.
package rules

import (
	"errors"
	"fmt"
	"sort"
	"strings"

	"github.com/bazelbuild/buildtools/build"
	"golang.org/x/exp/slices"
)

// Rules is used to find and modify Bazel rules of a set of rule kinds in WORKSPACE and .bzl files.
// Filter is a list of rule kinds to consider.
// If filter is empty, all rules are considered.
func Rules(file *build.File, filter []string) (rules []*build.Rule) {
	allRules := file.Rules("")
	if len(filter) == 0 {
		return allRules
	}
ruleLoop:
	for _, rule := range allRules {
		for _, ruleKind := range filter {
			if rule.Kind() == ruleKind {
				rules = append(rules, rule)
				continue ruleLoop
			}
		}
	}
	return rules
}

// ValidatePinned checks if the given rule is a pinned dependency rule.
// That is, if it has a name, either a url or urls attribute, and a sha256 attribute.
func ValidatePinned(rule *build.Rule) (validationErrs []error) {
	if rule.Name() == "" {
		validationErrs = append(validationErrs, errors.New("rule has no name"))
	}

	hasURL := rule.Attr("url") != nil
	hasURLs := rule.Attr("urls") != nil
	if !hasURL && !hasURLs {
		validationErrs = append(validationErrs, errors.New("rule has no url or urls attribute"))
	}
	if hasURL && hasURLs {
		validationErrs = append(validationErrs, errors.New("rule has both url and urls attribute"))
	}
	if hasURL {
		url := rule.AttrString("url")
		if url == "" {
			validationErrs = append(validationErrs, errors.New("rule has empty url attribute"))
		}
	}
	if hasURLs {
		urls := rule.AttrStrings("urls")
		if len(urls) == 0 {
			validationErrs = append(validationErrs, errors.New("rule has empty urls list attribute"))
		} else {
			for _, url := range urls {
				if url == "" {
					validationErrs = append(validationErrs, errors.New("rule has empty url in urls attribute"))
				}
			}
		}
	}
	if rule.Attr("sha256") == nil {
		validationErrs = append(validationErrs, errors.New("rule has no sha256 attribute"))
	} else {
		sha256 := rule.AttrString("sha256")
		if sha256 == "" {
			validationErrs = append(validationErrs, errors.New("rule has empty sha256 attribute"))
		}
	}
	return validationErrs
}

// Check checks if a dependency rule is normalized and contains a mirror url.
// All errors reported by this function can be fixed by calling AddURLs and Normalize.
func Check(rule *build.Rule) (validationErrs []error) {
	hasURL := rule.Attr("url") != nil
	if hasURL {
		validationErrs = append(validationErrs, errors.New("rule has url (singular) attribute"))
	}
	urls := rule.AttrStrings("urls")
	sorted := make([]string, len(urls))
	copy(sorted, urls)
	sortURLs(sorted)
	for i, url := range urls {
		if url != sorted[i] {
			validationErrs = append(validationErrs, errors.New("rule has unsorted urls attributes"))
			break
		}
	}
	if !HasMirrorURL(rule) {
		validationErrs = append(validationErrs, errors.New("rule is not mirrored"))
	}
	if rule.Kind() == "http_archive" && rule.Attr("type") == nil {
		validationErrs = append(validationErrs, errors.New("http_archive rule has no type attribute"))
	}
	if rule.Kind() == "rpm" && len(urls) != 1 {
		validationErrs = append(validationErrs, errors.New("rpm rule has unstable urls that are not the edgeless mirror"))
	}
	return validationErrs
}

// Normalize normalizes a rule and returns true if the rule was changed.
func Normalize(rule *build.Rule) (changed bool) {
	changed = addTypeAttribute(rule)
	urls := GetURLs(rule)
	normalizedURLS := append([]string{}, urls...)
	// rpm rules must have exactly one url (the edgeless mirror)
	if mirrorU, err := mirrorURL(rule); rule.Kind() == "rpm" && err == nil {
		normalizedURLS = []string{mirrorU}
	}
	sortURLs(normalizedURLS)
	normalizedURLS = deduplicateURLs(normalizedURLS)
	if slices.Equal(urls, normalizedURLS) && rule.Attr("url") == nil {
		return changed
	}
	setURLs(rule, normalizedURLS)
	return true
}

// AddURLs adds a url to a rule.
func AddURLs(rule *build.Rule, urls []string) {
	existingURLs := GetURLs(rule)
	existingURLs = append(existingURLs, urls...)
	sortURLs(existingURLs)
	deduplicatedURLs := deduplicateURLs(existingURLs)
	setURLs(rule, deduplicatedURLs)
}

// GetHash returns the sha256 hash of a rule.
func GetHash(rule *build.Rule) (string, error) {
	hash := rule.AttrString("sha256")
	if hash == "" {
		return "", fmt.Errorf("rule %s has empty or missing sha256 attribute", rule.Name())
	}
	return hash, nil
}

// SetHash sets the sha256 hash of a rule.
func SetHash(rule *build.Rule, hash string) {
	rule.SetAttr("sha256", &build.StringExpr{Value: hash})
}

// GetURLs returns the sorted urls of a rule.
func GetURLs(rule *build.Rule) []string {
	urls := rule.AttrStrings("urls")
	url := rule.AttrString("url")
	if url != "" {
		urls = append(urls, url)
	}
	return urls
}

// HasMirrorURL returns true if the rule has a url from the Edgeless mirror
// with the correct hash.
func HasMirrorURL(rule *build.Rule) bool {
	_, err := mirrorURL(rule)
	return err == nil
}

// PrepareUpgrade prepares a rule for an upgrade
// by removing all urls that are not upstream urls.
// and removing the hash attribute.
// it returns true if the rule was changed.
func PrepareUpgrade(rule *build.Rule) (changed bool, err error) {
	upstreamURLs, err := UpstreamURLs(rule)
	if err != nil {
		return false, err
	}
	setURLs(rule, upstreamURLs)
	rule.DelAttr("sha256")
	return true, nil
}

// UpstreamURLs returns the upstream urls (non-mirror urls) of a rule.
func UpstreamURLs(rule *build.Rule) (urls []string, err error) {
	urls = GetURLs(rule)
	var upstreamURLs []string
	for _, url := range urls {
		if isUpstreamURL(url) {
			upstreamURLs = append(upstreamURLs, url)
		}
	}
	if len(upstreamURLs) == 0 {
		return nil, ErrNoUpstreamURL
	}
	return upstreamURLs, nil
}

func deduplicateURLs(urls []string) (deduplicated []string) {
	seen := make(map[string]bool)
	for _, url := range urls {
		if !seen[url] {
			deduplicated = append(deduplicated, url)
			seen[url] = true
		}
	}
	return deduplicated
}

// addTypeAttribute adds the type attribute to http_archive rules if it is missing.
// it returns true if the rule was changed.
// it returns an error if the rule does not have enough information to add the type attribute.
func addTypeAttribute(rule *build.Rule) bool {
	// only http_archive rules have a type attribute
	if rule.Kind() != "http_archive" {
		return false
	}
	if rule.Attr("type") != nil {
		return false
	}
	// iterate over all URLs and check if they have a known archive type
	var archiveType string
urlLoop:
	for _, url := range GetURLs(rule) {
		switch {
		case strings.HasSuffix(url, ".aar"):
			archiveType = "aar"
			break urlLoop
		case strings.HasSuffix(url, ".ar"):
			archiveType = "ar"
			break urlLoop
		case strings.HasSuffix(url, ".deb"):
			archiveType = "deb"
			break urlLoop
		case strings.HasSuffix(url, ".jar"):
			archiveType = "jar"
			break urlLoop
		case strings.HasSuffix(url, ".tar.bz2"):
			archiveType = "tar.bz2"
			break urlLoop
		case strings.HasSuffix(url, ".tar.gz"):
			archiveType = "tar.gz"
			break urlLoop
		case strings.HasSuffix(url, ".tar.xz"):
			archiveType = "tar.xz"
			break urlLoop
		case strings.HasSuffix(url, ".tar.zst"):
			archiveType = "tar.zst"
			break urlLoop
		case strings.HasSuffix(url, ".tar"):
			archiveType = "tar"
			break urlLoop
		case strings.HasSuffix(url, ".tgz"):
			archiveType = "tgz"
			break urlLoop
		case strings.HasSuffix(url, ".txz"):
			archiveType = "txz"
			break urlLoop
		case strings.HasSuffix(url, ".tzst"):
			archiveType = "tzst"
			break urlLoop
		case strings.HasSuffix(url, ".war"):
			archiveType = "war"
			break urlLoop
		case strings.HasSuffix(url, ".zip"):
			archiveType = "zip"
			break urlLoop
		}
	}
	if archiveType == "" {
		return false
	}
	rule.SetAttr("type", &build.StringExpr{Value: archiveType})
	return true
}

// mirrorURL returns the first mirror URL for a rule.
func mirrorURL(rule *build.Rule) (string, error) {
	hash, err := GetHash(rule)
	if err != nil {
		return "", err
	}
	urls := GetURLs(rule)
	for _, url := range urls {
		if strings.HasPrefix(url, edgelessMirrorPrefix) && strings.HasSuffix(url, hash) {
			return url, nil
		}
	}
	return "", fmt.Errorf("rule %s has no mirror url", rule.Name())
}

func setURLs(rule *build.Rule, urls []string) {
	// delete single url attribute if it exists
	rule.DelAttr("url")
	urlsAttr := []build.Expr{}
	for _, url := range urls {
		urlsAttr = append(urlsAttr, &build.StringExpr{Value: url})
	}
	rule.SetAttr("urls", &build.ListExpr{List: urlsAttr, ForceMultiLine: true})
}

func sortURLs(urls []string) {
	// Bazel mirror should be first
	// edgeless mirror should be second
	// other urls should be last
	// if there are multiple urls from the same mirror, they should be sorted alphabetically
	sort.Slice(urls, func(i, j int) bool {
		rank := func(url string) int {
			if strings.HasPrefix(url, bazelMirrorPrefix) {
				return 0
			}
			if strings.HasPrefix(url, edgelessMirrorPrefix) {
				return 1
			}
			return 2
		}
		if rank(urls[i]) != rank(urls[j]) {
			return rank(urls[i]) < rank(urls[j])
		}
		return urls[i] < urls[j]
	})
}

func isUpstreamURL(url string) bool {
	return !strings.HasPrefix(url, bazelMirrorPrefix) && !strings.HasPrefix(url, edgelessMirrorPrefix)
}

var (
	// SupportedRules is a list of all rules that can be mirrored.
	SupportedRules = []string{
		"http_archive",
		"http_file",
		"rpm",
	}

	// ErrNoUpstreamURL is returned when a rule has no upstream URL.
	ErrNoUpstreamURL = errors.New("rule has no upstream URL")
)

const (
	bazelMirrorPrefix    = "https://mirror.bazel.build/"
	edgelessMirrorPrefix = "https://cdn.confidential.cloud/constellation/cas/sha256/"
)