package config_test

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"

	"github.com/gruntwork-io/terragrunt/internal/ctyhelper"
	"github.com/gruntwork-io/terragrunt/internal/engine"
	"github.com/gruntwork-io/terragrunt/internal/experiment"
	"github.com/gruntwork-io/terragrunt/internal/iacargs"
	"github.com/gruntwork-io/terragrunt/internal/strict/controls"
	"github.com/gruntwork-io/terragrunt/internal/telemetry"
	"github.com/gruntwork-io/terragrunt/internal/tfimpl"
	"github.com/gruntwork-io/terragrunt/internal/util"
	"github.com/gruntwork-io/terragrunt/internal/vexec"
	"github.com/gruntwork-io/terragrunt/pkg/config"
	"github.com/gruntwork-io/terragrunt/test/helpers"
	"github.com/gruntwork-io/terragrunt/test/helpers/logger"
	"github.com/puzpuzpuz/xsync/v4"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/zclconf/go-cty/cty"
)

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

	testCases := []struct {
		include      map[string]config.IncludeConfig
		configPath   string
		expectedPath string
		params       []string
	}{
		{
			configPath:   filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath),
			expectedPath: ".",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("..", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath),
			expectedPath: "child",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath),
			expectedPath: "child",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("../../..", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "child/sub-child/sub-sub-child",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "child/sub-child/sub-sub-child",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "../child/sub-child",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join("..", "child", "sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "child/sub-child",
		},
		{
			include: map[string]config.IncludeConfig{
				"root":  {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)},
				"child": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)},
			},
			params:       []string{"child"},
			configPath:   filepath.Join("..", "child", "sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "../child/sub-child",
		},
	}

	for _, tc := range testCases {
		trackInclude := getTrackIncludeFromTestData(tc.include, tc.params, tc.configPath)
		l := logger.CreateLogger()
		ctx, pctx := newTestParsingContext(t, tc.configPath)
		pctx = pctx.WithTrackInclude(trackInclude)
		actualPath, actualErr := config.PathRelativeToInclude(ctx, pctx, l, tc.params)
		require.NoError(t, actualErr, "For include %v and configPath %v, unexpected error: %v", tc.include, tc.configPath, actualErr)
		assert.Equal(t, tc.expectedPath, actualPath, "For include %v and configPath %v", tc.include, tc.configPath)
	}
}

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

	testCases := []struct {
		include      map[string]config.IncludeConfig
		configPath   string
		expectedPath string
		params       []string
	}{
		{
			configPath:   filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath),
			expectedPath: ".",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("..", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath),
			expectedPath: "..",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath),
			expectedPath: "..",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("../../..", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "../../..",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "../../..",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "../../other-child",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join("..", "child", "sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "../..",
		},
		{
			include: map[string]config.IncludeConfig{
				"root":  {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)},
				"child": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)},
			},
			params:       []string{"child"},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "../../other-child",
		},
	}

	for _, tc := range testCases {
		trackInclude := getTrackIncludeFromTestData(tc.include, tc.params, tc.configPath)
		l := logger.CreateLogger()
		ctx, pctx := newTestParsingContext(t, tc.configPath)
		pctx = pctx.WithTrackInclude(trackInclude)
		actualPath, actualErr := config.PathRelativeFromInclude(ctx, pctx, l, tc.params)
		require.NoError(t, actualErr, "For include %v and configPath %v, unexpected error: %v", tc.include, tc.configPath, actualErr)
		assert.Equal(t, tc.expectedPath, actualPath, "For include %v and configPath %v", tc.include, tc.configPath)
	}
}

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

	if runtime.GOOS == "windows" {
		t.Skip("Skipping test on Windows because it doesn't support bash")
	}

	homeDir := os.Getenv("HOME")

	testCases := []struct {
		expectErr      func(t *testing.T, err error)
		configPath     string
		expectedOutput string
		params         []string
	}{
		{
			params:         []string{"/bin/bash", "-c", "echo -n foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:         []string{"/bin/bash", "-c", "echo foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:         []string{"--terragrunt-quiet", "/bin/bash", "-c", "echo -n foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:         []string{"--terragrunt-quiet", "/bin/bash", "-c", "echo foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:         []string{"--terragrunt-global-cache", "/bin/bash", "-c", "echo foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:         []string{"--terragrunt-global-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:         []string{"--terragrunt-quiet", "--terragrunt-global-cache", "/bin/bash", "-c", "echo foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:         []string{"--terragrunt-no-cache", "/bin/bash", "-c", "echo foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:         []string{"--terragrunt-no-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:         []string{"--terragrunt-quiet", "--terragrunt-no-cache", "/bin/bash", "-c", "echo foo"},
			configPath:     homeDir,
			expectedOutput: "foo",
		},
		{
			params:     []string{"--terragrunt-no-cache", "--terragrunt-global-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"},
			configPath: homeDir,
			expectErr: func(t *testing.T, err error) {
				t.Helper()
				require.ErrorAs(t, err, new(config.ConflictingRunCmdCacheOptionsError))
			},
		},
		{
			params:     []string{"--terragrunt-global-cache", "--terragrunt-no-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"},
			configPath: homeDir,
			expectErr: func(t *testing.T, err error) {
				t.Helper()
				require.ErrorAs(t, err, new(config.ConflictingRunCmdCacheOptionsError))
			},
		},
		{
			configPath: homeDir,
			expectErr: func(t *testing.T, err error) {
				t.Helper()
				require.ErrorAs(t, err, new(config.EmptyStringNotAllowedError))
			},
		},
	}
	for _, tc := range testCases {
		t.Run(tc.configPath, func(t *testing.T) {
			t.Parallel()

			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, tc.configPath)

			actualOutput, actualErr := config.RunCommand(ctx, pctx, l, vexec.NewOSExec(), tc.params)
			if tc.expectErr != nil {
				require.Error(t, actualErr)
				tc.expectErr(t, actualErr)

				return
			}

			require.NoError(t, actualErr)
			assert.Equal(t, tc.expectedOutput, actualOutput)
		})
	}
}

func absPath(t *testing.T, path string) string {
	t.Helper()

	if filepath.IsAbs(path) {
		return filepath.Clean(path)
	}

	absPath, err := filepath.Abs(path)
	require.NoError(t, err)

	return filepath.Clean(absPath)
}

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

	expectParentFileNotFound := func(t *testing.T, err error) {
		t.Helper()
		require.ErrorAs(t, err, new(config.ParentFileNotFoundError))
	}

	testCases := []struct {
		expectErr         func(t *testing.T, err error)
		configPath        string
		name              string
		expectedPath      string
		params            []string
		maxFoldersToCheck int
	}{
		{
			name:         "simple-lookup",
			params:       []string{"root.hcl"},
			configPath:   absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", config.DefaultTerragruntConfigPath)),
			expectedPath: absPath(t, "../../test/fixtures/parent-folders/terragrunt-in-root/root.hcl"),
		},
		{
			name:         "nested-lookup",
			params:       []string{"root.hcl"},
			configPath:   absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath)),
			expectedPath: absPath(t, "../../test/fixtures/parent-folders/terragrunt-in-root/root.hcl"),
		},
		{
			name:              "lookup-with-max-folders",
			params:            []string{"root.hcl"},
			configPath:        absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "no-terragrunt-in-root", "child", "sub-child", config.DefaultTerragruntConfigPath)),
			maxFoldersToCheck: 3,
			expectErr:         expectParentFileNotFound,
		},
		{
			name:         "multiple-terragrunt-in-parents",
			params:       []string{"root.hcl"},
			configPath:   absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "multiple-terragrunt-in-parents", "child", config.DefaultTerragruntConfigPath)),
			expectedPath: absPath(t, "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/root.hcl"),
		},
		{
			name:         "multiple-terragrunt-in-parents-under-child",
			params:       []string{"root.hcl"},
			configPath:   absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "multiple-terragrunt-in-parents", "child", "sub-child", config.DefaultTerragruntConfigPath)),
			expectedPath: absPath(t, "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/child/root.hcl"),
		},
		{
			name:         "multiple-terragrunt-in-parents-under-sub-child",
			params:       []string{"root.hcl"},
			configPath:   absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "multiple-terragrunt-in-parents", "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath)),
			expectedPath: absPath(t, "../../test/fixtures/parent-folders/multiple-terragrunt-in-parents/child/sub-child/root.hcl"),
		},
		{
			name:         "parent-file-that-isnt-terragrunt",
			params:       []string{"foo.txt"},
			configPath:   absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "other-file-names", "child", config.DefaultTerragruntConfigPath)),
			expectedPath: absPath(t, "../../test/fixtures/parent-folders/other-file-names/foo.txt"),
		},
		{
			name:         "parent-file-that-isnt-terragrunt-in-another-subfolder",
			params:       []string{"common/foo.txt"},
			configPath:   absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "in-another-subfolder", "live", config.DefaultTerragruntConfigPath)),
			expectedPath: absPath(t, "../../test/fixtures/parent-folders/in-another-subfolder/common/foo.txt"),
		},
		{
			name:         "parent-file-that-isnt-terragrunt-in-another-subfolder-with-params",
			params:       []string{"tfwork"},
			configPath:   absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "with-params", "tfwork", "tg", config.DefaultTerragruntConfigPath)),
			expectedPath: absPath(t, "../../test/fixtures/parent-folders/with-params/tfwork"),
		},
		{
			name:       "not-found",
			configPath: "/",
			expectErr:  expectParentFileNotFound,
		},
		{
			name:       "not-found-with-path",
			configPath: "/fake/path",
			expectErr:  expectParentFileNotFound,
		},
		{
			name:         "fallback",
			params:       []string{"foo.txt", "fallback.txt"},
			configPath:   "/fake/path",
			expectedPath: "fallback.txt",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, tc.configPath)

			if tc.maxFoldersToCheck != 0 {
				pctx.MaxFoldersToCheck = tc.maxFoldersToCheck
			}

			actualPath, actualErr := config.FindInParentFolders(ctx, pctx, l, tc.params)
			if tc.expectErr != nil {
				require.Error(t, actualErr)
				tc.expectErr(t, actualErr)

				return
			}

			require.NoError(t, actualErr)
			assert.Equal(t, tc.expectedPath, actualPath)
		})
	}
}

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

	tempDir := helpers.TmpDirWOSymlinks(t)

	regionHclPath := filepath.Join(tempDir, "region.hcl")
	regionHclContent := `locals {
  aws_region = "us-east-1"
}`
	err := os.WriteFile(regionHclPath, []byte(regionHclContent), 0644)
	require.NoError(t, err)

	stackDir := filepath.Join(tempDir, "stack")
	err = os.MkdirAll(stackDir, 0755)
	require.NoError(t, err)

	stackHclPath := filepath.Join(stackDir, "terragrunt.stack.hcl")
	stackHclContent := `locals {
  regions_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))
  region       = local.regions_vars.locals.aws_region
}

unit "test" {
  source = "."
  path   = "test"
}`
	err = os.WriteFile(stackHclPath, []byte(stackHclContent), 0644)
	require.NoError(t, err)

	l := logger.CreateLogger()
	_, pctx := newTestParsingContext(t, stackHclPath)
	pctx.WorkingDir = tempDir

	stackConfig, err := config.ReadStackConfigFile(t.Context(), l, pctx, stackHclPath, nil)
	require.NoError(t, err)
	require.NotNil(t, stackConfig)

	region, exists := stackConfig.Locals["region"]
	require.True(t, exists, "Expected 'region' local to be parsed")
	require.Equal(t, "us-east-1", region)
}

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

	testCases := []struct {
		include           *config.IncludeConfig
		str               string
		configPath        string
		expectedOut       string
		expectedErr       string
		maxFoldersToCheck int
	}{
		{
			str:         "terraform { source = path_relative_to_include() }",
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedOut: ".",
		},
		{
			str:         "terraform { source = path_relative_to_include() }",
			include:     &config.IncludeConfig{Path: filepath.Join("..", config.DefaultTerragruntConfigPath)},
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedOut: "child",
		},
		{
			str:         "terraform { source = find_in_parent_folders(\"root.hcl\") }",
			configPath:  absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", "sub-child", config.DefaultTerragruntConfigPath)),
			expectedOut: absPath(t, "../../test/fixtures/parent-folders/terragrunt-in-root/root.hcl"),
		},
		{
			str:               "terraform { source = find_in_parent_folders(\"root.hcl\") }",
			configPath:        absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "terragrunt-in-root", "child", "sub-child", config.DefaultTerragruntConfigPath)),
			expectedErr:       "ParentFileNotFoundError",
			maxFoldersToCheck: 1,
		},
		{
			str:               "terraform { source = find_in_parent_folders(\"root.hcl\") }",
			configPath:        absPath(t, filepath.Join("../..", "test", "fixtures", "parent-folders", "no-terragrunt-in-root", "child", "sub-child", config.DefaultTerragruntConfigPath)),
			expectedErr:       "ParentFileNotFoundError",
			maxFoldersToCheck: 3,
		},
	}

	for _, tc := range testCases {
		// The following is necessary to make sure tc's values don't
		// get updated due to concurrency within the scope of t.Run(..) below
		t.Run(fmt.Sprintf("%s--%s", tc.str, tc.configPath), func(t *testing.T) {
			t.Parallel()

			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, tc.configPath)

			if tc.maxFoldersToCheck != 0 {
				pctx.MaxFoldersToCheck = tc.maxFoldersToCheck
			}

			actualOut, actualErr := config.ParseConfigString(ctx, pctx, l, "mock-path-for-test.hcl", tc.str, tc.include)
			if tc.expectedErr != "" {
				require.Error(t, actualErr)
				assert.Contains(t, actualErr.Error(), tc.expectedErr)
			} else {
				require.NoError(t, actualErr)
				assert.NotNil(t, actualOut)
				assert.NotNil(t, actualOut.Terraform)
				assert.NotNil(t, actualOut.Terraform.Source)
				assert.Equal(t, tc.expectedOut, *actualOut.Terraform.Source)
			}
		})
	}
}

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

	testCases := []struct {
		include     *config.IncludeConfig
		env         map[string]string
		str         string
		configPath  string
		expectedOut string
		expectedErr string
	}{
		{
			str:         `iam_role = "foo/${get_env()}/bar"`,
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedErr: "InvalidGetEnvParamsError",
		},
		{
			str:         `iam_role = "foo/${get_env("","")}/bar"`,
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedErr: "InvalidEnvParamNameError",
		},
		{
			str:         `iam_role = get_env()`,
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedErr: "InvalidGetEnvParamsError",
		},
		{
			str:         `iam_role = get_env("TEST_VAR_1", "TEST_VAR_2", "TEST_VAR_3")`,
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedErr: "InvalidGetEnvParamsError",
		},
		{
			str:         `iam_role = get_env("TEST_ENV_TERRAGRUNT_VAR")`,
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedOut: "SOMETHING",
			env:         map[string]string{"TEST_ENV_TERRAGRUNT_VAR": "SOMETHING"},
		},
		{
			str:         `iam_role = get_env("SOME_VAR", "SOME_VALUE")`,
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedOut: "SOME_VALUE",
		},
		{
			str:         `iam_role = "foo/${get_env("TEST_ENV_TERRAGRUNT_HIT","")}/bar"`,
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedOut: "foo//bar",
			env:         map[string]string{"TEST_ENV_TERRAGRUNT_OTHER": "SOMETHING"},
		},
		{
			str:         `iam_role = "foo/${get_env("TEST_ENV_TERRAGRUNT_HIT","DEFAULT")}/bar"`,
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedOut: "foo/DEFAULT/bar",
			env:         map[string]string{"TEST_ENV_TERRAGRUNT_OTHER": "SOMETHING"},
		},
		{
			str:         `iam_role = "foo/${get_env("TEST_ENV_TERRAGRUNT_VAR")}/bar"`,
			configPath:  filepath.Join("/root", "child", config.DefaultTerragruntConfigPath),
			expectedOut: "foo/SOMETHING/bar",
			env:         map[string]string{"TEST_ENV_TERRAGRUNT_VAR": "SOMETHING"},
		},
	}

	for _, tc := range testCases {
		// The following is necessary to make sure tc's values don't
		// get updated due to concurrency within the scope of t.Run(..) below
		t.Run(tc.str, func(t *testing.T) {
			t.Parallel()

			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, tc.configPath)

			if tc.env != nil {
				pctx.Env = tc.env
			}

			actualOut, actualErr := config.ParseConfigString(ctx, pctx, l, "mock-path-for-test.hcl", tc.str, tc.include)
			if tc.expectedErr != "" {
				require.Error(t, actualErr)
				assert.Contains(t, actualErr.Error(), tc.expectedErr)
			} else {
				require.NoError(t, actualErr)
				assert.Equal(t, tc.expectedOut, actualOut.IamRole)
			}
		})
	}
}

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

	testCases := []struct {
		include          *config.IncludeConfig
		str              string
		configPath       string
		expectedFooInput []string
	}{
		{
			str:              "inputs = { foo = get_terraform_commands_that_need_locking() }",
			configPath:       config.DefaultTerragruntConfigPath,
			expectedFooInput: config.TerraformCommandsNeedLocking,
		},
		{
			str:              `inputs = { foo = get_terraform_commands_that_need_vars() }`,
			configPath:       config.DefaultTerragruntConfigPath,
			expectedFooInput: config.TerraformCommandsNeedVars,
		},
		{
			str:              "inputs = { foo = get_terraform_commands_that_need_parallelism() }",
			configPath:       config.DefaultTerragruntConfigPath,
			expectedFooInput: config.TerraformCommandsNeedParallelism,
		},
	}

	for _, tc := range testCases {
		// The following is necessary to make sure tc's values don't
		// get updated due to concurrency within the scope of t.Run(..) below
		t.Run(tc.str, func(t *testing.T) {
			t.Parallel()

			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, tc.configPath)
			actualOut, actualErr := config.ParseConfigString(ctx, pctx, l, "mock-path-for-test.hcl", tc.str, tc.include)
			require.NoError(t, actualErr, "For string '%s' include %v and configPath %v, unexpected error: %v", tc.str, tc.include, tc.configPath, actualErr)

			assert.NotNil(t, actualOut)

			inputs := actualOut.Inputs
			assert.NotNil(t, inputs)

			foo, containsFoo := inputs["foo"]
			assert.True(t, containsFoo)

			fooSlice := toStringSlice(t, foo)

			assert.Equal(t, tc.expectedFooInput, fooSlice, "For string '%s' include %v and configPath %v", tc.str, tc.include, tc.configPath)
		})
	}
}

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

	for _, cliArgs := range [][]string{nil, {}, {"apply"}, {"plan", "-out=planfile"}} {
		expectedFooInput := cliArgs
		// Expecting nil to be returned for get_terraform_cli_args() call for
		// either nil or empty array of input args
		if len(cliArgs) == 0 {
			expectedFooInput = nil
		}

		str := "inputs = { foo = get_terraform_cli_args() }"
		t.Run(str, func(t *testing.T) {
			t.Parallel()

			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath)
			pctx.TerraformCliArgs = iacargs.New(cliArgs...)

			actualOut, actualErr := config.ParseConfigString(ctx, pctx, l, "mock-path-for-test.hcl", str, nil)
			require.NoError(t, actualErr, "For string '%s', unexpected error: %v", str, actualErr)

			assert.NotNil(t, actualOut)

			inputs := actualOut.Inputs
			assert.NotNil(t, inputs)

			foo, containsFoo := inputs["foo"]
			assert.True(t, containsFoo)

			fooSlice := toStringSlice(t, foo)
			assert.Equal(t, expectedFooInput, fooSlice, "For string '%s'", str)
		})
	}
}

func toStringSlice(t *testing.T, value any) []string {
	t.Helper()

	if value == nil {
		return nil
	}

	asInterfaceSlice, isInterfaceSlice := value.([]any)
	assert.True(t, isInterfaceSlice)

	// TODO: See if this logic is desired
	if len(asInterfaceSlice) == 0 {
		return nil
	}

	var out = make([]string, 0, len(asInterfaceSlice))
	for _, item := range asInterfaceSlice {
		asStr, isStr := item.(string)
		assert.True(t, isStr)

		out = append(out, asStr)
	}

	return out
}

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

	workingDir, err := os.Getwd()
	require.NoError(t, err, "Could not get current working dir: %v", err)
	testGetTerragruntDir(t, "/foo/bar/terragrunt.hcl", filepath.VolumeName(workingDir)+"/foo/bar")
}

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

	testGetTerragruntDir(t, "foo/bar/terragrunt.hcl", filepath.Join("foo", "bar"))
}

func testGetTerragruntDir(t *testing.T, configPath string, expectedPath string) {
	t.Helper()

	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, configPath)
	actualPath, err := config.GetTerragruntDir(ctx, pctx, l)

	require.NoError(t, err, "Unexpected error: %v", err)
	assert.Equal(t, expectedPath, actualPath)
}

// newTestParsingContext creates a ParsingContext with sensible test defaults.
// Replicates NewTerragruntOptionsForTest + configbridge.populateFromOpts.
func newTestParsingContext(tb testing.TB, configPath string) (context.Context, *config.ParsingContext) {
	tb.Helper()

	l := logger.CreateLogger()
	ctx, pctx := config.NewParsingContext(tb.Context(), l, config.WithStrictControls(controls.New()))

	workingDir, downloadDir := util.DefaultWorkingAndDownloadDirs(configPath)

	pctx.TerragruntConfigPath = configPath
	pctx.WorkingDir = workingDir
	pctx.RootWorkingDir = workingDir
	pctx.DownloadDir = downloadDir
	pctx.TFPath = "tofu"
	pctx.AutoInit = true
	pctx.Env = map[string]string{}
	pctx.SourceMap = map[string]string{}
	pctx.TerraformCliArgs = iacargs.New()
	pctx.Writers.Writer = os.Stdout
	pctx.Writers.ErrWriter = os.Stderr
	pctx.MaxFoldersToCheck = 100
	pctx.TofuImplementation = tfimpl.Unknown
	pctx.Experiments = experiment.NewExperiments()
	pctx.Telemetry = new(telemetry.Options)
	pctx.EngineOptions = new(engine.EngineOptions)
	pctx.FeatureFlags = xsync.NewMap[string, string]()

	return ctx, pctx
}

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

	currentDir, err := os.Getwd()
	require.NoError(t, err, "Could not get current working dir: %v", err)

	parentDir := filepath.Dir(currentDir)

	testCases := []struct {
		include      map[string]config.IncludeConfig
		configPath   string
		expectedPath string
		params       []string
	}{
		{
			configPath:   filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath),
			expectedPath: filepath.Join(helpers.RootFolder, "child"),
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("..", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath),
			expectedPath: helpers.RootFolder,
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", config.DefaultTerragruntConfigPath),
			expectedPath: helpers.RootFolder,
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("../../..", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: helpers.RootFolder,
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join(helpers.RootFolder, config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", "sub-sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: helpers.RootFolder,
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: filepath.VolumeName(parentDir) + "/other-child",
		},
		{
			include:      map[string]config.IncludeConfig{"": {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)}},
			configPath:   filepath.Join("..", "child", "sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: "..",
		},
		{
			include: map[string]config.IncludeConfig{
				"root":  {Path: filepath.Join("../..", config.DefaultTerragruntConfigPath)},
				"child": {Path: filepath.Join("../..", "other-child", config.DefaultTerragruntConfigPath)},
			},
			params:       []string{"child"},
			configPath:   filepath.Join(helpers.RootFolder, "child", "sub-child", config.DefaultTerragruntConfigPath),
			expectedPath: filepath.VolumeName(parentDir) + "/other-child",
		},
	}

	for _, tc := range testCases {
		trackInclude := getTrackIncludeFromTestData(tc.include, tc.params, tc.configPath)
		l := logger.CreateLogger()
		ctx, pctx := newTestParsingContext(t, tc.configPath)
		pctx = pctx.WithTrackInclude(trackInclude)
		actualPath, actualErr := config.GetParentTerragruntDir(ctx, pctx, l, tc.params)
		require.NoError(t, actualErr, "For include %v and configPath %v, unexpected error: %v", tc.include, tc.configPath, actualErr)
		assert.Equal(t, tc.expectedPath, actualPath, "For include %v and configPath %v", tc.include, tc.configPath)
	}
}

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

	testCases := []struct {
		expected any
		input    string
	}{
		{
			input:    "abs(-1)",
			expected: json.Number("1"),
		},
		{
			input:    `element(["one", "two", "three"], 1)`,
			expected: "two",
		},
		{
			input:    `chomp(file("other-file.txt"))`,
			expected: "This is a test file",
		},
		{
			input:    `sha1("input")`,
			expected: "140f86aae51ab9e1cda9b4254fe98a74eb54c1a1",
		},
		{
			input:    `split("|", "one|two|three")`,
			expected: []any{"one", "two", "three"},
		},
		{
			input:    `!tobool("false")`,
			expected: true,
		},
		{
			input:    `trimspace("     content     ")`,
			expected: "content",
		},
		{
			input:    `zipmap(["one", "two", "three"], [1, 2, 3])`,
			expected: map[string]any{"one": json.Number("1"), "two": json.Number("2"), "three": json.Number("3")},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.input, func(t *testing.T) {
			t.Parallel()

			cfgPath := filepath.Join("../..", "test", "fixtures", "config-terraform-functions", config.DefaultTerragruntConfigPath)
			configString := fmt.Sprintf("inputs = { test = %s }", tc.input)
			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, cfgPath)
			actual, err := config.ParseConfigString(ctx, pctx, l, cfgPath, configString, nil)
			require.NoError(t, err, "For hcl '%s', unexpected error: %v", tc.input, err)

			assert.NotNil(t, actual)

			inputs := actual.Inputs
			assert.NotNil(t, inputs)

			test, containsTest := inputs["test"]
			assert.True(t, containsTest)

			assert.Equal(t, tc.expected, test, "For hcl '%s'", tc.input)
		})
	}
}

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

	testCases := []struct {
		expected map[string]any
		input    string
	}{
		{
			input:    `deep_merge()`,
			expected: map[string]any{},
		},
		{
			input: `deep_merge(
				{ service = { retries = 1, mode = "safe" } },
				{ service = { retries = 3 } }
			)`,
			expected: map[string]any{
				"service": map[string]any{
					"retries": json.Number("3"),
					"mode":    "safe",
				},
			},
		},
		{
			input: `deep_merge(
				{ base = { enabled = true } },
				null,
				{ extra = { enabled = false } }
			)`,
			expected: map[string]any{
				"base": map[string]any{
					"enabled": true,
				},
				"extra": map[string]any{
					"enabled": false,
				},
			},
		},
		{
			input: `deep_merge(
				jsondecode("{\"env\":{\"region\":\"us-east-1\",\"tags\":{\"owner\":\"platform\"}},\"allow\":[1]}"),
				jsondecode("{\"env\":{\"tags\":{\"app\":\"api\"}},\"allow\":[2]}")
			)`,
			expected: map[string]any{
				"env": map[string]any{
					"region": "us-east-1",
					"tags": map[string]any{
						"owner": "platform",
						"app":   "api",
					},
				},
				"allow": []any{json.Number("1"), json.Number("2")},
			},
		},
		{
			input: `deep_merge(
				{
					config = {
						retries = 1
						flags   = { enabled = true }
					}
				},
				jsondecode("{\"config\":{\"timeout\":30,\"flags\":{\"debug\":false}}}")
			)`,
			expected: map[string]any{
				"config": map[string]any{
					"retries": json.Number("1"),
					"timeout": json.Number("30"),
					"flags": map[string]any{
						"enabled": true,
						"debug":   false,
					},
				},
			},
		},
		{
			input: `deep_merge(
				{
					env = {
						region = "us-east-1"
						tags   = { owner = "platform" }
					}
					allow = [1]
				},
				{
					env = {
						tags = { app = "api" }
					}
					allow = [2]
				},
				{
					env = {
						region = "us-west-2"
					}
				}
			)`,
			expected: map[string]any{
				"env": map[string]any{
					"region": "us-west-2",
					"tags": map[string]any{
						"owner": "platform",
						"app":   "api",
					},
				},
				"allow": []any{json.Number("1"), json.Number("2")},
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.input, func(t *testing.T) {
			t.Parallel()

			cfgPath := "../../test/fixtures/config-terraform-functions/" + config.DefaultTerragruntConfigPath
			configString := fmt.Sprintf("inputs = { test = %s }", tc.input)
			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, cfgPath)
			require.NoError(t, pctx.Experiments.EnableExperiment(experiment.DeepMerge))
			actual, err := config.ParseConfigString(ctx, pctx, l, cfgPath, configString, nil)
			require.NoError(t, err)
			require.NotNil(t, actual)

			test, containsTest := actual.Inputs["test"]
			require.True(t, containsTest)

			assert.Equal(t, tc.expected, test)
		})
	}
}

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

	cfgPath := "../../test/fixtures/config-terraform-functions/" + config.DefaultTerragruntConfigPath
	configString := `inputs = { test = deep_merge({ a = 1 }, { b = 2 }) }`
	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, cfgPath)
	_, err := config.ParseConfigString(ctx, pctx, l, cfgPath, configString, nil)

	require.Error(t, err)
	require.ErrorContains(t, err, "deep-merge")
}

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

	cfgPath := "../../test/fixtures/config-terraform-functions/" + config.DefaultTerragruntConfigPath
	configString := `inputs = { test = deep_merge({ a = 1 }, "invalid") }`
	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, cfgPath)
	require.NoError(t, pctx.Experiments.EnableExperiment(experiment.DeepMerge))
	_, err := config.ParseConfigString(ctx, pctx, l, cfgPath, configString, nil)

	require.Error(t, err)
	require.ErrorContains(t, err, `Call to function "deep_merge" failed: Expected param of type map or object but got string.`)
}

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

	cfgPath, err := filepath.Abs("../../test/fixtures/deep-merge-fileset/" + config.DefaultTerragruntConfigPath)
	require.NoError(t, err)

	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, cfgPath)
	require.NoError(t, pctx.Experiments.EnableExperiment(experiment.DeepMerge))

	cfg, err := config.ParseConfigFile(ctx, pctx, l, cfgPath, nil)
	require.NoError(t, err)
	require.NotNil(t, cfg)
	require.NotNil(t, cfg.Inputs)

	expected := map[string]any{
		"app": map[string]any{
			"name": "svc-prod",
			"settings": map[string]any{
				"retries": json.Number("2"),
				"timeout": json.Number("30"),
				"tags": map[string]any{
					"owner": "platform",
					"team":  "core",
				},
			},
		},
		"ports": []any{json.Number("80"), json.Number("443")},
		"env":   "prod",
		"extra": map[string]any{
			"enabled": true,
		},
	}

	assert.Equal(t, expected, cfg.Inputs)
}

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

	testCases := []struct {
		expected map[string]cty.Value
		input    string
	}{
		{
			input:    `{"bool": {"sensitive": false, "type": "bool", "value": true}}`,
			expected: map[string]cty.Value{"bool": cty.True},
		},
		{
			input:    `{"number": {"sensitive": false, "type": "number", "value": 42}}`,
			expected: map[string]cty.Value{"number": cty.NumberIntVal(42)},
		},
		{
			input:    `{"list_string": {"sensitive": false, "type": ["list", "string"], "value": ["4", "2"]}}`,
			expected: map[string]cty.Value{"list_string": cty.ListVal([]cty.Value{cty.StringVal("4"), cty.StringVal("2")})},
		},
		{
			input:    `{"map_string": {"sensitive": false, "type": ["map", "string"], "value": {"x": "foo", "y": "bar"}}}`,
			expected: map[string]cty.Value{"map_string": cty.MapVal(map[string]cty.Value{"x": cty.StringVal("foo"), "y": cty.StringVal("bar")})},
		},
		{
			input: `{"map_list_number": {"sensitive": false, "type": ["map", ["list", "number"]], "value": {"x": [4, 2]}}}`,
			expected: map[string]cty.Value{
				"map_list_number": cty.MapVal(
					map[string]cty.Value{
						"x": cty.ListVal([]cty.Value{cty.NumberIntVal(4), cty.NumberIntVal(2)}),
					},
				),
			},
		},
		{
			input: `{"object": {"sensitive": false, "type": ["object", {"x": "number", "y": "string", "lst": ["list", "string"]}], "value": {"x": 42, "y": "the truth", "lst": ["foo", "bar"]}}}`,
			expected: map[string]cty.Value{
				"object": cty.ObjectVal(
					map[string]cty.Value{
						"x":   cty.NumberIntVal(42),
						"y":   cty.StringVal("the truth"),
						"lst": cty.ListVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}),
					},
				),
			},
		},
		{
			input: `{"out1": {"sensitive": false, "type": "number", "value": 42}, "out2": {"sensitive": false, "type": "string", "value": "foo bar"}}`,
			expected: map[string]cty.Value{
				"out1": cty.NumberIntVal(42),
				"out2": cty.StringVal("foo bar"),
			},
		},
	}

	mockTargetConfig := config.DefaultTerragruntConfigPath
	for _, tc := range testCases {
		converted, err := config.TerraformOutputJSONToCtyValueMap(mockTargetConfig, []byte(tc.input))
		require.NoError(t, err)
		assert.Equal(t, getKeys(converted), getKeys(tc.expected))

		for k, v := range converted {
			assert.True(t, v.Equals(tc.expected[k]).True())
		}
	}
}

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

	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath)
	tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/inputs/terragrunt.hcl", nil)
	require.NoError(t, err)

	tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty)
	require.NoError(t, err)

	inputsMap := tgConfigMap["inputs"].(map[string]any)

	assert.Equal(t, "string", inputsMap["string"].(string))
	assert.Equal(t, json.Number("42"), inputsMap["number"].(json.Number))
	assert.True(t, inputsMap["bool"].(bool))
	assert.Equal(t, []any{"a", "b", "c"}, inputsMap["list_string"].([]any))
	assert.Equal(t, []any{json.Number("1"), json.Number("2"), json.Number("3")}, inputsMap["list_number"].([]any))
	assert.Equal(t, []any{true, false}, inputsMap["list_bool"].([]any))
	assert.Equal(t, map[string]any{"foo": "bar"}, inputsMap["map_string"].(map[string]any))
	assert.Equal(t, map[string]any{"foo": json.Number("42"), "bar": json.Number("12345")}, inputsMap["map_number"].(map[string]any))
	assert.Equal(t, map[string]any{"foo": true, "bar": false, "baz": true}, inputsMap["map_bool"].(map[string]any))

	assert.Equal(
		t,
		map[string]any{
			"str":  "string",
			"num":  json.Number("42"),
			"list": []any{json.Number("1"), json.Number("2"), json.Number("3")},
			"map":  map[string]any{"foo": "bar"},
		},
		inputsMap["object"].(map[string]any),
	)

	assert.Equal(t, "default", inputsMap["from_env"].(string))
}

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

	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath)
	tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/terragrunt/terragrunt.hcl", nil)
	require.NoError(t, err)

	tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty)
	require.NoError(t, err)

	remoteStateMap := tgConfigMap["remote_state"].(map[string]any)
	assert.Equal(t, "s3", remoteStateMap["backend"].(string))
	configMap := remoteStateMap["config"].(map[string]any)
	assert.True(t, configMap["encrypt"].(bool))
	assert.Equal(t, "terraform.tfstate", configMap["key"].(string))
	assert.Equal(
		t,
		map[string]any{"owner": "terragrunt integration test", "name": "Terraform state storage"},
		configMap["s3_bucket_tags"].(map[string]any),
	)
	assert.Equal(
		t,
		map[string]any{"owner": "terragrunt integration test", "name": "Terraform lock table"},
		configMap["dynamodb_table_tags"].(map[string]any),
	)
	assert.Equal(
		t,
		map[string]any{"owner": "terragrunt integration test", "name": "Terraform access log storage"},
		configMap["accesslogging_bucket_tags"].(map[string]any),
	)
}

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

	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath)
	tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/hooks/before-after-and-on-error/terragrunt.hcl", nil)
	require.NoError(t, err)

	tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty)
	require.NoError(t, err)

	terraformMap := tgConfigMap["terraform"].(map[string]any)
	beforeHooksMap := terraformMap["before_hook"].(map[string]any)
	assert.Equal(
		t,
		[]any{"touch", "before.out"},
		beforeHooksMap["before_hook_1"].(map[string]any)["execute"].([]any),
	)
	assert.Equal(
		t,
		[]any{"echo", "BEFORE_TERRAGRUNT_READ_CONFIG"},
		beforeHooksMap["before_hook_2"].(map[string]any)["execute"].([]any),
	)

	afterHooksMap := terraformMap["after_hook"].(map[string]any)
	assert.Equal(
		t,
		[]any{"touch", "after.out"},
		afterHooksMap["after_hook_1"].(map[string]any)["execute"].([]any),
	)
	assert.Equal(
		t,
		[]any{"echo", "AFTER_TERRAGRUNT_READ_CONFIG"},
		afterHooksMap["after_hook_2"].(map[string]any)["execute"].([]any),
	)

	errorHooksMap := terraformMap["error_hook"].(map[string]any)
	assert.Equal(
		t,
		[]any{"echo", "ON_APPLY_ERROR"},
		errorHooksMap["error_hook_1"].(map[string]any)["execute"].([]any),
	)
}

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

	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath)
	tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/locals/canonical/terragrunt.hcl", nil)
	require.NoError(t, err)

	tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty)
	require.NoError(t, err)

	localsMap := tgConfigMap["locals"].(map[string]any)
	assert.Equal(t, json.Number("2"), localsMap["x"].(json.Number))
	assert.Equal(t, "Hello world", strings.TrimSpace(localsMap["file_contents"].(string)))
	assert.Equal(t, json.Number("42"), localsMap["number_expression"].(json.Number))
}

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

	testCases := []struct {
		config   *config.TerragruntConfig
		source   string
		expected string
	}{
		{config: mockConfigWithSource(""), source: "", expected: ""},
		{config: mockConfigWithSource(""), source: "/source/modules", expected: ""},
		{config: mockConfigWithSource("git::git@github.com:acme/modules.git//foo/bar"), source: "/source/modules", expected: "/source/modules//foo/bar"},
		{config: mockConfigWithSource("git::git@github.com:acme/modules.git//foo/bar?ref=v0.0.1"), source: "/source/modules", expected: "/source/modules//foo/bar"},
		{config: mockConfigWithSource("git::git@github.com:acme/emr_cluster.git?ref=feature/fix_bugs"), source: "/source/modules", expected: "/source/modules//emr_cluster"},
		{config: mockConfigWithSource("git::ssh://git@ghe.ourcorp.com/OurOrg/some-module.git"), source: "/source/modules", expected: "/source/modules//some-module"},
		{config: mockConfigWithSource("github.com/hashicorp/example"), source: "/source/modules", expected: "/source/modules//example"},
		{config: mockConfigWithSource("github.com/hashicorp/example//subdir"), source: "/source/modules", expected: "/source/modules//subdir"},
		{config: mockConfigWithSource("git@github.com:hashicorp/example.git//subdir"), source: "/source/modules", expected: "/source/modules//subdir"},
		{config: mockConfigWithSource("./some/path//to/modulename"), source: "/source/modules", expected: "/source/modules//to/modulename"},
	}

	for _, tc := range testCases {
		t.Run(fmt.Sprintf("%v-%s", *tc.config.Terraform.Source, tc.source), func(t *testing.T) {
			t.Parallel()

			actual, err := config.GetTerragruntSourceForModule(tc.source, "mock-for-test", tc.config)
			require.NoError(t, err)
			assert.Equal(t, tc.expected, actual)
		})
	}
}

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

	testCases := []struct {
		args     []string
		expected bool
	}{
		{args: []string{"hello world", "hello"}, expected: true},
		{args: []string{"hello world", "world"}, expected: false},
		{args: []string{"hello world", ""}, expected: true},
		{args: []string{"hello world", " "}, expected: false},
		{args: []string{"", ""}, expected: true},
		{args: []string{"", " "}, expected: false},
		{args: []string{" ", ""}, expected: true},
		{args: []string{"", "hello"}, expected: false},
		{args: []string{" ", "hello"}, expected: false},
	}

	for id, tc := range testCases {
		t.Run(fmt.Sprintf("%v %v", id, tc.args), func(t *testing.T) {
			t.Parallel()

			ctx, pctx := newTestParsingContext(t, "")
			actual, err := config.StartsWith(ctx, pctx, tc.args)
			require.NoError(t, err)
			assert.Equal(t, tc.expected, actual)
		})
	}
}

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

	testCases := []struct {
		args     []string
		expected bool
	}{
		{args: []string{"hello world", "world"}, expected: true},
		{args: []string{"hello world", "hello"}, expected: false},
		{args: []string{"hello world", ""}, expected: true},
		{args: []string{"hello world", " "}, expected: false},
		{args: []string{"", ""}, expected: true},
		{args: []string{"", " "}, expected: false},
		{args: []string{" ", ""}, expected: true},
		{args: []string{"", "hello"}, expected: false},
		{args: []string{" ", "hello"}, expected: false},
	}

	for id, tc := range testCases {
		t.Run(fmt.Sprintf("%v %v", id, tc.args), func(t *testing.T) {
			t.Parallel()

			ctx, pctx := newTestParsingContext(t, "")
			actual, err := config.EndsWith(ctx, pctx, tc.args)
			require.NoError(t, err)
			assert.Equal(t, tc.expected, actual)
		})
	}
}

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

	testCases := []struct {
		err   string
		args  []string
		value int64
	}{
		{
			args: []string{"2017-11-22T00:00:00Z", "2017-11-22T00:00:00Z"},
		},
		{
			args: []string{"2017-11-22T00:00:00Z", "2017-11-22T01:00:00+01:00"},
		},
		{
			args:  []string{"2017-11-22T00:00:01Z", "2017-11-22T01:00:00+01:00"},
			value: 1,
		},
		{
			args:  []string{"2017-11-22T01:00:00Z", "2017-11-22T00:59:00-01:00"},
			value: -1,
		},
		{
			args:  []string{"2017-11-22T01:00:00+01:00", "2017-11-22T01:00:00-01:00"},
			value: -1,
		},
		{
			args:  []string{"2017-11-22T01:00:00-01:00", "2017-11-22T01:00:00+01:00"},
			value: 1,
		},
		{
			args: []string{"2017-11-22T00:00:00Z", "bloop"},
			err:  `could not parse second parameter "bloop": not a valid RFC3339 timestamp: cannot use "bloop" as year`,
		},
		{
			args: []string{"2017-11-22 00:00:00Z", "2017-11-22T00:00:00Z"},
			err:  `could not parse first parameter "2017-11-22 00:00:00Z": not a valid RFC3339 timestamp: missing required time introducer 'T'`,
		},
	}

	for _, tc := range testCases {
		t.Run(fmt.Sprintf("TimeCmp(%#v, %#v)", tc.args[0], tc.args[1]), func(t *testing.T) {
			t.Parallel()

			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, "")

			actual, err := config.TimeCmp(ctx, pctx, l, tc.args)
			if tc.err != "" {
				require.EqualError(t, err, tc.err)
			} else {
				require.NoError(t, err)
			}

			assert.Equal(t, tc.value, actual)
		})
	}
}

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

	testCases := []struct {
		err   string
		args  []string
		value bool
	}{
		{
			args:  []string{"hello world", "hello"},
			value: true,
		},
		{
			args:  []string{"hello world", "world"},
			value: true,
		},
		{
			args:  []string{"hello world0", "0"},
			value: true,
		},
		{
			args: []string{"hello world", "test"},
		},
	}

	for _, tc := range testCases {
		t.Run(fmt.Sprintf("StrContains %v", tc.args), func(t *testing.T) {
			t.Parallel()

			ctx, pctx := newTestParsingContext(t, "")

			actual, err := config.StrContains(ctx, pctx, tc.args)
			if tc.err != "" {
				require.EqualError(t, err, tc.err)
			} else {
				require.NoError(t, err)
			}

			assert.Equal(t, tc.value, actual)
		})
	}
}

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

	l := logger.CreateLogger()
	ctx, pctx := newTestParsingContext(t, config.DefaultTerragruntConfigPath)
	tgConfigCty, err := config.ParseTerragruntConfig(ctx, pctx, l, "../../test/fixtures/read-tf-vars/terragrunt.hcl", nil)
	require.NoError(t, err)

	tgConfigMap, err := ctyhelper.ParseCtyValueToMap(tgConfigCty)
	require.NoError(t, err)

	locals := tgConfigMap["locals"].(map[string]any)

	assert.Equal(t, "string", locals["string_var"].(string))
	assert.Equal(t, json.Number("42"), locals["number_var"].(json.Number))
	assert.True(t, locals["bool_var"].(bool))
	assert.Equal(t, []any{"hello", "world"}, locals["list_var"].([]any))

	assert.Equal(t, json.Number("24"), locals["json_number_var"].(json.Number))
	assert.Equal(t, "another string", locals["json_string_var"].(string))
	assert.False(t, locals["json_bool_var"].(bool))
}

func mockConfigWithSource(sourceURL string) *config.TerragruntConfig {
	cfg := config.TerragruntConfig{IsPartial: true}
	cfg.Terraform = &config.TerraformConfig{Source: &sourceURL}

	return &cfg
}

// Return keys as a map so it is treated like a set, and order doesn't matter when comparing equivalence
func getKeys(valueMap map[string]cty.Value) map[string]bool {
	keys := map[string]bool{}
	for k := range valueMap {
		keys[k] = true
	}

	return keys
}

// getTrackIncludeFromTestData mirrors getTrackInclude: include paths are resolved to absolute against the directory of
// configPath so production callers can rely on IncludeConfig.Path being absolute.
func getTrackIncludeFromTestData(includeMap map[string]config.IncludeConfig, params []string, configPath string) *config.TrackInclude {
	if len(includeMap) == 0 {
		return nil
	}

	configDir := filepath.Dir(configPath)
	normalizedMap := make(map[string]config.IncludeConfig, len(includeMap))
	currentList := make([]config.IncludeConfig, 0, len(includeMap))

	for name, val := range includeMap {
		if val.Path != "" && !filepath.IsAbs(val.Path) {
			val.Path = filepath.Clean(filepath.Join(configDir, val.Path))
		}

		normalizedMap[name] = val
		currentList = append(currentList, val)
	}

	trackInclude := &config.TrackInclude{
		CurrentList: currentList,
		CurrentMap:  normalizedMap,
	}
	if len(params) == 0 {
		trackInclude.Original = &currentList[0]
	}

	return trackInclude
}

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

	testCases := []struct {
		err   string
		args  []string
		value bool
	}{
		{
			args:  []string{"1.2", ">= 1.0, < 1.4"},
			value: true,
		},
		{
			args:  []string{"1.0", ">= 1.0, < 1.4"},
			value: true,
		},
		{
			args:  []string{"1.4", ">= 1.0, < 1.4"},
			value: false,
		},
		{
			args:  []string{"1.E", ">= 1.0, < 1.4"},
			value: false,
			err:   "invalid version 1.E: malformed version: 1.E",
		},
		{
			args:  []string{"1.4", ">== 1.0, < 1.4"},
			value: false,
			err:   "invalid constraint >== 1.0, < 1.4: malformed constraint: >== 1.0",
		},
	}

	for _, tc := range testCases {
		t.Run(fmt.Sprintf("constraint_check(%#v, %#v)", tc.args[0], tc.args[1]), func(t *testing.T) {
			t.Parallel()

			ctx, pctx := newTestParsingContext(t, "")

			actual, err := config.ConstraintCheck(ctx, pctx, tc.args)
			if tc.err != "" {
				require.EqualError(t, err, tc.err)
			} else {
				require.NoError(t, err)
			}

			assert.Equal(t, tc.value, actual)
		})
	}
}

// TestStartsWithArityRegression: startswith with wrong arity must return WrongNumberOfParamsError, not panic.
func TestStartsWithArityRegression(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name string
		args []string
	}{
		{name: "no args", args: []string{}},
		{name: "one arg (the bug trigger)", args: []string{"foo"}},
		{name: "three args", args: []string{"foo", "bar", "baz"}},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			ctx, pctx := newTestParsingContext(t, "")

			require.NotPanics(t, func() {
				_, err := config.StartsWith(ctx, pctx, tc.args)
				require.Error(t, err, "must return error for wrong arity (%d args)", len(tc.args))
				require.ErrorAs(t, err, new(config.WrongNumberOfParamsError))
			}, "startswith with %d args must not panic", len(tc.args))
		})
	}
}

// TestEndsWithArityRegression: endswith with wrong arity must return WrongNumberOfParamsError, not panic.
func TestEndsWithArityRegression(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name string
		args []string
	}{
		{name: "no args", args: []string{}},
		{name: "one arg (the bug trigger)", args: []string{"foo"}},
		{name: "three args", args: []string{"foo", "bar", "baz"}},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			ctx, pctx := newTestParsingContext(t, "")

			require.NotPanics(t, func() {
				_, err := config.EndsWith(ctx, pctx, tc.args)
				require.Error(t, err, "must return error for wrong arity (%d args)", len(tc.args))
				require.ErrorAs(t, err, new(config.WrongNumberOfParamsError))
			}, "endswith with %d args must not panic", len(tc.args))
		})
	}
}

// TestStrContainsArityRegression: strcontains with wrong arity must return WrongNumberOfParamsError, not panic.
func TestStrContainsArityRegression(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name string
		args []string
	}{
		{name: "no args", args: []string{}},
		{name: "one arg (the bug trigger)", args: []string{"hello"}},
		{name: "three args", args: []string{"hello", "world", "extra"}},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			ctx, pctx := newTestParsingContext(t, "")

			require.NotPanics(t, func() {
				_, err := config.StrContains(ctx, pctx, tc.args)
				require.Error(t, err, "must return error for wrong arity (%d args)", len(tc.args))
				require.ErrorAs(t, err, new(config.WrongNumberOfParamsError))
			}, "strcontains with %d args must not panic", len(tc.args))
		})
	}
}

// TestRunCommandOptionsOnlyArityRegression: run_cmd with only option flags must return EmptyStringNotAllowedError, not panic.
func TestRunCommandOptionsOnlyArityRegression(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		name   string
		params []string
	}{
		{name: "single quiet flag", params: []string{"--terragrunt-quiet"}},
		{name: "single no-cache flag", params: []string{"--terragrunt-no-cache"}},
		{name: "single global-cache flag", params: []string{"--terragrunt-global-cache"}},
		{name: "two compatible flags", params: []string{"--terragrunt-quiet", "--terragrunt-no-cache"}},
		{name: "two compatible flags reversed", params: []string{"--terragrunt-no-cache", "--terragrunt-quiet"}},
		{name: "duplicate quiet", params: []string{"--terragrunt-quiet", "--terragrunt-quiet"}},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			l := logger.CreateLogger()
			ctx, pctx := newTestParsingContext(t, "")

			require.NotPanics(t, func() {
				_, err := config.RunCommand(ctx, pctx, l, vexec.NewOSExec(), tc.params)
				require.Error(t, err, "must return error when only option flags are supplied (%v)", tc.params)
				require.ErrorAs(t, err, new(config.EmptyStringNotAllowedError))
			}, "run_cmd with options-only %v must not panic", tc.params)
		})
	}
}
