package config_test

import (
	"reflect"
	"sort"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/zclconf/go-cty/cty"

	"github.com/gruntwork-io/terragrunt/internal/codegen"
	"github.com/gruntwork-io/terragrunt/internal/ctyhelper"
	"github.com/gruntwork-io/terragrunt/internal/remotestate"
	"github.com/gruntwork-io/terragrunt/pkg/config"
	"github.com/gruntwork-io/terragrunt/test/helpers/logger"
)

// This test makes sure that all the fields from the TerragruntConfig struct are accounted for in the conversion to
// cty.Value.
func TestTerragruntConfigAsCtyDrift(t *testing.T) {
	t.Parallel()

	testSource := "./foo"
	testTrue := true
	testFalse := false
	mockOutputs := cty.Zero
	mockOutputsAllowedTerraformCommands := []string{"init"}
	metaVal := cty.MapVal(map[string]cty.Value{
		"foo": cty.StringVal("bar"),
	})
	testConfig := config.TerragruntConfig{
		Engine: &config.EngineConfig{
			Source: "github.com/acme/terragrunt-plugin-custom-opentofu",
			Meta:   &metaVal,
		},
		Catalog: &config.CatalogConfig{
			URLs: []string{
				"repo/path",
			},
		},
		Terraform: &config.TerraformConfig{
			Source: &testSource,
			ExtraArgs: []config.TerraformExtraArguments{
				{
					Name:     "init",
					Commands: []string{"init"},
				},
			},
			BeforeHooks: []config.Hook{
				{
					Name:     "init",
					Commands: []string{"init"},
					Execute:  []string{"true"},
				},
			},
			AfterHooks: []config.Hook{
				{
					Name:     "init",
					Commands: []string{"init"},
					Execute:  []string{"true"},
				},
			},
			ErrorHooks: []config.ErrorHook{
				{
					Name:     "init",
					Commands: []string{"init"},
					Execute:  []string{"true"},
					OnErrors: []string{".*"},
				},
			},
		},
		TerraformBinary:             "terraform",
		TerraformVersionConstraint:  "= 0.12.20",
		TerragruntVersionConstraint: "= 0.23.18",
		RemoteState: remotestate.New(&remotestate.Config{
			BackendName:                   "foo",
			DisableInit:                   true,
			DisableDependencyOptimization: true,
			BackendConfig: map[string]any{
				"bar": "baz",
			},
		}),
		Dependencies: &config.ModuleDependencies{
			Paths: []string{"foo"},
		},
		DownloadDir:    ".terragrunt-cache",
		PreventDestroy: &testTrue,
		IamRole:        "terragruntRole",
		Inputs: map[string]any{
			"aws_region": "us-east-1",
		},
		Locals: map[string]any{
			"quote": "the answer is 42",
		},
		TerragruntDependencies: config.Dependencies{
			config.Dependency{
				Name:                                "foo",
				ConfigPath:                          cty.StringVal("foo"),
				SkipOutputs:                         &testTrue,
				MockOutputs:                         &mockOutputs,
				MockOutputsAllowedTerraformCommands: &mockOutputsAllowedTerraformCommands,
				MockOutputsMergeWithState:           &testFalse,
				RenderedOutputs:                     &mockOutputs,
			},
		},
		FeatureFlags: config.FeatureFlags{
			&config.FeatureFlag{
				Name:    "test",
				Default: &cty.Zero,
			},
		},
		Errors: &config.ErrorsConfig{
			Retry: []*config.RetryBlock{
				{
					Label:            "test",
					RetryableErrors:  []string{"test"},
					MaxAttempts:      0,
					SleepIntervalSec: 0,
				},
			},
			Ignore: []*config.IgnoreBlock{
				{
					Label:           "test",
					IgnorableErrors: nil,
					Message:         "",
					Signals:         nil,
				},
			},
		},
		GenerateConfigs: map[string]codegen.GenerateConfig{
			"provider": {
				Path:          "foo",
				IfExists:      codegen.ExistsOverwriteTerragrunt,
				IfExistsStr:   "overwrite_terragrunt",
				CommentPrefix: "# ",
				Contents: `terraform {
  backend "s3" {}
}`,
			},
		},
		Exclude: &config.ExcludeConfig{},
	}
	ctyVal, err := config.TerragruntConfigAsCty(&testConfig)
	require.NoError(t, err)

	ctyMap, err := ctyhelper.ParseCtyValueToMap(ctyVal)
	require.NoError(t, err)

	// Test the root properties
	testConfigFields := structFieldNames(testConfig)
	checked := map[string]bool{} // used to track which fields of the ctyMap were seen

	for _, field := range testConfigFields {
		mapKey, isConverted := terragruntConfigStructFieldToMapKey(t, field)
		if isConverted {
			_, hasKey := ctyMap[mapKey]
			assert.Truef(t, hasKey, "Struct field %s (convert of map key %s) did not convert to cty val", field, mapKey)
			checked[mapKey] = true
		}
	}

	for key := range ctyMap {
		_, hasKey := checked[key]
		assert.Truef(t, hasKey, "cty value key %s is not accounted for from struct field", key)
	}
}

// TestTerragruntConfigAsCtyMixedIgnoreSignals pins the fix for a panic
// reachable from `terragrunt render` when two errors.ignore blocks have
// differently-shaped signals maps. Before the fix, cty.ListVal panicked
// on "inconsistent list element types" because gocty inferred
// cty.Map(cty.String) for the populated block and
// cty.Map(cty.DynamicPseudoType) for the empty one.
func TestTerragruntConfigAsCtyMixedIgnoreSignals(t *testing.T) {
	t.Parallel()

	testConfig := config.TerragruntConfig{
		Errors: &config.ErrorsConfig{
			Ignore: []*config.IgnoreBlock{
				{
					Label:           "with-signals",
					IgnorableErrors: []string{".*timeout.*"},
					Message:         "ignored",
					Signals: map[string]cty.Value{
						"foo": cty.StringVal("bar"),
					},
				},
				{
					Label:           "without-signals",
					IgnorableErrors: []string{".*5xx.*"},
					Message:         "also ignored",
				},
			},
		},
	}

	ctyVal, err := config.TerragruntConfigAsCty(&testConfig)
	require.NoError(t, err)

	ctyMap, err := ctyhelper.ParseCtyValueToMap(ctyVal)
	require.NoError(t, err)

	errorsMap := ctyMap["errors"].(map[string]any)
	ignore := errorsMap["ignore"].([]any)
	require.Len(t, ignore, 2)

	first := ignore[0].(map[string]any)
	assert.Equal(t, "with-signals", first["name"])
	assert.Equal(t, "ignored", first["message"])
	assert.Equal(t, map[string]any{"foo": "bar"}, first["signals"])

	second := ignore[1].(map[string]any)
	assert.Equal(t, "without-signals", second["name"])
	assert.Equal(t, "also ignored", second["message"])
}

// This test makes sure that all the fields in RemoteState are converted to cty
func TestRemoteStateAsCtyDrift(t *testing.T) {
	t.Parallel()

	testConfig := remotestate.Config{
		BackendName:                   "foo",
		DisableInit:                   true,
		DisableDependencyOptimization: true,
		Generate: &remotestate.ConfigGenerate{
			Path:     "foo",
			IfExists: "overwrite_terragrunt",
		},
		BackendConfig: map[string]any{
			"bar": "baz",
		},
		Encryption: map[string]any{
			"bar": "baz",
		},
	}

	ctyVal, err := config.RemoteStateAsCty(remotestate.New(&testConfig))
	require.NoError(t, err)

	ctyMap, err := ctyhelper.ParseCtyValueToMap(ctyVal)
	require.NoError(t, err)

	// Test the root properties
	testConfigFields := structFieldNames(testConfig)
	checked := map[string]bool{} // used to track which fields of the ctyMap were seen

	for _, field := range testConfigFields {
		mapKey, isConverted := remoteStateStructFieldToMapKey(t, field)
		if isConverted {
			_, hasKey := ctyMap[mapKey]
			assert.Truef(t, hasKey, "Struct field %s (convert of map key %s) did not convert to cty val", field, mapKey)
			checked[mapKey] = true
		}
	}

	for key := range ctyMap {
		_, hasKey := checked[key]
		assert.Truef(t, hasKey, "cty value key %s is not accounted for from struct field", key)
	}
}

// This test makes sure that all the fields in TerraformConfig exist in ctyTerraformConfig.
func TestTerraformConfigAsCtyDrift(t *testing.T) {
	t.Parallel()

	// Fields that are intentionally excluded from CtyTerraformConfig because
	// they use omit-when-nil semantics via ctyObjectAddField.
	omitWhenNilFields := map[string]bool{
		"UpdateSourceWithCAS": true,
		"Mutable":             true,
	}

	var terraformConfigFields []string

	for _, name := range structFieldNames(config.TerraformConfig{}) {
		if omitWhenNilFields[name] {
			continue
		}

		terraformConfigFields = append(terraformConfigFields, name)
	}

	sort.Strings(terraformConfigFields)

	ctyTerraformConfigFields := structFieldNames(config.CtyTerraformConfig{})
	sort.Strings(ctyTerraformConfigFields)
	assert.Equal(t, terraformConfigFields, ctyTerraformConfigFields)
}

func TestStackUnitCtyReading(t *testing.T) {
	t.Parallel()

	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath)
	tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/stacks/basic/live/terragrunt.stack.hcl", nil)
	require.NoError(t, err)
	stackMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty)
	require.NoError(t, err)
	assert.NotNil(t, stackMap)
	// validate parsed unit
	unit := stackMap["unit"].(map[string]any)
	assert.NotNil(t, unit)
	assert.NotNil(t, unit["mother"])
	assert.NotNil(t, unit["father"])
	assert.NotNil(t, unit["chick_1"])
	assert.NotNil(t, unit["chick_2"])
}

func TestStackLocalsCtyReading(t *testing.T) {
	t.Parallel()

	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath)
	tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/stacks/locals/live/terragrunt.stack.hcl", nil)
	require.NoError(t, err)
	stackMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty)
	require.NoError(t, err)
	assert.NotNil(t, stackMap)
	locals := stackMap["local"].(map[string]any)
	assert.NotNil(t, locals)
}

func terragruntConfigStructFieldToMapKey(t *testing.T, fieldName string) (string, bool) {
	t.Helper()

	switch fieldName {
	case "Catalog":
		return "catalog", true
	case "Terraform":
		return "terraform", true
	case "TerraformBinary":
		return "terraform_binary", true
	case "TerraformVersionConstraint":
		return "terraform_version_constraint", true
	case "TerragruntVersionConstraint":
		return "terragrunt_version_constraint", true
	case "RemoteState":
		return "remote_state", true
	case "Dependencies":
		return "dependencies", true
	case "DownloadDir":
		return "download_dir", true
	case "PreventDestroy":
		return "prevent_destroy", true
	case "IamRole":
		return "iam_role", true
	case "IamAssumeRoleDuration":
		return "iam_assume_role_duration", true
	case "IamAssumeRoleSessionName":
		return "iam_assume_role_session_name", true
	case "IamWebIdentityToken":
		return "iam_web_identity_token", true
	case "Inputs":
		return "inputs", true
	case "Locals":
		return "locals", true
	case "TerragruntDependencies":
		return "dependency", true
	case "GenerateConfigs":
		return "generate", true
	case "IsPartial":
		return "", false
	case "ProcessedIncludes":
		return "", false
	case "FieldsMetadata":
		return "", false
	case "Engine":
		return "engine", true
	case "FeatureFlags":
		return "feature", true
	case "Exclude":
		return "exclude", true
	case "Errors":
		return "errors", true
	default:
		t.Fatalf("Unknown struct property: %s", fieldName)
		// This should not execute
		return "", false
	}
}

func remoteStateStructFieldToMapKey(t *testing.T, fieldName string) (string, bool) {
	t.Helper()

	switch fieldName {
	case "BackendName":
		return "backend", true
	case "DisableInit":
		return "disable_init", true
	case "DisableDependencyOptimization":
		return "disable_dependency_optimization", true
	case "Generate":
		return "generate", true
	case "BackendConfig":
		return "config", true
	case "Encryption":
		return "encryption", true
	default:
		t.Fatalf("Unknown struct property: %s", fieldName)
		// This should not execute
		return "", false
	}
}

// structFieldNames returns the exported field names declared on v's struct
// type. v may be a struct value or a pointer to one. Embedded fields appear
// under the embedded type's name, matching fatih/structs' behavior.
func structFieldNames(v any) []string {
	t := reflect.TypeOf(v)
	if t.Kind() == reflect.Pointer {
		t = t.Elem()
	}

	names := make([]string, 0, t.NumField())

	for f := range t.Fields() {
		if f.IsExported() {
			names = append(names, f.Name)
		}
	}

	return names
}
