//go:build !windows

package test_test

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

	"github.com/gruntwork-io/terragrunt/internal/configbridge"
	"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/pkg/config"
	"github.com/gruntwork-io/terragrunt/pkg/options"
	"github.com/gruntwork-io/terragrunt/test/helpers"
	"github.com/gruntwork-io/terragrunt/test/helpers/logger"
	"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"
	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"
	testFixtureStackDepsAutoIncComplexSiblings   = "fixtures/stacks/stack-deps-autoinclude-complex-siblings"
	testFixtureStackDepsRunAllFuncsInNestedStack = "fixtures/stacks/stack-deps-runall-funcs-in-nested-stack"
	testFixtureStackDepsNoDeps                   = "fixtures/stacks/stack-deps-no-deps"
	testFixtureStackDepsMergePrecedence          = "fixtures/stacks/stack-deps-merge-precedence"
	testFixtureStackDepsArbitraryOverride        = "fixtures/stacks/stack-deps-arbitrary-override"
	testFixtureStackDepsStackAutoInclude         = "fixtures/stacks/stack-deps-stack-autoinclude"
	testFixtureStackDepsStackAutoIncludeNested   = "fixtures/stacks/stack-deps-stack-autoinclude-nested"
	testFixtureStackDepsCrossLevelValues         = "fixtures/stacks/stack-deps-cross-level-values"
)

// 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))

	l := logger.CreateLogger()
	_, pctx := configbridge.NewParsingContext(t.Context(), l, options.NewTerragruntOptions())
	funcs, err := config.EarlyStackParseFunctions(t.Context(), l, stackDir, pctx)
	require.NoError(t, err)

	unitPaths, err := inthclparse.UnitPathsFromStackDir(vfs.NewOSFS(), stackDir, funcs)
	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])

	// Apply the whole tree: the network stack's units run first, then app consumes the
	// real aggregated output dependency.network.outputs.vpc.vpc_id (not the mock).
	helpers.RunTerragrunt(t, "terragrunt run --all --non-interactive --experiment stack-dependencies --working-dir "+rootPath+" -- apply -auto-approve")

	markerPath := helpers.FindCachedFile(t, appDir, "marker.txt")
	markerContent, err := os.ReadFile(markerPath)
	require.NoError(t, err)
	assert.Equal(t, "app received: vpc-cross-stack", string(markerContent),
		"app must receive the network stack's real vpc output, not the mock")

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

// Regression: a non-literal expression (here, format()) in an unrelated unit must not block autoinclude resolution. Generation succeeds and the autoinclude file is produced for the unit that declares it.
func TestStackDepsAutoIncludeWithFormatInOtherUnit(t *testing.T) {
	t.Parallel()

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

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

	require.NoError(t, err, "non-literal expressions in unrelated units must not block autoinclude generation")
	require.FileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "subnet", inthclparse.AutoIncludeFile))
}

// Regression: a unit using `values.X` references in a sibling unit must not block autoinclude generation on a different unit.
func TestStackDepsAutoIncludeWithValuesRefInOtherUnit(t *testing.T) {
	t.Parallel()

	tmpDir := t.TempDir()
	stackDir := filepath.Join(tmpDir, "live")
	require.NoError(t, os.MkdirAll(stackDir, 0755))

	for _, unitName := range []string{"account", "idp", "roles"} {
		unitDir := filepath.Join(tmpDir, "catalog", "units", unitName)
		require.NoError(t, os.MkdirAll(unitDir, 0755))
		require.NoError(t, os.WriteFile(filepath.Join(unitDir, "terragrunt.hcl"), []byte("inputs = {}\n"), 0644))
	}

	valuesPath := filepath.Join(stackDir, "terragrunt.values.hcl")
	require.NoError(t, os.WriteFile(valuesPath, []byte(`
account = "acme"
region  = "us-east-1"
idps    = ["okta"]
roles   = ["admin"]
`), 0644))

	stackHCL := `
unit "account" {
  source = "../catalog/units/account"
  path   = "account_hcl"
  values = {
    account = values.account
    region  = values.region
  }
}

unit "idps" {
  source = "../catalog/units/idp"
  path   = "idps_hcl"
  values = {
    identityProviders = values.idps
    region            = values.region
  }
}

unit "roles" {
  source = "../catalog/units/roles"
  path   = "roles_hcl"
  values = {
    roles  = values.roles
    region = values.region
  }
  autoinclude {
    dependency "account" {
      config_path = unit.account.path
    }
  }
}
`
	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, "values references in unrelated units must not block autoinclude generation on roles unit")

	require.FileExists(t, filepath.Join(stackDir, inthclparse.StackDir, "roles_hcl", inthclparse.AutoIncludeFile))
	require.NoFileExists(t, filepath.Join(stackDir, inthclparse.StackDir, "account_hcl", inthclparse.AutoIncludeFile))
	require.NoFileExists(t, filepath.Join(stackDir, inthclparse.StackDir, "idps_hcl", 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 production-only expressions appear in source/path attributes")

	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 non-literal HCL expressions in unrelated units. Generation must succeed.
func TestStackDepsAutoIncludePassesViaInclude(t *testing.T) {
	t.Parallel()

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

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

	require.NoError(t, err, "autoinclude in an included file must succeed even when the included file contains non-literal expressions in other units")
	require.FileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "subnet", inthclparse.AutoIncludeFile))
}

// Root's include.path is an HCL expression (format()) that the stack-dependencies parser must resolve with the production function context. The included file declares autoinclude, so generation must succeed.
func TestStackDepsAutoIncludePassesViaDynamicInclude(t *testing.T) {
	t.Parallel()

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

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

	require.NoError(t, err, "autoinclude reachable via an expression-based include path must succeed")
	require.FileExists(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.SplitSeq(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)
}

// TestStackDepsAutoIncludeWithLocalRefInOtherUnit pins that local.* references in unrelated units do not block autoinclude generation.
func TestStackDepsAutoIncludeWithLocalRefInOtherUnit(t *testing.T) {
	t.Parallel()

	tmpDir := t.TempDir()
	stackDir := filepath.Join(tmpDir, "live")
	require.NoError(t, os.MkdirAll(stackDir, 0755))

	for _, unitName := range []string{"a", "b"} {
		unitDir := filepath.Join(tmpDir, "catalog", "units", unitName)
		require.NoError(t, os.MkdirAll(unitDir, 0755))
		require.NoError(t, os.WriteFile(filepath.Join(unitDir, "terragrunt.hcl"), []byte("inputs = {}\n"), 0644))
	}

	stackHCL := `
locals {
  region = "us-east-1"
}

unit "a" {
  source = "../catalog/units/a"
  path   = "a"
  values = {
    region = local.region
  }
}

unit "b" {
  source = "../catalog/units/b"
  path   = "b"
  autoinclude {
    dependency "a" {
      config_path = unit.a.path
    }
  }
}
`
	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, "local.* references in unrelated units must not block autoinclude generation")

	require.FileExists(t, filepath.Join(stackDir, inthclparse.StackDir, "b", inthclparse.AutoIncludeFile))
}

// TestStackDepsAutoIncludeWithFunctionInSource pins that terragrunt function calls (e.g. get_terragrunt_dir()) in the source attribute of an unrelated unit do not block autoinclude generation.
func TestStackDepsAutoIncludeWithFunctionInSource(t *testing.T) {
	t.Parallel()

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

	_, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err, "get_terragrunt_dir() in source plus local.* and values.* references in unrelated units must not block autoinclude generation")

	// Autoinclude must be generated only on the unit that declared it.
	require.FileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "roles", inthclparse.AutoIncludeFile))
	require.NoFileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "account", inthclparse.AutoIncludeFile))
	require.NoFileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "idps", inthclparse.AutoIncludeFile))
}

// TestStackDepsE2EAutoIncludeWithComplexSiblings is the end-to-end regression for stacks whose unrelated units use every HCL feature class that previously broke the simplified parser; the roles unit must observe the account unit's output through the generated autoinclude.
func TestStackDepsE2EAutoIncludeWithComplexSiblings(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsAutoIncComplexSiblings)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsAutoIncComplexSiblings)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsAutoIncComplexSiblings, "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, "roles", inthclparse.AutoIncludeFile)
	require.FileExists(t, autoIncludePath, "roles unit must have its autoinclude file generated")

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

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

	markerPath := helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "roles"), "marker.txt")

	markerContent, err := os.ReadFile(markerPath)
	require.NoError(t, err)
	assert.Equal(t, "roles-received: account-output", string(markerContent), "roles must receive account's output via the generated autoinclude dependency")

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

// Regression: `run --all` discovery must walk a generated nested stack file even when its unit `source` attribute contains terragrunt function calls.
func TestStackDepsRunAllWithFunctionsInNestedStack(t *testing.T) {
	t.Parallel()

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

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

	_, stderr, runErr := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt run --all --non-interactive --experiment stack-dependencies --working-dir "+rootPath+" -- plan")
	require.NoError(t, runErr, "run --all must succeed when the generated nested stack file contains terragrunt function calls; stderr=%s", stderr)
	assert.NotContains(t, stderr, "Function calls not allowed",
		"discovery must not surface 'Function calls not allowed' on generated nested stack files")

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

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

	foundNestedVPC := false

	for _, c := range components {
		if c.Type == "unit" && filepath.Base(c.Path) == "vpc" && strings.Contains(c.Path, filepath.Join("networking", inthclparse.StackDir, "vpc")) {
			foundNestedVPC = true
			break
		}
	}

	require.True(t, foundNestedVPC, "generated nested stack unit vpc must be present in find output")

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

	nestedVPCGen := filepath.Join(rootPath, inthclparse.StackDir, "networking", inthclparse.StackDir, "vpc")
	vpcOutput, _, outputErr := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt output -json vpc_id --experiment stack-dependencies --working-dir "+nestedVPCGen)
	require.NoError(t, outputErr, "nested vpc unit must be applied by run --all discovery")
	assert.Contains(t, vpcOutput, "vpc-from-nested-stack")

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

// TestStackDepsNestedSameNameWithCAS covers a stack named "foo" containing a unit also named "foo": CAS must rewrite the relative source in the catalog stack file without name collisions, and an external sibling unit "bar" depends on the nested foo unit via the supported `${stack.foo.path}/.terragrunt-stack/foo` hand-computed path.
func TestStackDepsNestedSameNameWithCAS(t *testing.T) {
	t.Parallel()

	if !helpers.IsExperimentMode(t) {
		t.Skip("Experiment mode is not enabled")
	}

	catalog := helpers.TmpDirWOSymlinks(t)
	liveDir := helpers.TmpDirWOSymlinks(t)

	writeStackDepsFile(t, catalog, "units/foo/main.tf", `resource "local_file" "marker" {
  content  = "Hello from unit foo inside stack foo!"
  filename = "${path.module}/output.txt"
}

output "val" {
  value = "from-stack-foo-unit-foo"
}
`)
	writeStackDepsFile(t, catalog, "units/foo/terragrunt.hcl", ``)

	writeStackDepsFile(t, catalog, "units/bar/main.tf", `variable "val" {
  type        = string
  description = "Value received from the nested foo unit"
}

resource "local_file" "marker" {
  content  = "Received: ${var.val}"
  filename = "${path.module}/input.txt"
}

output "received_val" {
  value = var.val
}
`)
	writeStackDepsFile(t, catalog, "units/bar/terragrunt.hcl", ``)

	writeStackDepsFile(t, catalog, "stacks/foo/terragrunt.stack.hcl", `unit "foo" {
  source = "../..//units/foo"
  path   = "foo"

  update_source_with_cas = true
}
`)

	liveStack := fmt.Sprintf(`stack "foo" {
  source = %s
  path   = "foo"

  update_source_with_cas = true
}

unit "bar" {
  source = %s
  path   = "bar"

  update_source_with_cas = true

  autoinclude {
    dependency "foo_unit" {
      config_path = "${stack.foo.path}/.terragrunt-stack/foo"

      mock_outputs_allowed_terraform_commands = ["validate", "plan"]
      mock_outputs = {
        val = "fake-val"
      }
    }

    inputs = {
      val = dependency.foo_unit.outputs.val
    }
  }
}
`,
		strconv.Quote(filepath.ToSlash(catalog)+"//stacks/foo"),
		strconv.Quote(filepath.ToSlash(catalog)+"//units/bar"),
	)
	require.NoError(t, os.WriteFile(filepath.Join(liveDir, "terragrunt.stack.hcl"), []byte(liveStack), 0644))

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

	nestedFooStackFile := filepath.Join(liveDir, inthclparse.StackDir, "foo", "terragrunt.stack.hcl")
	require.FileExists(t, nestedFooStackFile)

	nestedFooUnitTfgFile := filepath.Join(liveDir, inthclparse.StackDir, "foo", inthclparse.StackDir, "foo", "terragrunt.hcl")
	require.FileExists(t, nestedFooUnitTfgFile)

	nestedFooStackContent, err := os.ReadFile(nestedFooStackFile)
	require.NoError(t, err)
	assert.Contains(t, string(nestedFooStackContent), "cas::", "nested stack file should reference CAS after generation")
	assert.NotContains(t, string(nestedFooStackContent), "../..//units/foo", "relative source must be rewritten by CAS")

	barAutoInclude := filepath.Join(liveDir, inthclparse.StackDir, "bar", inthclparse.AutoIncludeFile)
	require.FileExists(t, barAutoInclude)

	content, err := os.ReadFile(barAutoInclude)
	require.NoError(t, err)
	assert.Contains(t, string(content), `dependency "foo_unit"`)
	assert.Contains(t, string(content), "dependency.foo_unit.outputs.val")

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

	barInputPath := helpers.FindCachedFile(t, filepath.Join(liveDir, inthclparse.StackDir, "bar"), "input.txt")
	barInputContent, err := os.ReadFile(barInputPath)
	require.NoError(t, err)
	assert.Equal(t, "Received: from-stack-foo-unit-foo", string(barInputContent))

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

// TestStackDepsRelativeCatalogSourceRewrittenByCAS pins the supported flow for relative source paths inside catalog stack files: under the cas + stack-dependencies experiments, a nested catalog stack file using `source = "../..//units/foo"` is rewritten by CAS to a cas:: reference, so the generated copy under .terragrunt-stack is self-contained and does not require any sidecar metadata.
func TestStackDepsRelativeCatalogSourceRewrittenByCAS(t *testing.T) {
	t.Parallel()

	if !helpers.IsExperimentMode(t) {
		t.Skip("Experiment mode is not enabled")
	}

	catalog := helpers.TmpDirWOSymlinks(t)
	liveDir := helpers.TmpDirWOSymlinks(t)

	writeStackDepsFile(t, catalog, "units/baz/main.tf", `output "value" { value = "baz" }`)
	writeStackDepsFile(t, catalog, "units/baz/terragrunt.hcl", ``)

	writeStackDepsFile(t, catalog, "stacks/inner/terragrunt.stack.hcl", `unit "baz" {
  source = "../..//units/baz"
  path   = "baz"

  update_source_with_cas = true
}
`)

	liveStack := fmt.Sprintf(`stack "inner" {
  source = %s
  path   = "inner"

  update_source_with_cas = true
}
`, strconv.Quote(filepath.ToSlash(catalog)+"//stacks/inner"))
	require.NoError(t, os.WriteFile(filepath.Join(liveDir, "terragrunt.stack.hcl"), []byte(liveStack), 0644))

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

	nestedStackFile := filepath.Join(liveDir, inthclparse.StackDir, "inner", "terragrunt.stack.hcl")
	require.FileExists(t, nestedStackFile)

	// CAS must rewrite the relative source in the copied catalog stack file so the generated copy is self-contained.
	body, err := os.ReadFile(nestedStackFile)
	require.NoError(t, err)
	assert.Contains(t, string(body), "cas::", "nested stack file must reference CAS after generation")
	assert.NotContains(t, string(body), "../..//units/baz", "relative source must be rewritten by CAS")

	// No sidecar file should be written next to the copied stack.
	assert.NoFileExists(t, filepath.Join(liveDir, inthclparse.StackDir, "inner", ".terragrunt-stack-origin"),
		"sidecar must not be written; CAS is the supported source-resolution path")

	// And the nested unit must materialize under the recursion target.
	require.FileExists(t, filepath.Join(liveDir, inthclparse.StackDir, "inner", inthclparse.StackDir, "baz", "terragrunt.hcl"))
}

// writeStackDepsFile writes body to root/rel, creating parent dirs as needed; test fails on any error.
func writeStackDepsFile(t *testing.T, root, rel, body string) {
	t.Helper()

	full := filepath.Join(root, rel)
	require.NoError(t, os.MkdirAll(filepath.Dir(full), 0755))
	require.NoError(t, os.WriteFile(full, []byte(body), 0644))
}

// TestStackDepsNoDependenciesBaseline verifies the baseline case: the experiment
// is enabled and the stack has multiple units but no autoinclude. Generation must
// emit no autoinclude files and the stack must apply/destroy.
func TestStackDepsNoDependenciesBaseline(t *testing.T) {
	t.Parallel()

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

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

	// No autoinclude declared anywhere: generation must not emit autoinclude files.
	assert.NoFileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "alpha", inthclparse.AutoIncludeFile))
	assert.NoFileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "beta", inthclparse.AutoIncludeFile))

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

	require.FileExists(t, helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "alpha"), "marker.txt"))
	require.FileExists(t, helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "beta"), "marker.txt"))

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

// TestStackDepsAutoIncludeWinsOnConflict pins the documented merge precedence:
// when the unit's own terragrunt.hcl and the autoinclude both set the same
// input, the autoinclude value wins.
func TestStackDepsAutoIncludeWinsOnConflict(t *testing.T) {
	t.Parallel()

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

	helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stack-dependencies --working-dir "+rootPath)
	require.FileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "target", inthclparse.AutoIncludeFile))

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

	markerPath := helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "target"), "marker.txt")
	markerContent, err := os.ReadFile(markerPath)
	require.NoError(t, err)
	assert.Equal(t, "received: from-autoinclude", string(markerContent),
		"autoinclude value must win over the unit's own inputs on conflict")

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

// TestStackDepsAutoIncludeArbitraryOverride verifies that an autoinclude may patch
// a unit with config beyond dependency/inputs. Here a generate block is injected;
// it must be preserved in the generated file and emit its file on apply.
func TestStackDepsAutoIncludeArbitraryOverride(t *testing.T) {
	t.Parallel()

	helpers.CleanupTerraformFolder(t, testFixtureStackDepsArbitraryOverride)
	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackDepsArbitraryOverride)
	rootPath := filepath.Join(tmpEnvPath, testFixtureStackDepsArbitraryOverride, "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, "gen", inthclparse.AutoIncludeFile)
	require.FileExists(t, autoIncludePath)
	content, err := os.ReadFile(autoIncludePath)
	require.NoError(t, err)
	assert.Contains(t, string(content), `generate "injected"`,
		"non-dependency blocks in autoinclude must be preserved in the generated file")

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

	injectedPath := helpers.FindCachedFile(t, filepath.Join(rootPath, inthclparse.StackDir, "gen"), "injected.tf")
	require.FileExists(t, injectedPath, "generate block injected via autoinclude must produce its file")

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

// TestStackDepsMockOutputsAtPlan exercises mock_outputs functionally: with no
// prior apply, planning the dependent unit must succeed against the dependency's
// mock_outputs (allowed for "plan"), rather than failing on unavailable outputs.
func TestStackDepsMockOutputsAtPlan(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)

	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt run --all --non-interactive --experiment stack-dependencies --working-dir "+rootPath+" -- plan")
	require.NoError(t, err, "plan must succeed using mock_outputs before any apply; stderr=%s", stderr)
}

// TestStackDepsStackLevelAutoInclude verifies stack-level autoinclude generation.
// A stack does not have dependencies (only units do), so a stack-block autoinclude
// injects valid terragrunt.stack.hcl content (here an extra unit). Generation must
// produce terragrunt.autoinclude.stack.hcl (not the unit filename) in the generated
// nested-stack directory, preserving that content, without breaking discovery of
// the nested units.
func TestStackDepsStackLevelAutoInclude(t *testing.T) {
	t.Parallel()

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

	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)
	require.NoError(t, runner.WithWorkDir(gitPath).Init(t.Context()))

	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)

	stackAutoInc := filepath.Join(rootPath, inthclparse.StackDir, "networking", inthclparse.AutoIncludeStackFile)
	require.FileExists(t, stackAutoInc, "a stack-block autoinclude must generate terragrunt.autoinclude.stack.hcl")
	assert.NoFileExists(t, filepath.Join(rootPath, inthclparse.StackDir, "networking", inthclparse.AutoIncludeFile),
		"stack autoinclude must not be written with the unit-level filename")

	content, err := os.ReadFile(stackAutoInc)
	require.NoError(t, err)
	assert.Contains(t, string(content), `unit "extra"`, "the injected stack content must be preserved in the generated file")

	// Generation must not break discovery: the nested vpc unit must still enumerate.
	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt find --json --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

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

	foundVPC := false

	for _, c := range components {
		if c.Type == "unit" && filepath.Base(c.Path) == "vpc" {
			foundVPC = true
			break
		}
	}

	assert.True(t, foundVPC, "nested vpc unit must be discoverable")
}

// TestStackDepsStackLevelAutoIncludeMergedIntoNestedStack verifies that a unit
// injected via a stack-level autoinclude materializes in the nested stack, once
// the generated terragrunt.autoinclude.stack.hcl is merged into the nested
// terragrunt.stack.hcl the same way a unit's terragrunt.autoinclude.hcl is merged
// into its terragrunt.hcl.
func TestStackDepsStackLevelAutoIncludeMergedIntoNestedStack(t *testing.T) {
	t.Parallel()

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

	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)
	require.NoError(t, runner.WithWorkDir(gitPath).Init(t.Context()))

	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)

	// The injected unit must be merged into the nested stack and generated.
	nestedStackDir := filepath.Join(rootPath, inthclparse.StackDir, "networking", inthclparse.StackDir)
	assert.DirExists(t, filepath.Join(nestedStackDir, "extra"),
		"the unit injected via the stack-level autoinclude must materialize in the nested stack")

	// The merged unit must also be discoverable, proving the merge feeds downstream
	// tooling (find/run), not just the on-disk directory copy.
	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt find --json --experiment stack-dependencies --working-dir "+rootPath)
	require.NoError(t, err)

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

	foundExtra := false

	for _, c := range components {
		if c.Type == "unit" && filepath.Base(c.Path) == "extra" {
			foundExtra = true
			break
		}
	}

	assert.True(t, foundExtra, "the autoinclude-injected extra unit must be discoverable")
}

// TestStackDepsStackLevelAutoIncludeInjectsNestedStack verifies that a stack-level
// autoinclude can inject a nested stack (not just a unit). The injected stack must
// itself be re-discovered and expanded by the level-by-level generator, so its units
// materialize one level deeper than the autoinclude target.
func TestStackDepsStackLevelAutoIncludeInjectsNestedStack(t *testing.T) {
	t.Parallel()

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

	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)
	require.NoError(t, runner.WithWorkDir(gitPath).Init(t.Context()))

	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)

	// The autoinclude-injected "more" stack must merge into the networking stack and then
	// expand its own units a level deeper.
	moreStackDir := filepath.Join(rootPath, inthclparse.StackDir, "networking", inthclparse.StackDir, "more")
	assert.DirExists(t, moreStackDir,
		"the stack injected via the stack-level autoinclude must materialize in the nested stack")
	assert.DirExists(t, filepath.Join(moreStackDir, inthclparse.StackDir, "deep"),
		"the autoinclude-injected nested stack must expand its own units")
}

// TestStackDepsCrossLevelViaValues verifies a dependency between units at
// different stack levels. The parent passes unit.producer.path down to the child
// stack via values, and a unit inside the child stack consumes it as its
// autoinclude dependency config_path; the consumer must receive the producer's
// real output after apply.
func TestStackDepsCrossLevelViaValues(t *testing.T) {
	t.Parallel()

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

	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)
	require.NoError(t, runner.WithWorkDir(gitPath).Init(t.Context()))

	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)

	consumerDir := filepath.Join(rootPath, inthclparse.StackDir, "child", inthclparse.StackDir, "consumer")
	autoInc := filepath.Join(consumerDir, inthclparse.AutoIncludeFile)
	require.FileExists(t, autoInc, "consumer in the child stack must get an autoinclude wired to the parent's producer")

	content, err := os.ReadFile(autoInc)
	require.NoError(t, err)
	assert.Contains(t, string(content), `dependency "producer"`)

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

	inputPath := helpers.FindCachedFile(t, consumerDir, "input.txt")
	inputContent, err := os.ReadFile(inputPath)
	require.NoError(t, err)
	assert.Equal(t, "consumer received: produced-across-levels", string(inputContent),
		"consumer must receive the producer's output across stack levels")

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

// TestStackDepsAutoIncludeUnknownTarget is the negative path: an autoinclude
// dependency whose config_path references an undeclared unit must fail stack
// generate with a clean error, not a panic.
func TestStackDepsAutoIncludeUnknownTarget(t *testing.T) {
	t.Parallel()

	tmpDir := t.TempDir()
	stackDir := filepath.Join(tmpDir, "live")
	require.NoError(t, os.MkdirAll(stackDir, 0755))

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

	stackHCL := `
unit "a" {
  source = "../catalog/units/a"
  path   = "a"

  autoinclude {
    dependency "missing" {
      config_path = unit.does_not_exist.path
    }
  }
}
`
	require.NoError(t, os.WriteFile(filepath.Join(stackDir, "terragrunt.stack.hcl"), []byte(stackHCL), 0644))

	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t,
		"terragrunt stack generate --experiment stack-dependencies --working-dir "+stackDir)
	require.Error(t, err, "autoinclude referencing an undeclared unit must fail generation")
	assert.NotContains(t, stderr, "panic", "the failure must be a clean error, not a panic")
}
