package hclparse_test

import (
	"path/filepath"
	"strings"
	"sync/atomic"
	"testing"

	"github.com/gruntwork-io/terragrunt/internal/hclparse"
	"github.com/gruntwork-io/terragrunt/internal/vfs"
	"github.com/hashicorp/hcl/v2"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/zclconf/go-cty/cty"
	"github.com/zclconf/go-cty/cty/function"
	"github.com/zclconf/go-cty/cty/function/stdlib"
)

const (
	testStackDir = "/test/stack"
	testGenDir   = "/test/stack/.terragrunt-stack/app"
)

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

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "db" {
  source = "../catalog/units/db"
  path   = "db"
}
`

	result, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)
	require.Len(t, result.Units, 2)
	assert.Equal(t, "vpc", result.Units[0].Name)

	assert.Equal(t, "vpc", result.Units[0].Path)
	assert.Equal(t, "db", result.Units[1].Name)
	assert.Equal(t, "db", result.Units[1].Path)
	assert.Empty(t, result.AutoIncludes)
}

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

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "db" {
  source = "../catalog/units/db"
  path   = "db"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
    }
  }
}
`

	result, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)
	require.Len(t, result.Units, 2)

	assert.NotContains(t, result.AutoIncludes, "vpc")

	resolved, ok := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "db")]
	require.True(t, ok)
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(t, "vpc", resolved.Dependencies[0].Name)
	assert.Equal(
		t,
		filepath.Join(testStackDir, ".terragrunt-stack", "vpc"),
		resolved.Dependencies[0].ConfigPath,
	)
	assert.NotNil(t, resolved.RawBody)
}

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

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "rds" {
  source = "../catalog/units/rds"
  path   = "rds"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
    }

    dependency "rds" {
      config_path = unit.rds.path
    }
  }
}
`

	result, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

	resolved, ok := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "app")]
	require.True(t, ok)
	require.Len(t, resolved.Dependencies, 2)
	assert.Equal(t, "vpc", resolved.Dependencies[0].Name)
	assert.Equal(t, "rds", resolved.Dependencies[1].Name)
}

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

	src := `
stack "networking" {
  source = "../stacks/networking"
  path   = "networking"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "networking" {
      config_path = stack.networking.path
    }
  }
}
`

	result, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

	resolved, ok := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "app")]
	require.True(t, ok)
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(t, "networking", resolved.Dependencies[0].Name)
	assert.Equal(
		t,
		filepath.Join(testStackDir, ".terragrunt-stack", "networking"),
		resolved.Dependencies[0].ConfigPath,
	)
}

// TestParseStackFile_ComponentRefInValuesWithSiblingAutoInclude reproduces the
// regression where a stack block's values referencing unit.<name>.path failed with
// "There is no variable named \"unit\"" whenever the same file contained an
// autoinclude block: the phase-4 decode evaluated values before the component refs
// were published.
func TestParseStackFile_ComponentRefInValuesWithSiblingAutoInclude(t *testing.T) {
	t.Parallel()

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

stack "b" {
  source = "../catalog/stacks/b"
  path   = "b"

  values = {
    a_path = unit.a.path
  }
}

unit "c" {
  source = "../catalog/units/c"
  path   = "c"

  autoinclude {
    dependency "a" {
      config_path = unit.a.path
    }

    inputs = {
      v = dependency.a.outputs.v
    }
  }
}
`

	result, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)
	require.Len(t, result.Units, 2)
	require.Len(t, result.Stacks, 1)

	require.NotNil(t, result.Stacks[0].Values)
	aPath := result.Stacks[0].Values.GetAttr("a_path")
	assert.Equal(t, cty.StringVal(filepath.Join(testStackDir, ".terragrunt-stack", "a")), aPath,
		"stack values must resolve unit.a.path even when a sibling autoinclude block is present")

	resolved, ok := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "c")]
	require.True(t, ok)
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(
		t,
		filepath.Join(testStackDir, ".terragrunt-stack", "a"),
		resolved.Dependencies[0].ConfigPath,
	)
}

// TestParseStackFile_UnitValuesStackRefWithSiblingAutoInclude covers the mirrored
// shape: a unit block's values referencing stack.<name>.path alongside an
// autoinclude elsewhere in the file.
func TestParseStackFile_UnitValuesStackRefWithSiblingAutoInclude(t *testing.T) {
	t.Parallel()

	src := `
stack "networking" {
  source = "../stacks/networking"
  path   = "networking"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  values = {
    net_path = stack.networking.path
  }

  autoinclude {
    dependency "networking" {
      config_path = stack.networking.path
    }
  }
}
`

	result, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)
	require.Len(t, result.Units, 1)

	require.NotNil(t, result.Units[0].Values)
	netPath := result.Units[0].Values.GetAttr("net_path")
	assert.Equal(
		t,
		cty.StringVal(filepath.Join(testStackDir, ".terragrunt-stack", "networking")),
		netPath,
		"unit values must resolve stack.networking.path even when an autoinclude block is present",
	)
}

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

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}
`

	result, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)
	require.Len(t, result.Units, 1)
	assert.Empty(t, result.AutoIncludes)
}

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

	src := `
locals {
  base   = {}
  broken = local.base.missing
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"
}
`

	_, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.Error(t, err)

	var evalErr hclparse.LocalEvalError
	require.ErrorAs(t, err, &evalErr)
	assert.Equal(t, "broken", evalErr.Name)
	assert.Contains(t, err.Error(), "Unsupported attribute")

	var cycleErr hclparse.LocalsCycleError
	assert.NotErrorAs(t, err, &cycleErr)
}

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

	err := hclparse.GenerateAutoIncludeFile(vfs.NewMemMapFS(), nil, testGenDir, nil, nil)
	assert.NoError(t, err)
}

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

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path

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

    inputs = {
      val = dependency.vpc.outputs.val
    }
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      srcBytes,
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

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

	appDir := testGenDir

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(appDir, hclparse.AutoIncludeFile))
	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, `"fake-val"`)
	assert.Contains(t, content, "dependency.vpc.outputs.val")
}

// TestGenerateAutoIncludeFile_ResolvesFunctionsUnlessUnresolvable pins how the generator treats function calls in an
// autoinclude, across inputs, generate, remote_state, and mock_outputs: a resolvable call (upper) is evaluated to its
// literal, while a call that cannot be evaluated at generate time (get_unresolvable, unregistered here, so it errors)
// is kept verbatim with its stack-level local.* argument still rendered, so no stack-scoped reference leaks into the
// generated unit. A dependency.*.outputs.* reference is always kept verbatim.
func TestGenerateAutoIncludeFile_ResolvesFunctionsUnlessUnresolvable(t *testing.T) {
	t.Parallel()

	src := `
locals {
  tag = "t1"
}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path

      mock_outputs = {
        rendered = upper(local.tag)
      }
    }

    inputs = {
      dep_ref  = dependency.vpc.outputs.id
      local_in = upper(local.tag)
      fn_in    = get_unresolvable(local.tag)
    }

    generate "backend" {
      path     = "backend.tf"
      contents = upper(local.tag)
    }

    remote_state {
      backend = "local"

      config = {
        path = get_unresolvable("here")
      }
    }
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(fs, &hclparse.ParseStackFileInput{
		Src:       srcBytes,
		Filename:  "terragrunt.stack.hcl",
		StackDir:  testStackDir,
		Functions: map[string]function.Function{"upper": stdlib.UpperFunc},
	})
	require.NoError(t, err)

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

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, testGenDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(testGenDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	// A resolvable function renders to its literal everywhere (mock_outputs, inputs, generate).
	assert.Contains(
		t,
		content,
		`"T1"`,
		"a resolvable function must render to its literal at generate time",
	)
	assert.NotContains(t, content, "upper(", "a resolvable function must not be left verbatim")
	assert.NotContains(t, content, "local.tag", "a stack local must not be left verbatim anywhere")
	// An unresolvable function stays verbatim, but its stack local argument still renders so the generated unit never sees local.*.
	assert.Contains(
		t,
		content,
		`get_unresolvable("t1")`,
		"an unresolvable function stays verbatim while its local argument renders",
	)
	assert.Contains(
		t,
		content,
		"dependency.vpc.outputs.id",
		"a dependency reference in inputs must stay verbatim",
	)
	// The remote_state config path is qualified by its attribute name so it pins that arm verbatim independently of the inputs occurrence.
	assert.Contains(
		t,
		content,
		`path = get_unresolvable("here")`,
		"an unresolvable function in remote_state stays verbatim for the unit",
	)
}

// TestParseStackFile_ConfigPathEvalErrorIsAnchoredAtConfigPath pins that a config_path that fails to evaluate
// yields one clear "Invalid config_path" diagnostic anchored at the config_path expression, not at an unrelated block.
func TestParseStackFile_ConfigPathEvalErrorIsAnchoredAtConfigPath(t *testing.T) {
	t.Parallel()

	src := `
locals {
  base = {}
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "dep" {
      config_path = local.base.missing
    }
  }
}
`
	_, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
		Src: []byte(src), Filename: "terragrunt.stack.hcl", StackDir: testStackDir,
	})

	var diags hcl.Diagnostics

	require.ErrorAs(t, err, &diags)
	require.True(t, diags.HasErrors())
	assert.Equal(t, "Invalid config_path", diags[0].Summary)
	assert.Contains(t, diags[0].Detail, `dependency "dep" config_path could not be evaluated`)
	require.NotNil(
		t,
		diags[0].Subject,
		"the diagnostic must be anchored at the config_path expression",
	)

	srcLines := strings.Split(src, "\n")
	require.LessOrEqual(t, diags[0].Subject.Start.Line, len(srcLines))
	assert.Contains(t, srcLines[diags[0].Subject.Start.Line-1], "config_path",
		"the error must point at the config_path line, not an unrelated block")
}

// TestGenerateAutoIncludeFile_MockOutputsRendersFunction pins that a resolvable function call in mock_outputs is
// evaluated in the stack file context and rendered to a literal at generate time.
func TestGenerateAutoIncludeFile_MockOutputsRendersFunction(t *testing.T) {
	t.Parallel()

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path

      mock_outputs = {
        name = upper("vpc")
      }
    }
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(fs, &hclparse.ParseStackFileInput{
		Src:       srcBytes,
		Filename:  "terragrunt.stack.hcl",
		StackDir:  testStackDir,
		Functions: map[string]function.Function{"upper": stdlib.UpperFunc},
	})
	require.NoError(t, err)

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

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, testGenDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(testGenDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(
		t,
		content,
		`"VPC"`,
		"a function in mock_outputs must render to its literal at generate time",
	)
	assert.NotContains(t, content, "upper(", "a function in mock_outputs must not be left verbatim")
}

// TestGenerateAutoIncludeFile_MockOutputsDefersDependencyButRendersFunction pins that in mock_outputs a resolvable
// function renders to its literal while a dependency.* reference still stays verbatim through deferredRoots.
func TestGenerateAutoIncludeFile_MockOutputsDefersDependencyButRendersFunction(t *testing.T) {
	t.Parallel()

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path

      mock_outputs = {
        name    = upper("vpc")
        chained = dependency.other.outputs.id
      }
    }
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(fs, &hclparse.ParseStackFileInput{
		Src:       srcBytes,
		Filename:  "terragrunt.stack.hcl",
		StackDir:  testStackDir,
		Functions: map[string]function.Function{"upper": stdlib.UpperFunc},
	})
	require.NoError(t, err)

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

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, testGenDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(testGenDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(
		t,
		content,
		`"VPC"`,
		"a function in mock_outputs renders to its literal even though dependency.* in the same block defers",
	)
	assert.NotContains(t, content, "upper(", "a function in mock_outputs must not be left verbatim")
	assert.Contains(
		t,
		content,
		"dependency.other.outputs.id",
		"a dependency reference in mock_outputs stays verbatim through deferredRoots",
	)
}

// TestGenerateAutoIncludeFile_MockOutputsResolvesLocal pins that a dependency's mock_outputs resolves
// stack-level references (local.*) to literals at generate time.
func TestGenerateAutoIncludeFile_MockOutputsResolvesLocal(t *testing.T) {
	t.Parallel()

	src := `
locals {
  account = {
    name   = "my-account"
    region = "eu-west-1"
  }
}

unit "account" {
  source = "../catalog/units/account"
  path   = "account"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "account" {
      config_path = unit.account.path

      mock_outputs = {
        name   = local.account.name
        region = local.account.region
      }
    }
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      srcBytes,
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

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

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, testGenDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(testGenDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	// Dependency path: config_path = unit.account.path resolves to the sibling unit at generate time.
	assert.Contains(
		t,
		content,
		`"../account"`,
		"the dependency config_path (unit.<name>.path) must resolve at generate time",
	)
	// Dependency mock outputs: stack-level locals resolve to literals at generate time.
	assert.Contains(
		t,
		content,
		`"my-account"`,
		"a local in mock_outputs must resolve at generate time",
	)
	assert.Contains(
		t,
		content,
		`"eu-west-1"`,
		"a local in mock_outputs must resolve at generate time",
	)
	assert.NotContains(
		t,
		content,
		"local.account.name",
		"the local reference must not be left literal",
	)
	assert.NotContains(
		t,
		content,
		"local.account.region",
		"the local reference must not be left literal",
	)
}

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

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "rds" {
  source = "../catalog/units/rds"
  path   = "rds"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
    }

    dependency "rds" {
      config_path = unit.rds.path
    }

    inputs = {
      vpc_id = dependency.vpc.outputs.vpc_id
      db_url = dependency.rds.outputs.endpoint
    }
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      srcBytes,
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

	resolved := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "app")]
	require.NotNil(t, resolved)

	appDir := testGenDir

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(appDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(t, content, `dependency "vpc"`)
	assert.Contains(t, content, `dependency "rds"`)
	assert.Contains(t, content, "dependency.vpc.outputs.vpc_id")
	assert.Contains(t, content, "dependency.rds.outputs.endpoint")
}

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

	fs := vfs.NewMemMapFS()

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
    }

    inputs = {
      val = dependency.vpc.outputs.val
    }
  }
}
`
	srcBytes := []byte(src)

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      srcBytes,
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

	resolved := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "app")]
	require.NotNil(t, resolved)

	appDir := filepath.Join(testStackDir, ".terragrunt-stack", "app")

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(appDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(t, content, `dependency "vpc"`)
	assert.Contains(t, content, "../vpc")
	assert.NotContains(t, content, testStackDir)
}

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

	src := `
locals {
  env = "production"
}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
    }

    env_name = local.env

    inputs = {
      vpc_id = dependency.vpc.outputs.vpc_id
    }
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      srcBytes,
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

	resolved := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "app")]
	require.NotNil(t, resolved)

	appDir := testGenDir

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(appDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(t, content, "production")
	assert.NotContains(t, content, "local.env")
	assert.Contains(t, content, "dependency.vpc.outputs.vpc_id")
}

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

	src := `
locals {
  env    = "production"
  region = "us-east-1"
}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path

      mock_outputs_allowed_terraform_commands = ["plan"]
      mock_outputs = {
        vpc_id = "mock-vpc-id"
      }
    }

    inputs = {
      env    = local.env
      region = local.region
      vpc_id = dependency.vpc.outputs.vpc_id
    }

    env_label = local.env

    name_tag = "${local.env}-${dependency.vpc.outputs.vpc_id}-app"
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      srcBytes,
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

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

	appDir := testGenDir

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(appDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)

	// Stack-level locals resolve at generate time everywhere, including inside the inputs zone.
	assert.Contains(t, content, `"production"`, "local.env renders at generate time")
	assert.NotContains(t, content, "local.env", "a stack local must not be left verbatim")
	assert.Contains(
		t,
		content,
		`"us-east-1"`,
		"local.region renders at generate time, including inside inputs",
	)
	assert.NotContains(t, content, "local.region", "a stack local must not be left verbatim")

	// A dependency reference stays verbatim; a template renders its local part and defers the dependency part.
	assert.Contains(
		t,
		content,
		"dependency.vpc.outputs.vpc_id",
		"a dependency reference stays verbatim",
	)
	assert.Contains(
		t,
		content,
		"production-${dependency.vpc.outputs.vpc_id}-app",
		"a template renders locals and defers dependency.*",
	)
	assert.Contains(t, content, `dependency "vpc"`)
	assert.Contains(t, content, "mock_outputs")
}

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

	src := `
locals {
  a = local.b
  b = local.a
}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}
`

	_, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.Error(t, err)

	var cycleErr hclparse.LocalsCycleError
	require.ErrorAs(t, err, &cycleErr)
	assert.Len(t, cycleErr.Names, 2)
}

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

	src := `
locals {}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}
`

	result, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)
	require.Len(t, result.Units, 1)
}

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

	src := `
locals {
  vpc_path = unit.vpc.path
}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}
`

	_, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
		Src:      []byte(src),
		Filename: "terragrunt.stack.hcl",
		StackDir: testStackDir,
	})

	var localErr hclparse.LocalEvalError

	require.ErrorAs(t, err, &localErr)
	assert.Equal(t, "vpc_path", localErr.Name)
	assert.Contains(t, localErr.Error(), `There is no variable named "unit"`)
}

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

	src := `
locals {
  env    = "staging"
  region = "eu-west-1"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.app.path
    }

    env_tag    = local.env
    region_tag = local.region
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      srcBytes,
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

	resolved := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "app")]
	require.NotNil(t, resolved)

	appDir := testGenDir

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, appDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(appDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(t, content, "staging")
	assert.Contains(t, content, "eu-west-1")
	assert.NotContains(t, content, "local.env")
	assert.NotContains(t, content, "local.region")
}

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

	// A stack autoinclude injects valid terragrunt.stack.hcl content (unit/stack blocks), since stacks do not have dependencies.
	src := `
stack "networking" {
  source = "../stacks/networking"
  path   = "networking"

  autoinclude {
    unit "extra" {
      source = "../catalog/units/extra"
      path   = "extra"
    }
  }
}
`

	result, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

	resolved, ok := result.AutoIncludes[hclparse.AutoIncludeKey("stack", "networking")]
	require.True(t, ok, "stack networking should have autoinclude")
	assert.Equal(
		t,
		hclparse.KindStack,
		resolved.Kind,
		"Kind must be KindStack so the generator picks terragrunt.autoinclude.stack.hcl",
	)
}

// A stack autoinclude must not carry a dependency block: stacks do not have dependencies, and the
// generated terragrunt.autoinclude.stack.hcl is strictly decoded as unit/stack blocks only, so the
// rejection is raised at parse/generate time with a clear message instead of a later discovery decode error.
func TestParseStackFile_StackAutoIncludeRejectsDependency(t *testing.T) {
	t.Parallel()

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

stack "networking" {
  source = "../stacks/networking"
  path   = "networking"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
    }
  }
}
`

	_, err := hclparse.ParseStackFile(
		vfs.NewMemMapFS(),
		&hclparse.ParseStackFileInput{
			Src:      []byte(src),
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.Error(t, err, "a stack autoinclude carrying a dependency block must be rejected")
	assert.Contains(t, err.Error(), "dependency block is not allowed in a stack autoinclude")
}

// Pins the kind-to-filename mapping: unit autoincludes write terragrunt.autoinclude.hcl, stack autoincludes write terragrunt.autoinclude.stack.hcl.
func TestGenerateAutoIncludeFile_FilenameByKind(t *testing.T) {
	t.Parallel()

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

stack "networking" {
  source = "../stacks/networking"
  path   = "networking"

  autoinclude {
    unit "extra" {
      source = "../catalog/units/extra"
      path   = "extra"
    }
  }
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
    }
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      srcBytes,
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)

	unitResolved := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "app")]
	require.NotNil(t, unitResolved)

	stackResolved := result.AutoIncludes[hclparse.AutoIncludeKey("stack", "networking")]
	require.NotNil(t, stackResolved)

	unitDir := filepath.Join(testStackDir, ".terragrunt-stack", "app")
	require.NoError(
		t,
		hclparse.GenerateAutoIncludeFile(fs, unitResolved, unitDir, srcBytes, unitResolved.EvalCtx),
	)

	unitExists, err := vfs.FileExists(fs, filepath.Join(unitDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)
	assert.True(t, unitExists, "unit autoinclude must be written as terragrunt.autoinclude.hcl")

	stackExists, err := vfs.FileExists(fs, filepath.Join(unitDir, hclparse.AutoIncludeStackFile))
	require.NoError(t, err)
	assert.False(
		t,
		stackExists,
		"unit autoinclude must NOT be written as terragrunt.autoinclude.stack.hcl",
	)

	stackDir := filepath.Join(testStackDir, ".terragrunt-stack", "networking")
	require.NoError(
		t,
		hclparse.GenerateAutoIncludeFile(
			fs,
			stackResolved,
			stackDir,
			srcBytes,
			stackResolved.EvalCtx,
		),
	)

	stackFileExists, err := vfs.FileExists(
		fs,
		filepath.Join(stackDir, hclparse.AutoIncludeStackFile),
	)
	require.NoError(t, err)
	assert.True(
		t,
		stackFileExists,
		"stack autoinclude must be written as terragrunt.autoinclude.stack.hcl",
	)

	stackUnitFileExists, err := vfs.FileExists(
		fs,
		filepath.Join(stackDir, hclparse.AutoIncludeFile),
	)
	require.NoError(t, err)
	assert.False(
		t,
		stackUnitFileExists,
		"stack autoinclude must NOT be written as terragrunt.autoinclude.hcl",
	)
}

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

	assert.Equal(
		t,
		"terragrunt.autoinclude.hcl",
		hclparse.AutoIncludeFileNameForKind(hclparse.KindUnit),
	)
	assert.Equal(
		t,
		"terragrunt.autoinclude.stack.hcl",
		hclparse.AutoIncludeFileNameForKind(hclparse.KindStack),
	)
}

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

	assert.PanicsWithValue(
		t,
		`hclparse.AutoIncludeFileNameForKind: unknown kind "" (expected "unit" or "stack")`,
		func() {
			hclparse.AutoIncludeFileNameForKind("")
		},
	)
	assert.PanicsWithValue(
		t,
		`hclparse.AutoIncludeFileNameForKind: unknown kind "unknown" (expected "unit" or "stack")`,
		func() {
			hclparse.AutoIncludeFileNameForKind("unknown")
		},
	)
}

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

	fs := vfs.NewMemMapFS()

	mainSrc := `
include "shared" {
  path = "shared.hcl"
}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}
`
	includeSrc := `
unit "vpc" {
  source = "../catalog/units/vpc2"
  path   = "vpc2"
}
`

	require.NoError(t, fs.MkdirAll(testStackDir, 0755))
	require.NoError(
		t,
		vfs.WriteFile(fs, filepath.Join(testStackDir, "shared.hcl"), []byte(includeSrc), 0644),
	)

	_, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      []byte(mainSrc),
			Filename: filepath.Join(testStackDir, "terragrunt.stack.hcl"),
			StackDir: testStackDir,
		},
	)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "duplicate unit name")
}

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

	fs := vfs.NewMemMapFS()

	mainSrc := `
include "shared" {
  path = "shared.hcl"
}

stack "infra" {
  source = "../stacks/infra"
  path   = "infra"
}
`
	includeSrc := `
stack "infra" {
  source = "../stacks/infra2"
  path   = "infra2"
}
`

	require.NoError(t, fs.MkdirAll(testStackDir, 0755))
	require.NoError(
		t,
		vfs.WriteFile(fs, filepath.Join(testStackDir, "shared.hcl"), []byte(includeSrc), 0644),
	)

	_, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      []byte(mainSrc),
			Filename: filepath.Join(testStackDir, "terragrunt.stack.hcl"),
			StackDir: testStackDir,
		},
	)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "duplicate stack name")
}

// A top-level unit named "path" must parse cleanly and `unit.path.path` must resolve via autoinclude.
func TestParseStackFile_UnitNamedPathResolvesViaAutoInclude(t *testing.T) {
	t.Parallel()

	src := `
unit "path" {
  source = "../catalog/units/foo"
  path   = "p"
}

unit "consumer" {
  source = "../catalog/units/c"
  path   = "c"

  autoinclude {
    dependency "p" {
      config_path = unit.path.path
    }
  }
}
`

	result, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
		Src: []byte(src), Filename: "terragrunt.stack.hcl", StackDir: testStackDir,
	})
	require.NoError(t, err)
	require.Len(t, result.Units, 2)

	resolved := result.AutoIncludes[hclparse.AutoIncludeKey(hclparse.KindUnit, "consumer")]
	require.NotNil(t, resolved)
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(
		t,
		filepath.Join(testStackDir, hclparse.StackDir, "p"),
		resolved.Dependencies[0].ConfigPath,
	)
}

// A top-level stack named "path" must parse cleanly.
func TestParseStackFile_StackNamedPathIsAccepted(t *testing.T) {
	t.Parallel()

	src := `
stack "path" {
  source = "../catalog/stacks/path"
  path   = "p"
}
`

	result, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
		Src: []byte(src), Filename: "terragrunt.stack.hcl", StackDir: testStackDir,
	})
	require.NoError(t, err)
	require.Len(t, result.Stacks, 1)
	assert.Equal(t, "path", result.Stacks[0].Name)
}

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

	fs := vfs.NewMemMapFS()

	mainSrc := `
include "bad" {
  path = "bad.hcl"
}
`
	includeSrc := `
locals {
  env = "prod"
}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}
`

	require.NoError(t, fs.MkdirAll(testStackDir, 0755))
	require.NoError(
		t,
		vfs.WriteFile(fs, filepath.Join(testStackDir, "bad.hcl"), []byte(includeSrc), 0644),
	)

	_, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      []byte(mainSrc),
			Filename: filepath.Join(testStackDir, "terragrunt.stack.hcl"),
			StackDir: testStackDir,
		},
	)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "must not define locals")
}

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

	fs := vfs.NewMemMapFS()

	mainSrc := `
include "nested" {
  path = "nested.hcl"
}
`
	includeSrc := `
include "deeper" {
  path = "deeper.hcl"
}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}
`

	require.NoError(t, fs.MkdirAll(testStackDir, 0755))
	require.NoError(
		t,
		vfs.WriteFile(fs, filepath.Join(testStackDir, "nested.hcl"), []byte(includeSrc), 0644),
	)

	_, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      []byte(mainSrc),
			Filename: filepath.Join(testStackDir, "terragrunt.stack.hcl"),
			StackDir: testStackDir,
		},
	)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "must not define nested includes")
}

// Locks the per-file SourceBytes invariant: when a unit's autoinclude block lives in an included stack file, GenerateAutoIncludeFile must use the included file's bytes (not the root's) to slice expression text.
func TestParseStackFile_AutoIncludeInsideIncludedFile(t *testing.T) {
	t.Parallel()

	fs := vfs.NewMemMapFS()

	rootSrc := `
include "shared" {
  path = "shared.hcl"
}
`
	includedSrc := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"

  autoinclude {
    dependency "db" {
      config_path = "../db"
    }
  }
}
`

	require.NoError(t, fs.MkdirAll(testStackDir, 0755))
	require.NoError(
		t,
		vfs.WriteFile(fs, filepath.Join(testStackDir, "shared.hcl"), []byte(includedSrc), 0644),
	)

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      []byte(rootSrc),
			Filename: filepath.Join(testStackDir, "terragrunt.stack.hcl"),
			StackDir: testStackDir,
		},
	)
	require.NoError(t, err)
	require.Len(t, result.AutoIncludes, 1)

	resolved, ok := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "vpc")]
	require.True(t, ok, "expected resolved autoinclude for unit 'vpc'")
	require.NotNil(t, resolved)

	// SourceBytes must point at the included file's bytes, not the root's, so the generator can slice expression byte ranges correctly.
	assert.Equal(
		t,
		[]byte(includedSrc),
		resolved.SourceBytes,
		"SourceBytes must equal the included file's bytes",
	)
	assert.NotEqual(
		t,
		[]byte(rootSrc),
		resolved.SourceBytes,
		"SourceBytes must not be the root file's bytes",
	)
}

// Benchmarks

func BenchmarkParseStackFile_Simple(b *testing.B) {
	src := []byte(`
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "db" {
  source = "../catalog/units/db"
  path   = "db"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"
}
`)
	fs := vfs.NewMemMapFS()

	for b.Loop() {
		_, err := hclparse.ParseStackFile(
			fs,
			&hclparse.ParseStackFileInput{
				Src:      src,
				Filename: "terragrunt.stack.hcl",
				StackDir: testStackDir,
			},
		)
		if err != nil {
			require.NoError(b, err)
		}
	}
}

func BenchmarkParseStackFile_WithAutoInclude(b *testing.B) {
	src := []byte(`
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "db" {
  source = "../catalog/units/db"
  path   = "db"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
      mock_outputs = { id = "mock" }
    }

    inputs = {
      vpc_id = dependency.vpc.outputs.id
    }
  }
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
    }

    dependency "db" {
      config_path = unit.db.path
    }

    inputs = {
      vpc_id = dependency.vpc.outputs.id
      db_url = dependency.db.outputs.endpoint
    }
  }
}
`)
	fs := vfs.NewMemMapFS()

	for b.Loop() {
		_, err := hclparse.ParseStackFile(
			fs,
			&hclparse.ParseStackFileInput{
				Src:      src,
				Filename: "terragrunt.stack.hcl",
				StackDir: testStackDir,
			},
		)
		if err != nil {
			require.NoError(b, err)
		}
	}
}

func BenchmarkGenerateAutoIncludeFile(b *testing.B) {
	src := []byte(`
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
      mock_outputs_allowed_terraform_commands = ["plan"]
      mock_outputs = { val = "fake-val" }
    }

    inputs = {
      val = dependency.vpc.outputs.val
    }
  }
}
`)
	fs := vfs.NewMemMapFS()

	result, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      src,
			Filename: "terragrunt.stack.hcl",
			StackDir: testStackDir,
		},
	)
	if err != nil {
		require.NoError(b, err)
	}

	resolved := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "app")]

	for b.Loop() {
		err := hclparse.GenerateAutoIncludeFile(fs, resolved, testGenDir, src, resolved.EvalCtx)
		if err != nil {
			require.NoError(b, err)
		}
	}
}

// TestAutoIncludeResolve_ResolvesValuesInConfigPath pins that a values.* reference in a dependency config_path
// resolves against the stack file's values at generate time, producing a concrete dependency path.
func TestAutoIncludeResolve_ResolvesValuesInConfigPath(t *testing.T) {
	t.Parallel()

	src := `
unit "u" {
  source = "../catalog/units/u"
  path   = "u"

  autoinclude {
    dependency "dep" {
      config_path = values.producer_path
    }
  }
}
`
	values := cty.ObjectVal(map[string]cty.Value{"producer_path": cty.StringVal("../producer")})

	result, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
		Src: []byte(src), Filename: "terragrunt.stack.hcl", StackDir: testStackDir, Values: &values,
	})
	require.NoError(
		t,
		err,
		"values.* in a dependency config_path resolves against the stack values at generate time",
	)

	resolved, ok := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "u")]
	require.True(t, ok)
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(t, "dep", resolved.Dependencies[0].Name)
	assert.Contains(
		t,
		resolved.Dependencies[0].ConfigPath,
		"producer",
		"config_path resolves to the value's concrete path",
	)
}

// TestGenerateAutoIncludeFile_ResolvesValues pins that a values.* reference anywhere in an autoinclude (inputs,
// mock_outputs, config_path) resolves against the stack file's values at generate time, like local.*, while
// dependency.*.outputs.* stays verbatim for the generated unit.
func TestGenerateAutoIncludeFile_ResolvesValues(t *testing.T) {
	t.Parallel()

	src := `
unit "producer" {
  source = "../catalog/units/producer"
  path   = "producer"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "producer" {
      config_path = unit.producer.path

      mock_outputs = {
        region = values.region
      }
    }

    inputs = {
      region   = values.region
      dep_addr = dependency.producer.outputs.address
    }
  }
}
`
	srcBytes := []byte(src)
	fs := vfs.NewMemMapFS()

	values := cty.ObjectVal(map[string]cty.Value{"region": cty.StringVal("us-east-1")})

	result, err := hclparse.ParseStackFile(fs, &hclparse.ParseStackFileInput{
		Src: srcBytes, Filename: "terragrunt.stack.hcl", StackDir: testStackDir, Values: &values,
	})
	require.NoError(t, err)

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

	err = hclparse.GenerateAutoIncludeFile(fs, resolved, testGenDir, srcBytes, resolved.EvalCtx)
	require.NoError(t, err)

	generated, err := vfs.ReadFile(fs, filepath.Join(testGenDir, hclparse.AutoIncludeFile))
	require.NoError(t, err)

	content := string(generated)
	assert.Contains(
		t,
		content,
		`"us-east-1"`,
		"a values.* reference resolves to its literal at generate time",
	)
	assert.NotContains(
		t,
		content,
		"values.region",
		"a values.* reference must not be left verbatim",
	)
	assert.Contains(
		t,
		content,
		"dependency.producer.outputs.address",
		"a dependency output reference stays verbatim for the generated unit",
	)
}

// TestAutoIncludeResolve_RejectsLocalsBlock verifies a locals block inside autoinclude is rejected for both kinds.
func TestAutoIncludeResolve_RejectsLocalsBlock(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name      string
		kind      string
		component string
		src       string
	}{
		{
			name:      "unit",
			kind:      "unit",
			component: "u",
			src: `
unit "u" {
  source = "../catalog/units/u"
  path   = "u"

  autoinclude {
    locals {
      x = 1
    }
  }
}
`,
		},
		{
			name:      "stack",
			kind:      "stack",
			component: "s",
			src: `
stack "s" {
  source = "../catalog/stacks/s"
  path   = "s"

  autoinclude {
    locals {
      x = 1
    }
  }
}
`,
		},
		{
			// A locals block nested inside a dependency block must also be rejected.
			name:      "nested inside dependency",
			kind:      "unit",
			component: "u",
			src: `
unit "u" {
  source = "../catalog/units/u"
  path   = "u"

  autoinclude {
    dependency "dep" {
      config_path = unit.u.path

      locals {
        x = 1
      }
    }
  }
}
`,
		},
	}

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

			_, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
				Src: []byte(tc.src), Filename: "terragrunt.stack.hcl", StackDir: testStackDir,
			})

			var localsErr hclparse.AutoIncludeLocalsBlockError

			require.ErrorAs(t, err, &localsErr)
			assert.Equal(t, tc.kind, localsErr.Kind)
			assert.Equal(t, tc.component, localsErr.Component)
			assert.Contains(t, err.Error(), "declare locals at the stack level")
		})
	}
}

// TestAutoIncludeResolve_RejectsNestedAutoInclude verifies an autoinclude block nested directly inside an
// autoinclude block is rejected for both kinds.
func TestAutoIncludeResolve_RejectsNestedAutoInclude(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name string
		src  string
	}{
		{
			name: "unit",
			src: `
unit "u" {
  source = "../catalog/units/u"
  path   = "u"

  autoinclude {
    autoinclude {
      inputs = {
        x = "y"
      }
    }
  }
}
`,
		},
		{
			name: "stack",
			src: `
stack "s" {
  source = "../catalog/stacks/s"
  path   = "s"

  autoinclude {
    autoinclude {
      inputs = {
        x = "y"
      }
    }
  }
}
`,
		},
	}

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

			_, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
				Src: []byte(tc.src), Filename: "terragrunt.stack.hcl", StackDir: testStackDir,
			})

			var nestedErr hclparse.AutoIncludeNestedError

			require.ErrorAs(t, err, &nestedErr)
			assert.Contains(t, err.Error(), "must not contain another autoinclude block")
		})
	}
}

// TestAutoIncludeResolve_RejectsDeeplyNestedBlocks verifies the nested-block recursion guard returns a typed error
// instead of overflowing the stack on a pathologically deep autoinclude body.
func TestAutoIncludeResolve_RejectsDeeplyNestedBlocks(t *testing.T) {
	t.Parallel()

	const depth = 1100 // exceeds maxBlockDepth

	nested := strings.Repeat("nested {\n", depth) + strings.Repeat("}\n", depth)
	src := `
unit "u" {
  source = "../catalog/units/u"
  path   = "u"

  autoinclude {
` + nested + `  }
}
`

	_, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
		Src: []byte(src), Filename: "terragrunt.stack.hcl", StackDir: testStackDir,
	})
	require.Error(t, err)
	assert.Contains(
		t,
		err.Error(),
		"nesting too deep",
		"deep nesting must fail loud, not overflow the stack",
	)
}

// TestParseStackFile_AutoIncludeReferencesUnitMergedFromInclude verifies a unit declared in an included file is reachable as unit.<name>.path during autoinclude resolution.
func TestParseStackFile_AutoIncludeReferencesUnitMergedFromInclude(t *testing.T) {
	t.Parallel()

	fs := vfs.NewMemMapFS()

	mainSrc := `
include "shared" {
  path = "shared.hcl"
}
`

	includeSrc := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"

  autoinclude {
    dependency "vpc" {
      config_path = unit.vpc.path
    }
  }
}
`

	require.NoError(t, fs.MkdirAll(testStackDir, 0755))
	require.NoError(
		t,
		vfs.WriteFile(fs, filepath.Join(testStackDir, "shared.hcl"), []byte(includeSrc), 0644),
	)

	result, err := hclparse.ParseStackFile(fs, &hclparse.ParseStackFileInput{
		Src:      []byte(mainSrc),
		Filename: filepath.Join(testStackDir, "terragrunt.stack.hcl"),
		StackDir: testStackDir,
	})
	require.NoError(t, err)

	resolved, ok := result.AutoIncludes[hclparse.AutoIncludeKey("unit", "app")]
	require.True(t, ok, "autoinclude for unit 'app' (from included file) must be resolved")
	require.Len(t, resolved.Dependencies, 1)
	assert.Equal(t, "vpc", resolved.Dependencies[0].Name)
	assert.Equal(
		t,
		filepath.Join(testStackDir, ".terragrunt-stack", "vpc"),
		resolved.Dependencies[0].ConfigPath,
		"unit.vpc.path must resolve to vpc's generated path after include merge, not be undefined",
	)
}

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

	fs := vfs.NewMemMapFS()

	mainSrc := `
locals {
  shared_file = "shared.hcl"
}

include "shared" {
  path = local.shared_file
}
`

	includeSrc := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}
`

	require.NoError(t, fs.MkdirAll(testStackDir, 0755))
	require.NoError(
		t,
		vfs.WriteFile(fs, filepath.Join(testStackDir, "shared.hcl"), []byte(includeSrc), 0644),
	)

	result, err := hclparse.ParseStackFile(fs, &hclparse.ParseStackFileInput{
		Src:      []byte(mainSrc),
		Filename: filepath.Join(testStackDir, "terragrunt.stack.hcl"),
		StackDir: testStackDir,
	})
	require.NoError(t, err)
	require.Len(t, result.Units, 1)
	assert.Equal(t, "vpc", result.Units[0].Name)
}

// TestParseStackFile_BootstrapUnitPathEvalErrorSurfaces verifies the bootstrap path returns an error when a unit's path expression cannot be evaluated.
func TestParseStackFile_BootstrapUnitPathEvalErrorSurfaces(t *testing.T) {
	t.Parallel()

	src := `
unit "vpc" {
  source = "../catalog/units/vpc"
  path   = get_repo_root()
}
`

	_, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
		Src: []byte(src), Filename: "terragrunt.stack.hcl", StackDir: testStackDir,
	})
	require.Error(
		t,
		err,
		"bootstrap parse must surface unsupported-function eval errors instead of silently skipping the unit",
	)
	assert.Contains(
		t,
		err.Error(),
		"get_repo_root",
		"error must name the offending function so users can locate it",
	)
}

// TestParseStackFile_BootstrapStackSourceEvalErrorSurfaces pins the same hardening for stack-block source expressions.
func TestParseStackFile_BootstrapStackSourceEvalErrorSurfaces(t *testing.T) {
	t.Parallel()

	src := `
stack "networking" {
  source = get_repo_root()
  path   = "networking"
}
`

	_, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
		Src: []byte(src), Filename: "terragrunt.stack.hcl", StackDir: testStackDir,
	})
	require.Error(
		t,
		err,
		"bootstrap parse must surface unsupported-function eval errors in stack source instead of silently skipping the stack",
	)
	assert.Contains(
		t,
		err.Error(),
		"get_repo_root",
		"error must name the offending function so users can locate it",
	)
}
func TestParseStackFile_LocalEvaluatedOnce(t *testing.T) {
	t.Parallel()

	var calls atomic.Int32

	onceFn := function.New(&function.Spec{
		Type: function.StaticReturnType(cty.String),
		Impl: func([]cty.Value, cty.Type) (cty.Value, error) {
			calls.Add(1)
			return cty.StringVal("/includes/extra.stack.hcl"), nil
		},
	})

	fs := vfs.NewMemMapFS()
	require.NoError(t, vfs.WriteFile(fs, "/includes/extra.stack.hcl", []byte(`
unit "extra" {
  source = "../catalog/units/extra"
  path   = "extra"
}
`), 0644))

	src := []byte(`
locals {
  include_file = once()
}

include "extra" {
  path = local.include_file
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"
}
`)

	_, err := hclparse.ParseStackFile(fs, &hclparse.ParseStackFileInput{
		Src:      src,
		Filename: "terragrunt.stack.hcl",
		StackDir: "/test",
		Functions: map[string]function.Function{
			"once": onceFn,
		},
	})

	require.NoError(t, err)
	assert.Equal(t, int32(1), calls.Load())
}

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

	cases := []struct {
		name string
		src  string
	}{
		{
			name: "unit missing source",
			src: `
unit "vpc" {
  path = "vpc"
}
`,
		},
		{
			name: "unit missing path",
			src: `
unit "vpc" {
  source = "../catalog/units/vpc"
}
`,
		},
		{
			name: "stack missing source",
			src: `
stack "networking" {
  path = "networking"
}
`,
		},
		{
			name: "stack missing path",
			src: `
stack "networking" {
  source = "../catalog/stacks/networking"
}
`,
		},
	}

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

			_, err := hclparse.ParseStackFile(vfs.NewMemMapFS(), &hclparse.ParseStackFileInput{
				Src:      []byte(tc.src),
				Filename: "terragrunt.stack.hcl",
				StackDir: testStackDir,
			})

			require.Error(t, err)
		})
	}
}

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

	src := []byte(`
locals {
  a = local.b
  b = local.c
  c = local.a
}

unit "vpc" {
  source = "../catalog/units/vpc"
  path   = "vpc"
}
`)

	var first []string

	for i := range 5 {
		_, err := hclparse.ParseStackFile(
			vfs.NewMemMapFS(),
			&hclparse.ParseStackFileInput{
				Src:      src,
				Filename: "terragrunt.stack.hcl",
				StackDir: testStackDir,
			},
		)
		require.Error(t, err)

		var cycleErr hclparse.LocalsCycleError
		require.ErrorAs(t, err, &cycleErr)

		if i == 0 {
			first = cycleErr.Names
			continue
		}

		assert.Equal(
			t,
			first,
			cycleErr.Names,
			"cycle path must be deterministic across runs (run %d)",
			i,
		)
	}
}

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

	fs := vfs.NewMemMapFS()
	require.NoError(t, vfs.WriteFile(fs, "/test/dummy.stack.hcl", []byte(`
unit "extra" {
  source = "../catalog/units/extra"
  path   = "extra"
}
`), 0644))

	src := []byte(`
locals {
  bad = null
}

include "extra" {
  path = local.bad
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"
}
`)

	_, err := hclparse.ParseStackFile(
		fs,
		&hclparse.ParseStackFileInput{
			Src:      src,
			Filename: "terragrunt.stack.hcl",
			StackDir: "/test",
		},
	)
	require.Error(t, err)

	var validationErr hclparse.IncludeValidationError
	require.ErrorAs(t, err, &validationErr)

	var diags hcl.Diagnostics
	require.ErrorAs(t, err, &diags)
	require.NotEmpty(t, diags)
	require.NotNil(
		t,
		diags[0].Subject,
		"include validation diag must carry a source position for editor underlining",
	)
	assert.Equal(t, "terragrunt.stack.hcl", diags[0].Subject.Filename)
}

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

	// The include path is resolved relative to the parent stack file's StackDir
	// (here: `/test`), so the included file must live alongside the parent.
	fs := vfs.NewMemMapFS()
	require.NoError(t, vfs.WriteFile(fs, "/test/extra.stack.hcl", []byte(`
unit "extra" {
  source = "../catalog/units/extra"
}
`), 0644))

	src := []byte(`
include "extra" {
  path = "extra.stack.hcl"
}

unit "app" {
  source = "../catalog/units/app"
  path   = "app"
}
`)

	_, err := hclparse.ParseStackFile(fs, &hclparse.ParseStackFileInput{
		Src:      src,
		Filename: "terragrunt.stack.hcl",
		StackDir: "/test",
	})

	require.Error(t, err)
	assert.Contains(t, err.Error(), `path`)
}
