//go:build !windows

package test_test

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/gruntwork-io/terragrunt/internal/git"
	inthclparse "github.com/gruntwork-io/terragrunt/internal/hclparse"
	"github.com/gruntwork-io/terragrunt/internal/vexec"
	"github.com/gruntwork-io/terragrunt/internal/vfs"
	"github.com/gruntwork-io/terragrunt/test/helpers"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const (
	testFixtureStackDepsAutoInclude              = "fixtures/stacks/stack-dependencies-autoinclude"
	testFixtureStackDepsStackRef                 = "fixtures/stacks/stack-dependencies-stack-ref"
	testFixtureStackDepsBasic                    = "fixtures/stacks/stack-deps-basic"
	testFixtureStackDepsChain                    = "fixtures/stacks/stack-deps-chain"
	testFixtureStackDepsCrossStack               = "fixtures/stacks/stack-deps-cross-stack"
	testFixtureStackDepsUnitInStack              = "fixtures/stacks/stack-deps-unit-in-stack"
	testFixtureStackDepsEntireStack              = "fixtures/stacks/stack-deps-entire-stack"
	testFixtureStackDepsNestedStack              = "fixtures/stacks/stack-deps-nested-stack"
	testFixtureStackDepsTree                     = "fixtures/stacks/stack-deps-tree"
	testFixtureStackDepsAutoIncParserLimit       = "fixtures/stacks/stack-deps-autoinclude-parser-limit"
	testFixtureStackDepsAutoIncViaInclude        = "fixtures/stacks/stack-deps-autoinclude-via-include"
	testFixtureStackDepsAutoIncViaDynInclude     = "fixtures/stacks/stack-deps-autoinclude-via-dyn-include"
	testFixtureStackDepsAutoIncViaIncludeSuccess = "fixtures/stacks/stack-deps-autoinclude-via-include-success"
)

// TestStackDepsAutoIncludeGenerationAndDAG tests parsing, autoinclude generation,
// and DAG dependency extraction in a single flow.
func TestStackDepsAutoIncludeGenerationAndDAG(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsAutoInclude)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsAutoInclude)
	liveDir := filepath.Join(tmpEnvPath, testFixtureStackDepsAutoInclude, "live")
	liveDir, err := filepath.EvalSymlinks(liveDir)
	require.NoError(t, err)

	stackFile := filepath.Join(liveDir, "terragrunt.stack.hcl")

	srcBytes, err := os.ReadFile(stackFile)
	require.NoError(t, err)

	result, err := inthclparse.ParseStackFile(vfs.NewOSFS(), &inthclparse.ParseStackFileInput{Src: srcBytes, Filename: stackFile, StackDir: liveDir})
	require.NoError(t, err)
	require.Len(t, result.Units, 2)

	resolved, ok := result.AutoIncludes[inthclparse.AutoIncludeKey("unit", "app")]
	require.True(t, ok, "app should have autoinclude")
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(t, "vpc", resolved.Dependencies[0].Name)

	appDir := filepath.Join(liveDir, inthclparse.StackDir, "app")

	err = inthclparse.GenerateAutoIncludeFile(vfs.NewOSFS(), resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	autoIncludePath := filepath.Join(appDir, inthclparse.AutoIncludeFile)
	require.FileExists(t, autoIncludePath)

	generated, err := os.ReadFile(autoIncludePath)
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(t, content, "Generated by Terragrunt")
	assert.Contains(t, content, `dependency "vpc"`)
	assert.Contains(t, content, "../vpc")
	assert.Contains(t, content, "mock_outputs_allowed_terraform_commands")
	assert.Contains(t, content, "mock-vpc-id")
	assert.Contains(t, content, `"test"`)
	assert.NotContains(t, content, "local.env")
	assert.Contains(t, content, "dependency.vpc.outputs.vpc_id")

	// Verify DAG sees the dependency
	depPaths, depErr := inthclparse.AutoIncludeDependencyPaths(vfs.NewOSFS(), appDir)
	require.NoError(t, depErr)
	require.Len(t, depPaths, 1)

	vpcDir := filepath.Join(liveDir, inthclparse.StackDir, "vpc")
	assert.Equal(t, vpcDir, depPaths[0])
}

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

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsAutoInclude)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsAutoInclude)
	liveDir := filepath.Join(tmpEnvPath, testFixtureStackDepsAutoInclude, "live")

	symlinkDir := filepath.Join(tmpEnvPath, "symlinked-live")
	require.NoError(t, os.Symlink(liveDir, symlinkDir))

	stackFile := filepath.Join(symlinkDir, "terragrunt.stack.hcl")

	srcBytes, err := os.ReadFile(stackFile)
	require.NoError(t, err)

	result, err := inthclparse.ParseStackFile(vfs.NewOSFS(), &inthclparse.ParseStackFileInput{
		Src:      srcBytes,
		Filename: stackFile,
		StackDir: symlinkDir,
	})
	require.NoError(t, err)
	require.Len(t, result.Units, 2)

	resolved, ok := result.AutoIncludes[inthclparse.AutoIncludeKey("unit", "app")]
	require.True(t, ok)
	require.Len(t, resolved.Dependencies, 1)
}

// TestStackDepsDAGWithoutAutoInclude verifies that units without autoinclude
// files return no extra dependency paths.
func TestStackDepsDAGWithoutAutoInclude(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsAutoInclude)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsAutoInclude)
	liveDir := filepath.Join(tmpEnvPath, testFixtureStackDepsAutoInclude, "live")

	stackFile := filepath.Join(liveDir, "terragrunt.stack.hcl")

	srcBytes, err := os.ReadFile(stackFile)
	require.NoError(t, err)

	result, err := inthclparse.ParseStackFile(vfs.NewOSFS(), &inthclparse.ParseStackFileInput{Src: srcBytes, Filename: stackFile, StackDir: liveDir})
	require.NoError(t, err)

	resolved, ok := result.AutoIncludes[inthclparse.AutoIncludeKey("unit", "app")]
	require.True(t, ok)

	appDir := filepath.Join(liveDir, inthclparse.StackDir, "app")

	err = inthclparse.GenerateAutoIncludeFile(vfs.NewOSFS(), resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	// vpc unit has no autoinclude: should return no deps
	vpcDir := filepath.Join(liveDir, inthclparse.StackDir, "vpc")
	require.NoError(t, os.MkdirAll(vpcDir, 0755))

	vpcDeps, vpcErr := inthclparse.AutoIncludeDependencyPaths(vfs.NewOSFS(), vpcDir)
	require.NoError(t, vpcErr)
	assert.Empty(t, vpcDeps)
}

// TestStackDepsDAGExpandsStackToUnits verifies that when a dependency
// points to a stack directory, the stack is expanded to constituent unit paths.
func TestStackDepsDAGExpandsStackToUnits(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsStackRef)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsStackRef)
	liveDir := filepath.Join(tmpEnvPath, testFixtureStackDepsStackRef, "live")
	liveDir, err := filepath.EvalSymlinks(liveDir)
	require.NoError(t, err)

	stackFile := filepath.Join(liveDir, "terragrunt.stack.hcl")

	srcBytes, err := os.ReadFile(stackFile)
	require.NoError(t, err)

	result, err := inthclparse.ParseStackFile(vfs.NewOSFS(), &inthclparse.ParseStackFileInput{
		Src:      srcBytes,
		Filename: stackFile,
		StackDir: liveDir,
	})
	require.NoError(t, err)

	resolved, ok := result.AutoIncludes[inthclparse.AutoIncludeKey("unit", "app_stack_dep")]
	require.True(t, ok)

	appDir := filepath.Join(liveDir, inthclparse.StackDir, "app-stack-dep")

	err = inthclparse.GenerateAutoIncludeFile(vfs.NewOSFS(), resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	depPaths, depErr := inthclparse.AutoIncludeDependencyPaths(vfs.NewOSFS(), appDir)
	require.NoError(t, depErr)
	require.Len(t, depPaths, 1)

	stackDir := depPaths[0]

	nestedStackSrc := filepath.Join(tmpEnvPath, testFixtureStackDepsStackRef, "catalog", "stacks", "networking", "terragrunt.stack.hcl")

	require.NoError(t, os.MkdirAll(stackDir, 0755))

	nestedContent, err := os.ReadFile(nestedStackSrc)
	require.NoError(t, err)
	require.NoError(t, os.WriteFile(filepath.Join(stackDir, "terragrunt.stack.hcl"), nestedContent, 0644))

	unitPaths, err := inthclparse.UnitPathsFromStackDir(vfs.NewOSFS(), stackDir)
	require.NoError(t, err)
	require.Len(t, unitPaths, 2, "networking stack should expand to 2 unit paths")

	expectedVPC := filepath.Join(stackDir, inthclparse.StackDir, "vpc")
	expectedSubnets := filepath.Join(stackDir, inthclparse.StackDir, "subnets")

	assert.Contains(t, unitPaths, expectedVPC)
	assert.Contains(t, unitPaths, expectedSubnets)
}

// TestStackDepsE2EBasic runs the full end-to-end flow with 2 units:
// stack generate -> run --all apply -> verify outputs -> run --all destroy.
func TestStackDepsE2EBasic(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsBasic)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsBasic)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsBasic, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	autoIncludePath := filepath.Join(rootPath, inthclparse.StackDir, "unit-w-inputs", "terragrunt.autoinclude.hcl")
	require.FileExists(t, autoIncludePath)

	content, err := os.ReadFile(autoIncludePath)
	require.NoError(t, err)
	assert.Contains(t, string(content), `dependency "unit_w_outputs"`)
	assert.Contains(t, string(content), "../unit-w-outputs")

	helpers.RunTerragrunt(t, "terragrunt run --all --non-interactive --experiment stack-dependencies --working-dir "+rootPath+" -- apply -auto-approve")

	inputPath := helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "unit-w-inputs"), "input.txt")

	inputContent, err := os.ReadFile(inputPath)
	require.NoError(t, err)
	assert.Equal(t, "Received: Hello!", string(inputContent))

	helpers.RunTerragrunt(t, "terragrunt run --all --non-interactive --experiment stack-dependencies --working-dir "+rootPath+" -- destroy -auto-approve")
}

// TestStackDepsE2EChain runs a 3-level dependency chain end-to-end:
// unit_a -> unit_b -> unit_c
// Verifies chained output propagation and correct apply/destroy ordering.
func TestStackDepsE2EChain(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsChain)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsChain)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsChain, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	// Verify autoinclude generated for unit-b and unit-a but not unit-c
	require.FileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "unit-b", "terragrunt.autoinclude.hcl"))
	require.FileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "unit-a", "terragrunt.autoinclude.hcl"))
	assert.NoFileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "unit-c", "terragrunt.autoinclude.hcl"))

	helpers.RunTerragrunt(t, "terragrunt run --all --non-interactive --experiment stack-dependencies --working-dir "+rootPath+" -- apply -auto-approve")

	// Verify unit-a received chained output: from-b(from-c)
	markerA := helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "unit-a"), "marker.txt")

	contentA, err := os.ReadFile(markerA)
	require.NoError(t, err)
	assert.Equal(t, "unit-a received: from-b(from-c)", string(contentA))

	// Verify unit-b received: from-c
	markerB := helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "unit-b"), "marker.txt")

	contentB, err := os.ReadFile(markerB)
	require.NoError(t, err)
	assert.Equal(t, "unit-b received: from-c", string(contentB))

	helpers.RunTerragrunt(t, "terragrunt run --all --non-interactive --experiment stack-dependencies --working-dir "+rootPath+" -- destroy -auto-approve")

	// Destroy must remove the marker files produced by apply.
	assert.NoFileExists(t, markerA)
	assert.NoFileExists(t, markerB)
}

// TestStackDepsE2ECrossStack tests stack generation with cross-stack dependencies:
// a "network" stack (containing vpc + subnets) and an "app" unit depending on
// the entire network stack via stack.network.path.
func TestStackDepsE2ECrossStack(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsCrossStack)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsCrossStack)
	gitPath := filepath.Join(tmpEnvPath, testFixtureStackDepsCrossStack)

	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	err = runner.WithWorkDir(gitPath).Init(t.Context())
	require.NoError(t, err)

	rootPath := filepath.Join(gitPath, "live")
	rootPath, err = filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	autoIncludePath := filepath.Join(rootPath, inthclparse.StackDir, "app", "terragrunt.autoinclude.hcl")
	require.FileExists(t, autoIncludePath)

	content, err := os.ReadFile(autoIncludePath)
	require.NoError(t, err)
	assert.Contains(t, string(content), `dependency "network"`)
	assert.Contains(t, string(content), "../network")

	// Verify network stack units were generated
	networkStackDir := filepath.Join(rootPath, inthclparse.StackDir, "network", inthclparse.StackDir)
	assert.DirExists(t, filepath.Join(networkStackDir, "vpc"))
	assert.DirExists(t, filepath.Join(networkStackDir, "subnets"))

	// Verify DAG sees the dependency
	appDir := filepath.Join(rootPath, inthclparse.StackDir, "app")
	depPaths, depErr := inthclparse.AutoIncludeDependencyPaths(vfs.NewOSFS(), appDir)
	require.NoError(t, depErr)
	require.Len(t, depPaths, 1)
	assert.Equal(t, filepath.Join(rootPath, inthclparse.StackDir, "network"), depPaths[0])
}

// TestStackDepsDocExample_UnitInStack tests the docs example:
// "Dependencies on units within a nested stack"
func TestStackDepsDocExample_UnitInStack(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsUnitInStack)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsUnitInStack)
	liveDir := filepath.Join(tmpEnvPath, testFixtureStackDepsUnitInStack, "live")
	liveDir, err := filepath.EvalSymlinks(liveDir)
	require.NoError(t, err)

	stackFile := filepath.Join(liveDir, "terragrunt.stack.hcl")

	srcBytes, err := os.ReadFile(stackFile)
	require.NoError(t, err)

	result, err := inthclparse.ParseStackFile(vfs.NewOSFS(), &inthclparse.ParseStackFileInput{
		Src:      srcBytes,
		Filename: stackFile,
		StackDir: liveDir,
	})
	require.NoError(t, err)
	require.Len(t, result.Stacks, 1)
	require.Len(t, result.Units, 1)

	resolved, ok := result.AutoIncludes[inthclparse.AutoIncludeKey("unit", "unit_w_inputs")]
	require.True(t, ok)
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(t, "unit_w_outputs", resolved.Dependencies[0].Name)

	expectedPath := filepath.Join(liveDir, inthclparse.StackDir, "stack-w-outputs", inthclparse.StackDir, "unit-w-outputs")
	assert.Equal(t, expectedPath, resolved.Dependencies[0].ConfigPath)

	appDir := filepath.Join(liveDir, inthclparse.StackDir, "unit-w-inputs")

	err = inthclparse.GenerateAutoIncludeFile(vfs.NewOSFS(), resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := os.ReadFile(filepath.Join(appDir, inthclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(t, content, `dependency "unit_w_outputs"`)
	assert.Contains(t, content, `"fake-val"`)
	assert.Contains(t, content, "dependency.unit_w_outputs.outputs.val")
}

// TestStackDepsDocExample_EntireStack tests the docs example:
// "Dependencies on entire stacks"
func TestStackDepsDocExample_EntireStack(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsEntireStack)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsEntireStack)
	liveDir := filepath.Join(tmpEnvPath, testFixtureStackDepsEntireStack, "live")
	liveDir, err := filepath.EvalSymlinks(liveDir)
	require.NoError(t, err)

	stackFile := filepath.Join(liveDir, "terragrunt.stack.hcl")

	srcBytes, err := os.ReadFile(stackFile)
	require.NoError(t, err)

	result, err := inthclparse.ParseStackFile(vfs.NewOSFS(), &inthclparse.ParseStackFileInput{
		Src:      srcBytes,
		Filename: stackFile,
		StackDir: liveDir,
	})
	require.NoError(t, err)
	require.Len(t, result.Stacks, 1)
	require.Len(t, result.Units, 1)

	resolved, ok := result.AutoIncludes[inthclparse.AutoIncludeKey("unit", "app")]
	require.True(t, ok)
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(t, "infra", resolved.Dependencies[0].Name)

	expectedPath := filepath.Join(liveDir, inthclparse.StackDir, "infra")
	assert.Equal(t, expectedPath, resolved.Dependencies[0].ConfigPath)

	appDir := filepath.Join(liveDir, inthclparse.StackDir, "app")

	err = inthclparse.GenerateAutoIncludeFile(vfs.NewOSFS(), resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := os.ReadFile(filepath.Join(appDir, inthclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(t, content, `dependency "infra"`)
	assert.Contains(t, content, "../infra")
	assert.Contains(t, content, "mock_outputs_allowed_terraform_commands")
	assert.Contains(t, content, "mock-vpc-id")
	assert.Contains(t, content, "dependency.infra.outputs.vpc.vpc_id")

	depPaths, depErr := inthclparse.AutoIncludeDependencyPaths(vfs.NewOSFS(), appDir)
	require.NoError(t, depErr)
	require.Len(t, depPaths, 1)
	assert.Equal(t, expectedPath, depPaths[0])
}

// TestStackDepsDocExample_NestedStackPath tests the docs example:
// "stack.<name>.<nested_stack_name>.path" resolution.
// A parent stack contains an "infra" stack which itself contains a "deep" nested stack.
// The reference stack.infra.deep.path resolves to the nested stack's generated directory.
func TestStackDepsDocExample_NestedStackPath(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsNestedStack)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsNestedStack)
	liveDir := filepath.Join(tmpEnvPath, testFixtureStackDepsNestedStack, "live")
	liveDir, err := filepath.EvalSymlinks(liveDir)
	require.NoError(t, err)

	stackFile := filepath.Join(liveDir, "terragrunt.stack.hcl")

	srcBytes, err := os.ReadFile(stackFile)
	require.NoError(t, err)

	result, err := inthclparse.ParseStackFile(vfs.NewOSFS(), &inthclparse.ParseStackFileInput{
		Src:      srcBytes,
		Filename: stackFile,
		StackDir: liveDir,
	})
	require.NoError(t, err)
	require.Len(t, result.Stacks, 1)
	require.Len(t, result.Units, 1)

	// Verify autoinclude resolved stack.infra.deep.path
	resolved, ok := result.AutoIncludes[inthclparse.AutoIncludeKey("unit", "app")]
	require.True(t, ok, "app should have autoinclude")
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(t, "deep", resolved.Dependencies[0].Name)

	// Path should point to nested stack: .terragrunt-stack/infra/.terragrunt-stack/deep
	expectedPath := filepath.Join(liveDir, inthclparse.StackDir, "infra", inthclparse.StackDir, "deep")
	assert.Equal(t, expectedPath, resolved.Dependencies[0].ConfigPath)

	// Generate autoinclude and verify content
	appDir := filepath.Join(liveDir, inthclparse.StackDir, "app")

	err = inthclparse.GenerateAutoIncludeFile(vfs.NewOSFS(), resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)
	require.FileExists(t, filepath.Join(appDir, inthclparse.AutoIncludeFile))

	generated, err := os.ReadFile(filepath.Join(appDir, inthclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(t, content, `dependency "deep"`)
	assert.Contains(t, content, "mock_outputs")
	assert.Contains(t, content, `"mock-db"`)
	assert.Contains(t, content, "dependency.deep.outputs.val")

	// Verify DAG sees the dependency
	depPaths, depErr := inthclparse.AutoIncludeDependencyPaths(vfs.NewOSFS(), appDir)
	require.NoError(t, depErr)
	require.Len(t, depPaths, 1)
	assert.Equal(t, expectedPath, depPaths[0])
}

// Stack file declares autoinclude AND uses format() in path which the simplified parser cannot decode. Hard error required.
func TestStackDepsAutoIncludeLoudFailOnParserLimit(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsAutoIncParserLimit)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsAutoIncParserLimit)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsAutoIncParserLimit, "live")

	stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	require.Error(t, err, "stack generate must return an error when autoinclude is declared and the two-pass parser cannot decode the stack file")
	assert.Contains(t, err.Error()+"\n"+stdout+stderr, "failed to parse autoinclude block")
	require.NoFileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "subnet", inthclparse.AutoIncludeFile))
}

// Companion contract: parser-incompatible HCL without an autoinclude block still generates successfully (silent skip is allowed only when the user has nothing to lose).
func TestStackDepsParserLimitOKWithoutAutoInclude(t *testing.T) {
	t.Parallel()

	tmpDir := t.TempDir()
	stackDir := filepath.Join(tmpDir, "live")
	require.NoError(t, os.MkdirAll(stackDir, 0755))
	require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "catalog", "units", "vpc"), 0755))
	require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "catalog", "units", "vpc", "terragrunt.hcl"), []byte("inputs = {}\n"), 0644))

	stackHCL := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = format("%s", "vpc")
}
`
	require.NoError(t, os.WriteFile(filepath.Join(stackDir, "terragrunt.stack.hcl"), []byte(stackHCL), 0644))

	_, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt stack generate --experiment stack-dependencies --working-dir "+stackDir)
	require.NoError(t, err, "stack generate must succeed without autoinclude even if the two-pass parser cannot decode source/path expressions")

	require.DirExists(t, filepath.Join(stackDir, inthclparse.StackDir, "vpc"))
	require.NoFileExists(t, filepath.Join(stackDir, inthclparse.StackDir, "vpc", "terragrunt.autoinclude.hcl"))
}

// Root has only an `include` block (literal path); autoinclude lives in the included file along with parser-incompatible HCL.
func TestStackDepsAutoIncludeLoudFailViaInclude(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsAutoIncViaInclude)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsAutoIncViaInclude)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsAutoIncViaInclude, "live")

	stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	require.Error(t, err, "stack generate must return an error when autoinclude is declared in an included stack file")
	assert.Contains(t, err.Error()+"\n"+stdout+stderr, "failed to parse autoinclude block")
	require.NoFileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "subnet", inthclparse.AutoIncludeFile))
}

// Root's include.path is an HCL expression (format()) the simplified parser cannot decode; production parser still resolves it and the included file declares autoinclude.
func TestStackDepsAutoIncludeLoudFailViaDynamicInclude(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsAutoIncViaDynInclude)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsAutoIncViaDynInclude)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsAutoIncViaDynInclude, "live")

	stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	require.Error(t, err, "stack generate must return an error when autoinclude is reachable via an expression-based include path")
	assert.Contains(t, err.Error()+"\n"+stdout+stderr, "failed to parse autoinclude block")
	require.NoFileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "subnet", inthclparse.AutoIncludeFile))
}

// Root has only an `include`; the included file declares parser-compatible autoinclude. Generation must use the included file's bytes when slicing expressions, otherwise mock_outputs/inputs come out garbled or empty.
func TestStackDepsAutoIncludeViaIncludePreservesContent(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsAutoIncViaIncludeSuccess)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsAutoIncViaIncludeSuccess)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsAutoIncViaIncludeSuccess, "live")

	_, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

	autoIncludePath := filepath.Join(rootPath, inthclparse.StackDir, "subnet", inthclparse.AutoIncludeFile)
	require.FileExists(t, autoIncludePath)

	generated, err := os.ReadFile(autoIncludePath)
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(t, content, `dependency "vpc"`)
	assert.Contains(t, content, "mock_outputs_allowed_terraform_commands")
	assert.Contains(t, content, "shared-mock-id")
	assert.Contains(t, content, "dependency.vpc.outputs.id")
}

// findComponent is the JSON structure returned by terragrunt find --json.
type findComponent struct {
	Type         string   `json:"type"`
	Path         string   `json:"path"`
	Dependencies []string `json:"dependencies,omitempty"`
}

// TestStackDepsFindJSON verifies that terragrunt find --json --dag --dependencies
// correctly shows stack dependency relationships from autoinclude.
func TestStackDepsFindJSON(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsBasic)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsBasic)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsBasic, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt find --json --dag --dependencies --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

	var components []findComponent

	require.NoError(t, json.Unmarshal([]byte(stdout), &components))

	// Find the unit-w-inputs component and verify its dependency
	foundInputs := false
	foundOutputs := false

	for _, c := range components {
		if c.Type != "unit" {
			continue
		}

		if filepath.Base(c.Path) == "unit-w-inputs" {
			foundInputs = true

			require.Len(t, c.Dependencies, 1)
			assert.Contains(t, c.Dependencies[0], "unit-w-outputs")
		}

		if filepath.Base(c.Path) == "unit-w-outputs" {
			foundOutputs = true

			assert.Empty(t, c.Dependencies)
		}
	}

	require.True(t, foundInputs, "unit-w-inputs should be in find output")
	require.True(t, foundOutputs, "unit-w-outputs should be in find output")
}

// TestStackDepsFindDAG verifies that terragrunt find --dag lists units in
// dependency order: unit-w-outputs before unit-w-inputs.
func TestStackDepsFindDAG(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsBasic)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsBasic)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsBasic, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt find --dag --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

	outputsIdx := strings.Index(stdout, "unit-w-outputs")
	inputsIdx := strings.Index(stdout, "unit-w-inputs")

	require.NotEqual(t, -1, outputsIdx, "unit-w-outputs should be in output")
	require.NotEqual(t, -1, inputsIdx, "unit-w-inputs should be in output")
	assert.Less(t, outputsIdx, inputsIdx, "unit-w-outputs should appear before unit-w-inputs in DAG order")
}

// TestStackDepsListLong verifies that terragrunt list --long --dependencies --dag
// shows dependency columns with stack dependency relationships.
func TestStackDepsListLong(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsBasic)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsBasic)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsBasic, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt list --long --dependencies --dag --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

	assert.Contains(t, stdout, "Dependencies")

	for _, line := range strings.Split(stdout, "\n") {
		if !strings.Contains(line, "unit-w-inputs") {
			continue
		}

		assert.Contains(t, line, "unit-w-outputs",
			"unit-w-inputs row should show unit-w-outputs as dependency")
	}
}

// TestStackDepsListTree verifies that terragrunt list --tree --dag
// shows a tree structure reflecting dependency relationships.
func TestStackDepsListTree(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsBasic)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsBasic)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsBasic, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt list --tree --dag --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

	assert.Contains(t, stdout, "unit-w-outputs")
	assert.Contains(t, stdout, "unit-w-inputs")

	outputsIdx := strings.Index(stdout, "unit-w-outputs")
	inputsIdx := strings.Index(stdout, "unit-w-inputs")

	assert.Less(t, outputsIdx, inputsIdx)
}

// TestStackDepsFindChain verifies find --json --dag --dependencies with a
// 3-level dependency chain: unit_a -> unit_b -> unit_c.
func TestStackDepsFindChain(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsChain)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsChain)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsChain, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt find --json --dag --dependencies --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

	var components []findComponent

	require.NoError(t, json.Unmarshal([]byte(stdout), &components))

	depsByPath := make(map[string][]string)

	for _, c := range components {
		depsByPath[c.Path] = c.Dependencies
	}

	var unitAPath, unitBPath, unitCPath string

	for path := range depsByPath {
		switch {
		case filepath.Base(path) == "unit-a":
			unitAPath = path
		case filepath.Base(path) == "unit-b":
			unitBPath = path
		case filepath.Base(path) == "unit-c":
			unitCPath = path
		}
	}

	require.NotEmpty(t, unitAPath, "unit-a should be in output")
	require.NotEmpty(t, unitBPath, "unit-b should be in output")
	require.NotEmpty(t, unitCPath, "unit-c should be in output")

	require.Len(t, depsByPath[unitAPath], 1)
	assert.Equal(t, unitBPath, depsByPath[unitAPath][0])

	require.Len(t, depsByPath[unitBPath], 1)
	assert.Equal(t, unitCPath, depsByPath[unitBPath][0])

	assert.Empty(t, depsByPath[unitCPath])
}

// TestStackDepsFindTree verifies find --json --dag --dependencies with a
// multi-level dependency tree:
//
//	    A
//	   / \
//	  B   C
//	 / \
//	D   E
//
// D, E, C are leaves. B depends on D+E. A depends on B+C.
func TestStackDepsFindTree(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsTree)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsTree)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsTree, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt find --json --dag --dependencies --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

	var components []findComponent

	require.NoError(t, json.Unmarshal([]byte(stdout), &components))

	depsByPath := make(map[string][]string)

	for _, c := range components {
		depsByPath[c.Path] = c.Dependencies
	}

	var aPath, bPath, cPath, dPath, ePath string

	for path := range depsByPath {
		switch {
		case filepath.Base(path) == "unit-a":
			aPath = path
		case filepath.Base(path) == "unit-b":
			bPath = path
		case filepath.Base(path) == "unit-c":
			cPath = path
		case filepath.Base(path) == "unit-d":
			dPath = path
		case filepath.Base(path) == "unit-e":
			ePath = path
		}
	}

	require.NotEmpty(t, aPath, "unit-a should be in output")
	require.NotEmpty(t, bPath, "unit-b should be in output")
	require.NotEmpty(t, cPath, "unit-c should be in output")
	require.NotEmpty(t, dPath, "unit-d should be in output")
	require.NotEmpty(t, ePath, "unit-e should be in output")

	// A depends on B and C
	require.Len(t, depsByPath[aPath], 2)
	assert.Contains(t, depsByPath[aPath], bPath)
	assert.Contains(t, depsByPath[aPath], cPath)

	// B depends on D and E
	require.Len(t, depsByPath[bPath], 2)
	assert.Contains(t, depsByPath[bPath], dPath)
	assert.Contains(t, depsByPath[bPath], ePath)

	// C, D, E are leaves
	assert.Empty(t, depsByPath[cPath])
	assert.Empty(t, depsByPath[dPath])
	assert.Empty(t, depsByPath[ePath])
}

// TestStackDepsFindTreeDAGOrder verifies that find --dag with a multi-level
// tree outputs units in correct topological order: leaves before parents.
func TestStackDepsFindTreeDAGOrder(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsTree)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsTree)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsTree, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt find --dag --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

	idxA := strings.Index(stdout, "unit-a")
	idxB := strings.Index(stdout, "unit-b")
	idxC := strings.Index(stdout, "unit-c")
	idxD := strings.Index(stdout, "unit-d")
	idxE := strings.Index(stdout, "unit-e")

	require.NotEqual(t, -1, idxA)
	require.NotEqual(t, -1, idxB)
	require.NotEqual(t, -1, idxC)
	require.NotEqual(t, -1, idxD)
	require.NotEqual(t, -1, idxE)

	// D and E must appear before B (B depends on D+E)
	assert.Less(t, idxD, idxB, "unit-d should appear before unit-b")
	assert.Less(t, idxE, idxB, "unit-e should appear before unit-b")

	// B and C must appear before A (A depends on B+C)
	assert.Less(t, idxB, idxA, "unit-b should appear before unit-a")
	assert.Less(t, idxC, idxA, "unit-c should appear before unit-a")
}

// TestStackDepsE2ETree runs apply/destroy on the multi-level dependency tree
// and verifies output propagation through all levels.
func TestStackDepsE2ETree(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsTree)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsTree)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsTree, "live")
	rootPath, err := filepath.EvalSymlinks(rootPath)
	require.NoError(t, err)

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)

	helpers.RunTerragrunt(t, "terragrunt run --all --non-interactive --experiment stack-dependencies --working-dir "+rootPath+" -- apply -auto-approve")

	// Verify unit-b received outputs from D and E
	markerB := helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "unit-b"), "marker.txt")

	contentB, err := os.ReadFile(markerB)
	require.NoError(t, err)
	assert.Equal(t, "unit-b(from-d,from-e)", string(contentB))

	// Verify unit-a received outputs from B and C
	markerA := helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "unit-a"), "marker.txt")

	contentA, err := os.ReadFile(markerA)
	require.NoError(t, err)
	assert.Equal(t, "unit-a(from-b(from-d,from-e),from-c)", string(contentA))

	helpers.RunTerragrunt(t, "terragrunt run --all --non-interactive --experiment stack-dependencies --working-dir "+rootPath+" -- destroy -auto-approve")

	// Destroy must remove the marker files produced by apply.
	assert.NoFileExists(t, markerA)
	assert.NoFileExists(t, markerB)
}
