package codegen

import (
	"bufio"
	"encoding/json"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclsimple"
	"github.com/hashicorp/hcl/v2/hclwrite"
	ctyjson "github.com/zclconf/go-cty/cty/json"

	"errors"

	"github.com/gruntwork-io/terragrunt/internal/util"
	"github.com/gruntwork-io/terragrunt/pkg/log"
)

const (
	// A comment that is added to the top of the generated file to indicate that this file was generated by Terragrunt.
	// We use a hardcoded random string at the end to make the string further unique.
	TerragruntGeneratedSignature = "Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa"

	// The default prefix to use for comments in the generated file
	DefaultCommentPrefix = "# "
)

// GenerateConfigExists is an enum to represent valid values for if_exists.
type GenerateConfigExists int

const (
	ExistsError GenerateConfigExists = iota
	ExistsSkip
	ExistsOverwrite
	ExistsOverwriteTerragrunt
	ExistsUnknown
)

// GenerateConfigDisabled is an enum to represent valid values for if_disabled.
type GenerateConfigDisabled int

const (
	DisabledSkip GenerateConfigDisabled = iota
	DisabledRemove
	DisabledRemoveTerragrunt
	DisabledUnknown
)

const (
	ExistsErrorStr               = "error"
	ExistsSkipStr                = "skip"
	ExistsOverwriteStr           = "overwrite"
	ExistsOverwriteTerragruntStr = "overwrite_terragrunt"

	DisabledSkipStr             = "skip"
	DisabledRemoveStr           = "remove"
	DisabledRemoveTerragruntStr = "remove_terragrunt"

	assumeRoleConfigKey                = "assume_role"
	assumeRoleWithWebIdentityConfigKey = "assume_role_with_web_identity"

	encryptionBlockName = "encryption"

	EncryptionKeyProviderKey = "key_provider"
	encryptionResourceName   = "default"

	encryptionMethodKey     = "method"
	encryptionDefaultMethod = "aes_gcm"

	encryptionKeysAttributeName = "keys"

	encryptionStateBlockName = "state"
	encryptionPlanBlockName  = "plan"
)

// GenerateConfig is configuration for generating code
type GenerateConfig struct {
	HclFmt           *bool  `cty:"hcl_fmt"`
	Path             string `cty:"path"`
	IfExistsStr      string `cty:"if_exists"`
	IfDisabledStr    string `cty:"if_disabled"`
	CommentPrefix    string `cty:"comment_prefix"`
	Contents         string `cty:"contents"`
	IfExists         GenerateConfigExists
	IfDisabled       GenerateConfigDisabled
	DisableSignature bool `cty:"disable_signature"`
	Disable          bool `cty:"disable"`
}

// WriteToFile will generate a new file at the given target path with the given contents. If a file already exists at
// the target path, the behavior depends on the value of IfExists:
// - if ExistsError, return an error.
// - if ExistsSkip, do nothing and return
// - if ExistsOverwrite, overwrite the existing file
func WriteToFile(l log.Logger, basePath string, config *GenerateConfig) error {
	// Figure out the target path to generate the code in. If relative, merge with basePath.
	var targetPath string
	if filepath.IsAbs(config.Path) {
		targetPath = config.Path
	} else {
		targetPath = filepath.Join(basePath, config.Path)
	}

	targetFileExists := util.FileExists(targetPath)

	// If this GenerateConfig is disabled then skip further processing.
	if config.Disable {
		l.Debugf("Skipping generating file at %s because it is disabled", config.Path)

		if targetFileExists {
			if shouldRemove, err := shouldRemoveWithFileExists(l, targetPath, config.IfDisabled); err != nil {
				return err
			} else if shouldRemove {
				if err := os.Remove(targetPath); err != nil {
					return err
				}
			}
		}

		return nil
	}

	if targetFileExists {
		shouldContinue, err := shouldContinueWithFileExists(l, targetPath, config.IfExists)
		if err != nil || !shouldContinue {
			return err
		}
	}

	// Add the signature as a prefix to the file, unless it is disabled.
	prefix := ""
	if !config.DisableSignature {
		prefix = fmt.Sprintf("%s%s\n", config.CommentPrefix, TerragruntGeneratedSignature)
	}

	fmtGeneratedCode := false

	if config.HclFmt == nil {
		var fmtExt = map[string]struct{}{
			".hcl":  {},
			".tf":   {},
			".tofu": {},
		}

		ext := filepath.Ext(config.Path)
		if _, ok := fmtExt[ext]; ok {
			fmtGeneratedCode = true
		}
	} else {
		fmtGeneratedCode = *config.HclFmt
	}

	contentsToWrite := fmt.Appendf(nil, "%s%s", prefix, config.Contents)
	if fmtGeneratedCode {
		contentsToWrite = hclwrite.Format(contentsToWrite)
	}

	// CAS may materialize the target as a read-only hard link, so remove it before writing
	if targetFileExists && util.IsFile(targetPath) {
		if err := os.Remove(targetPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
			return err
		}
	}

	const ownerWriteGlobalReadPerms = 0644
	if err := os.WriteFile(targetPath, contentsToWrite, ownerWriteGlobalReadPerms); err != nil {
		return err
	}

	l.Debugf("Generated file %s.", targetPath)

	return nil
}

// Whether or not file generation should continue if the file path already exists. The answer depends on the
// ifExists configuration.
func shouldContinueWithFileExists(l log.Logger, path string, ifExists GenerateConfigExists) (bool, error) {
	// TODO: Make exhaustive
	switch ifExists { //nolint:exhaustive
	case ExistsError:
		return false, GenerateFileExistsError{path: path}
	case ExistsSkip:
		// Do nothing since file exists and skip was configured
		l.Debugf("The file path %s already exists and if_exists for code generation"+
			" set to \"skip\". Will not regenerate file.", path)

		return false, nil
	case ExistsOverwrite:
		// We will continue to proceed to generate file, but log a message to indicate that we detected the file
		// exists.
		l.Debugf("The file path %s already exists and if_exists for code generation"+
			" set to \"overwrite\". Regenerating file.", path)

		return true, nil
	case ExistsOverwriteTerragrunt:
		// If file was not generated, error out because overwrite_terragrunt if_exists setting only handles if the
		// existing file was generated by terragrunt.
		wasGenerated, err := fileWasGeneratedByTerragrunt(path)
		if err != nil {
			return false, err
		}

		if !wasGenerated {
			l.Errorf("ERROR: The file path %s already exists and was not generated by terragrunt.", path)

			return false, GenerateFileExistsError{path: path}
		}

		// Since file was generated by terragrunt, continue.
		l.Debugf("The file path %s already exists, but was a previously generated"+
			" file by terragrunt. Since if_exists for code generation is set to"+
			" \"overwrite_terragrunt\", regenerating file.", path)

		return true, nil
	default:
		// This shouldn't happen, but we add this case anyway for defensive coding.
		return false, UnknownGenerateIfExistsVal{""}
	}
}

// shouldRemoveWithFileExists returns true if the already existing file should be removed.
func shouldRemoveWithFileExists(l log.Logger, path string, ifDisable GenerateConfigDisabled) (bool, error) {
	// TODO: Make exhaustive
	switch ifDisable { //nolint:exhaustive
	case DisabledSkip:
		// Do nothing since skip was configured.
		l.Debugf("The file path %s already exists and if_disabled for code"+
			" generation set to \"skip\", will not remove file.", path)

		return false, nil
	case DisabledRemove:
		// The file exists and will be removed.
		l.Debugf("The file path %s already exists and if_disabled for code"+
			" generation set to \"remove\", removing file.", path)

		return true, nil
	case DisabledRemoveTerragrunt:
		// If file was not generated, error out because remove_terragrunt
		// if_disabled setting only handles if the existing file was
		// generated by terragrunt.
		wasGenerated, err := fileWasGeneratedByTerragrunt(path)
		if err != nil {
			return false, err
		}

		if !wasGenerated {
			l.Errorf("ERROR: The file path %s already exists and was not generated by terragrunt.", path)

			return false, GenerateFileRemoveError{path: path}
		}

		// Since file was generated by terragrunt, removing.
		l.Debugf("The file path %s already exists, but was a previously generated"+
			" file by terragrunt. Since if_disabled for code generation is set"+
			" to \"remove_terragrunt\", removing file.", path)

		return true, nil
	default:
		// This shouldn't happen, but we add this case anyway for defensive coding.
		return false, UnknownGenerateIfDisabledVal{""}
	}
}

// Check if the file was generated by terragrunt by checking if the first line of the file has the signature. Since the
// generated string will be prefixed with the configured comment prefix, the check needs to see if the first line ends
// with the signature string.
func fileWasGeneratedByTerragrunt(path string) (bool, error) {
	file, err := os.Open(path)
	if err != nil {
		return false, err
	}
	defer file.Close()

	reader := bufio.NewReader(file)

	firstLine, err := reader.ReadString('\n')
	if err != nil {
		return false, err
	}

	return strings.HasSuffix(strings.TrimSpace(firstLine), TerragruntGeneratedSignature), nil
}

const (
	terraformBlock = "terraform"
	backendBlock   = "backend"
)

// RemoteStateConfigToTerraformCode converts the arbitrary map that
// represents a remote state config into HCL code to configure that
// remote state.
func RemoteStateConfigToTerraformCode(
	backend string,
	config map[string]any,
	encryption map[string]any,
) ([]byte, error) {
	f := hclwrite.NewEmptyFile()
	terraformBlock := f.Body().AppendNewBlock(terraformBlock, nil).Body()
	backendBlock := terraformBlock.AppendNewBlock(backendBlock, []string{backend})
	backendBlockBody := backendBlock.Body()

	var backendKeys = make([]string, 0, len(config))

	for key := range config {
		backendKeys = append(backendKeys, key)
	}

	sort.Strings(backendKeys)

	for _, key := range backendKeys {
		// Since we don't have the cty type information for the config and since config can be arbitrary, we cheat by using
		// json as an intermediate representation.
		//
		// handle assume role config key in a different way since it is a single line HCL object
		if key == assumeRoleConfigKey {
			assumeRoleValue, isAssumeRole := config[assumeRoleConfigKey].(string)
			if !isAssumeRole {
				continue
			}
			// Extracting the values requires two steps.
			// Parsing into a struct first, enabling hclsimple.Decode() to deal with complex types.
			// Then copying values into the assumeRoleMap for rendering to HCL.
			assumeRoleMap := make(map[string]any)

			type assumeRoleConfig struct {
				RoleArn           string            `hcl:"role_arn"`
				Duration          string            `hcl:"duration,optional"`
				ExternalID        string            `hcl:"external_id,optional"`
				Policy            string            `hcl:"policy,optional"`
				PolicyArns        []string          `hcl:"policy_arns,optional"`
				SessionName       string            `hcl:"session_name,optional"`
				SourceIdentity    string            `hcl:"source_identity,optional"`
				Tags              map[string]string `hcl:"tags,optional"`
				TransitiveTagKeys []string          `hcl:"transitive_tag_keys,optional"`
			}

			var parsedConfig assumeRoleConfig
			// split single line hcl to default multiline file
			hclValue := strings.TrimSuffix(assumeRoleValue, "}")
			hclValue = strings.TrimPrefix(hclValue, "{")
			hclValue = ReplaceAllCommasOutsideQuotesWithNewLines(hclValue)

			err := hclsimple.Decode("s3_assume_role.hcl", []byte(hclValue), nil, &parsedConfig)
			if err != nil {
				return nil, err
			}

			// Copy filled values to the map, could be made shorter but keeping it simple for now
			if parsedConfig.RoleArn != "" {
				assumeRoleMap["role_arn"] = parsedConfig.RoleArn
			}

			if parsedConfig.Duration != "" {
				assumeRoleMap["duration"] = parsedConfig.Duration
			}

			if parsedConfig.ExternalID != "" {
				assumeRoleMap["external_id"] = parsedConfig.ExternalID
			}

			if parsedConfig.Policy != "" {
				assumeRoleMap["policy"] = parsedConfig.Policy
			}

			if len(parsedConfig.PolicyArns) > 0 {
				assumeRoleMap["policy_arns"] = parsedConfig.PolicyArns
			}

			if parsedConfig.SessionName != "" {
				assumeRoleMap["session_name"] = parsedConfig.SessionName
			}

			if parsedConfig.SourceIdentity != "" {
				assumeRoleMap["source_identity"] = parsedConfig.SourceIdentity
			}

			if len(parsedConfig.Tags) > 0 {
				assumeRoleMap["tags"] = parsedConfig.Tags
			}

			if len(parsedConfig.TransitiveTagKeys) > 0 {
				assumeRoleMap["transitive_tag_keys"] = parsedConfig.TransitiveTagKeys
			}

			// write assume role map as HCL object
			ctyVal, err := convertValue(assumeRoleMap)
			if err != nil {
				return nil, err
			}

			backendBlockBody.SetAttributeValue(key, ctyVal.Value)

			continue
		}

		if key == assumeRoleWithWebIdentityConfigKey {
			assumeRoleWithWebIdentityValue, isAssumeRoleWithWebIdentity := config[assumeRoleWithWebIdentityConfigKey].(string)
			if !isAssumeRoleWithWebIdentity {
				continue
			}

			// Extracting the values requires two steps.
			// Parsing into a struct first, enabling hclsimple.Decode() to deal with complex types.
			// Then copying values into the assumeRoleMap for rendering to HCL.
			assumeRoleMap := make(map[string]any)

			type assumeRoleWithWebIdentityConfig struct {
				RoleArn              string   `hcl:"role_arn"`
				Duration             string   `hcl:"duration,optional"`
				Policy               string   `hcl:"policy,optional"`
				SessionName          string   `hcl:"session_name,optional"`
				WebIdentityToken     string   `hcl:"web_identity_token,optional"`
				WebIdentityTokenFile string   `hcl:"web_identity_token_file,optional"`
				PolicyArns           []string `hcl:"policy_arns,optional"`
			}

			var parsedConfig assumeRoleWithWebIdentityConfig
			// split single line hcl to default multiline file
			hclValue := strings.TrimSuffix(assumeRoleWithWebIdentityValue, "}")
			hclValue = strings.TrimPrefix(hclValue, "{")
			hclValue = ReplaceAllCommasOutsideQuotesWithNewLines(hclValue)

			err := hclsimple.Decode("s3_assume_role_with_web_identity.hcl", []byte(hclValue), nil, &parsedConfig)
			if err != nil {
				return nil, err
			}

			if parsedConfig.RoleArn != "" {
				assumeRoleMap["role_arn"] = parsedConfig.RoleArn
			}

			if parsedConfig.Duration != "" {
				assumeRoleMap["duration"] = parsedConfig.Duration
			}

			if parsedConfig.Policy != "" {
				assumeRoleMap["policy"] = parsedConfig.Policy
			}

			if len(parsedConfig.PolicyArns) > 0 {
				assumeRoleMap["policy_arns"] = parsedConfig.PolicyArns
			}

			if parsedConfig.SessionName != "" {
				assumeRoleMap["session_name"] = parsedConfig.SessionName
			}

			if parsedConfig.WebIdentityToken != "" {
				assumeRoleMap["web_identity_token"] = parsedConfig.WebIdentityToken
			}

			if parsedConfig.WebIdentityTokenFile != "" {
				assumeRoleMap["web_identity_token_file"] = parsedConfig.WebIdentityTokenFile
			}

			// write assume role map as HCL object
			ctyVal, err := convertValue(assumeRoleMap)
			if err != nil {
				return nil, err
			}

			backendBlockBody.SetAttributeValue(key, ctyVal.Value)

			continue
		}

		ctyVal, err := convertValue(config[key])
		if err != nil {
			return nil, err
		}

		backendBlockBody.SetAttributeValue(key, ctyVal.Value)
	}

	// encryption can be empty
	if len(encryption) == 0 {
		return f.Bytes(), nil
	}

	// extract key_provider first to create key_provider block
	keyProvider, found := encryption[EncryptionKeyProviderKey].(string)
	if !found {
		return nil, errors.New(EncryptionKeyProviderKey + " is mandatory but not found in the encryption map")
	}

	keyProviderTraversal := hcl.Traversal{
		hcl.TraverseRoot{Name: EncryptionKeyProviderKey},
		hcl.TraverseAttr{Name: keyProvider},
		hcl.TraverseAttr{Name: encryptionResourceName},
	}

	methodTraversal := hcl.Traversal{
		hcl.TraverseRoot{Name: encryptionMethodKey},
		hcl.TraverseAttr{Name: encryptionDefaultMethod},
		hcl.TraverseAttr{Name: encryptionResourceName},
	}

	// encryption block
	encryptionBlock := terraformBlock.AppendNewBlock(encryptionBlockName, nil)
	encryptionBlockBody := encryptionBlock.Body()

	// Append key_provider block
	keyProviderBlock := encryptionBlockBody.AppendNewBlock(
		EncryptionKeyProviderKey,
		[]string{keyProvider, encryptionResourceName},
	)
	keyProviderBlockBody := keyProviderBlock.Body()

	// Append method block
	methodBlock := encryptionBlockBody.AppendNewBlock(
		encryptionMethodKey,
		[]string{encryptionDefaultMethod, encryptionResourceName},
	).Body()
	methodBlock.SetAttributeTraversal(encryptionKeysAttributeName, keyProviderTraversal)

	// Append state block
	stateBlock := encryptionBlockBody.AppendNewBlock(encryptionStateBlockName, nil).Body()
	stateBlock.SetAttributeTraversal(encryptionMethodKey, methodTraversal)

	// Append plan block
	planBlock := encryptionBlockBody.AppendNewBlock(encryptionPlanBlockName, nil).Body()
	planBlock.SetAttributeTraversal(encryptionMethodKey, methodTraversal)

	var encryptionKeys = make([]string, 0, len(encryption))

	for key := range encryption {
		encryptionKeys = append(encryptionKeys, key)
	}

	sort.Strings(encryptionKeys)

	// Fill key_provider block with ordered attributes
	for _, key := range encryptionKeys {
		if key == EncryptionKeyProviderKey {
			continue
		}

		value, ok := encryption[key]
		if !ok {
			continue
		}

		// Skip basic types with zero values
		if value == "" || value == 0 {
			continue
		}

		ctyVal, err := convertValue(value)
		if err != nil {
			return nil, err
		}

		if keyProviderBlockBody != nil {
			keyProviderBlockBody.SetAttributeValue(key, ctyVal.Value)
		}
	}

	return f.Bytes(), nil
}

func convertValue(v any) (ctyjson.SimpleJSONValue, error) {
	jsonBytes, err := json.Marshal(v)
	if err != nil {
		return ctyjson.SimpleJSONValue{}, err
	}

	var ctyVal ctyjson.SimpleJSONValue
	if err := ctyVal.UnmarshalJSON(jsonBytes); err != nil {
		return ctyjson.SimpleJSONValue{}, err
	}

	return ctyVal, nil
}

// ReplaceAllCommasOutsideQuotesWithNewLines replaces top-level argument-separating commas with new
// lines. Commas inside quoted strings or nested delimiters (lists, objects, and function-call parens)
// are left intact, so only commas that separate arguments at depth zero are converted.
func ReplaceAllCommasOutsideQuotesWithNewLines(s string) string {
	var result strings.Builder

	inQuotes := false
	depth := 0

	for i := 0; i < len(s); i++ {
		ch := s[i]

		switch {
		case ch == '\\' && inQuotes:
			result.WriteByte(ch)

			i++
			if i < len(s) {
				result.WriteByte(s[i])
			}
		case ch == '"':
			inQuotes = !inQuotes

			result.WriteByte(ch)
		case (ch == '[' || ch == '{' || ch == '(') && !inQuotes:
			depth++

			result.WriteByte(ch)
		case (ch == ']' || ch == '}' || ch == ')') && !inQuotes:
			depth--

			result.WriteByte(ch)
		case ch == ',' && !inQuotes && depth == 0:
			result.WriteByte('\n')

		default:
			result.WriteByte(ch)
		}
	}

	return result.String()
}

// GenerateConfigExistsFromString converts a string representation of if_exists into the enum, returning an error if it
// is not set to one of the known values.
func GenerateConfigExistsFromString(val string) (GenerateConfigExists, error) {
	switch val {
	case ExistsErrorStr:
		return ExistsError, nil
	case ExistsSkipStr:
		return ExistsSkip, nil
	case ExistsOverwriteStr:
		return ExistsOverwrite, nil
	case ExistsOverwriteTerragruntStr:
		return ExistsOverwriteTerragrunt, nil
	}

	return ExistsUnknown, UnknownGenerateIfExistsVal{val: val}
}

// GenerateConfigDisabledFromString converts a string representation of
// if_disabled into the enum, returning an error if it is not set to one
// of the known values.
func GenerateConfigDisabledFromString(val string) (GenerateConfigDisabled, error) {
	switch val {
	case DisabledSkipStr:
		return DisabledSkip, nil
	case DisabledRemoveStr:
		return DisabledRemove, nil
	case DisabledRemoveTerragruntStr:
		return DisabledRemoveTerragrunt, nil
	}

	return DisabledUnknown, UnknownGenerateIfDisabledVal{val: val}
}
