package usage

import (
	"bytes"
	"fmt"
	"os"
	"sort"
	"strings"

	"github.com/infracost/infracost/internal/schema"
	"github.com/pkg/errors"
	log "github.com/sirupsen/logrus"
	"golang.org/x/mod/semver"
	yamlv3 "gopkg.in/yaml.v3"
)

const minUsageFileVersion = "0.1"
const maxUsageFileVersion = "0.1"

type UsageFile struct { // nolint:revive
	Version string `yaml:"version"`
	// We represent resource usage in using a YAML node so we have control over the comments
	RawResourceUsage yamlv3.Node `yaml:"resource_usage"`
	// The raw usage is then parsed into this struct
	ResourceUsages []*ResourceUsage `yaml:"-"`
}

// CreateUsageFile creates a blank usage file if it does not exists
func CreateUsageFile(path string) error {
	if _, err := os.Stat(path); os.IsNotExist(err) {

		usageFile := NewBlankUsageFile()

		err = usageFile.WriteToPath(path)
		if err != nil {
			return errors.Wrapf(err, "Error writing blank usage file to %s", path)
		}
	} else {
		log.Debug("Specified usage file already exists, no overriding")
	}

	return nil
}

func LoadUsageFile(path string) (*UsageFile, error) {
	blankUsage := NewBlankUsageFile()
	if _, err := os.Stat(path); os.IsNotExist(err) {
		log.Debug("Specified usage file does not exist. Using a blank file")

		return blankUsage, nil
	}

	contents, err := os.ReadFile(path)
	if err != nil {
		return blankUsage, errors.Wrapf(err, "Error reading usage file")
	}

	usageFile, err := LoadUsageFileFromString(string(contents))
	if err != nil {
		return blankUsage, errors.Wrapf(err, "Error loading usage file")
	}

	return usageFile, nil
}

func NewBlankUsageFile() *UsageFile {
	usageFile := &UsageFile{
		Version: maxUsageFileVersion,
		RawResourceUsage: yamlv3.Node{
			Kind: yamlv3.MappingNode,
		},
	}

	return usageFile
}

func LoadUsageFileFromString(s string) (*UsageFile, error) {
	usageFile := &UsageFile{}

	err := yamlv3.Unmarshal([]byte(s), usageFile)
	if err != nil {
		return usageFile, errors.Wrap(err, "Error parsing usage YAML")
	}

	if !usageFile.checkVersion() {
		return usageFile, fmt.Errorf("Invalid usage file version. Supported versions are %s ≤ x ≤ %s", minUsageFileVersion, maxUsageFileVersion)
	}

	err = usageFile.parseResourceUsages()
	if err != nil {
		return usageFile, errors.Wrap(err, "Error loading YAML file")
	}

	return usageFile, nil
}

func (u *UsageFile) WriteToPath(path string) error {
	allCommented := u.dumpResourceUsages()

	root := &yamlv3.Node{
		Kind: yamlv3.MappingNode,
		HeadComment: `You can use this file to define resource usage estimates for Infracost to use when calculating
the cost of usage-based resource, such as AWS S3 or Lambda.
` + "`infracost breakdown --usage-file infracost-usage.yml [other flags]`" + `
See https://infracost.io/usage-file/ for docs`,
	}

	resourceUsagesKeyNode := &yamlv3.Node{
		Kind:  yamlv3.ScalarNode,
		Value: "resource_usage",
	}
	if allCommented {
		markNodeAsComment(resourceUsagesKeyNode)
	}

	root.Content = append(root.Content,
		&yamlv3.Node{
			Kind:  yamlv3.ScalarNode,
			Value: "version",
		},
		&yamlv3.Node{
			Kind:  yamlv3.ScalarNode,
			Value: u.Version,
		},
		resourceUsagesKeyNode,
		&u.RawResourceUsage,
	)

	// Add a comment to the first commented-out resource
	for _, node := range u.RawResourceUsage.Content {
		if isNodeMarkedAsCommented(node) {
			node.HeadComment = `##
## The following usage values are all commented-out, you can uncomment resources and customize as needed.
##`
			break
		}
	}

	var buf bytes.Buffer
	yamlEncoder := yamlv3.NewEncoder(&buf)
	yamlEncoder.SetIndent(2)
	err := yamlEncoder.Encode(root)
	if err != nil {
		return err
	}

	b := buf.Bytes()
	// If all the resources are commented then we also want to comment the resource_usage key
	b = replaceCommentMarks(b)

	return os.WriteFile(path, b, 0600)
}

func (u *UsageFile) ToUsageDataMap() map[string]*schema.UsageData {
	m := make(map[string]*schema.UsageData)

	for _, resourceUsage := range u.ResourceUsages {
		m[resourceUsage.Name] = schema.NewUsageData(resourceUsage.Name, schema.ParseAttributes(resourceUsage.Map()))
	}

	return m
}

func (u *UsageFile) checkVersion() bool {
	v := u.Version
	if !strings.HasPrefix(u.Version, "v") {
		v = "v" + u.Version
	}
	return semver.Compare(v, "v"+minUsageFileVersion) >= 0 && semver.Compare(v, "v"+maxUsageFileVersion) <= 0
}

// InvalidKeys returns a list of keys that are invalid in the usage file.
// It currently checks the reference usage file for a list of valid keys.
// In the future we will want this to usage the resource usage schema structs as well.
func (u *UsageFile) InvalidKeys() ([]string, error) {
	invalidKeys := make([]string, 0)

	refFile, err := LoadReferenceFile()
	if err != nil {
		return invalidKeys, err
	}

	for _, resourceUsage := range u.ResourceUsages {
		refResourceUsage := refFile.FindMatchingResourceUsage(resourceUsage.Name)
		if refResourceUsage == nil {
			continue
		}

		refItemMap := refResourceUsage.Map()

		// Iterate over provided keys and check if they are
		// present in the reference usage file
		for _, item := range resourceUsage.Items {
			invalidKeys = append(invalidKeys, findInvalidKeys(item, refItemMap)...)
		}
	}

	// Remove duplicate entries
	invalidKeys = removeDuplicateStr(invalidKeys)

	// Sort the keys alphabetically
	sort.Strings(invalidKeys)

	return invalidKeys, nil
}

func removeDuplicateStr(strSlice []string) []string {
	allKeys := make(map[string]bool)
	list := []string{}
	for _, item := range strSlice {
		if _, value := allKeys[item]; !value {
			allKeys[item] = true
			list = append(list, item)
		}
	}
	return list
}

// findInvalidKeys recursively searches for invalid keys in the provided item
func findInvalidKeys(item *schema.UsageItem, refMap map[string]interface{}) []string {
	invalidKeys := make([]string, 0)

	if refVal, ok := refMap[item.Key]; !ok {
		invalidKeys = append(invalidKeys, item.Key)
	} else if item.ValueType == schema.SubResourceUsage && item.Value != nil {
		for _, subItem := range item.Value.(*ResourceUsage).Items {
			invalidKeys = append(invalidKeys, findInvalidKeys(subItem, refVal.(map[string]interface{}))...)
		}
	}

	return invalidKeys
}

func (u *UsageFile) parseResourceUsages() error {
	var err error
	u.ResourceUsages, err = ResourceUsagesFromYAML(u.RawResourceUsage)
	if err != nil {
		return errors.Wrapf(err, "Error parsing usage file")
	}

	return nil
}

func (u *UsageFile) dumpResourceUsages() bool {
	var allCommented bool
	u.RawResourceUsage, allCommented = ResourceUsagesToYAML(u.ResourceUsages)
	return allCommented
}
