package config

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"maps"
	"net/url"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"

	"cloud.google.com/go/storage"
	"github.com/gruntwork-io/terragrunt/internal/azurehelper"
	"github.com/gruntwork-io/terragrunt/internal/cache"
	"github.com/gruntwork-io/terragrunt/internal/experiment"
	"github.com/gruntwork-io/terragrunt/internal/iacargs"
	"github.com/gruntwork-io/terragrunt/internal/remotestate"
	"github.com/gruntwork-io/terragrunt/internal/strict/controls"
	"github.com/gruntwork-io/terragrunt/pkg/log"

	"github.com/gruntwork-io/terragrunt/internal/remotestate/backend"
	azurermbackend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/azurerm"
	gcsbackend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/gcs"
	s3backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3"

	"github.com/gruntwork-io/terragrunt/internal/getter"
	"github.com/hashicorp/hcl/v2"
	"github.com/zclconf/go-cty/cty"
	"github.com/zclconf/go-cty/cty/gocty"
	ctyjson "github.com/zclconf/go-cty/cty/json"
	"golang.org/x/sync/errgroup"

	"github.com/gruntwork-io/terragrunt/internal/codegen"
	"github.com/gruntwork-io/terragrunt/internal/iam"
	"github.com/gruntwork-io/terragrunt/internal/report"
	"github.com/gruntwork-io/terragrunt/internal/runner/run"
	"github.com/gruntwork-io/terragrunt/internal/runner/run/creds"
	"github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/amazonsts"
	"github.com/gruntwork-io/terragrunt/internal/runner/runcfg"
	"github.com/gruntwork-io/terragrunt/internal/shell"
	"github.com/gruntwork-io/terragrunt/internal/telemetry"
	"github.com/gruntwork-io/terragrunt/internal/tf"
	"github.com/gruntwork-io/terragrunt/internal/util"
	"github.com/gruntwork-io/terragrunt/internal/vfs"
	"github.com/gruntwork-io/terragrunt/pkg/config/hclparse"

	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/trace"
)

const (
	renderJSONCommand       = "render-json"
	renderCommand           = "render"
	defaultStateWorkspace   = "default"
	defaultS3WorkspacePath  = "env:"
	defaultAzureReadTimeout = 5 * time.Minute
	gcsEncryptionKeyBytes   = 32
	azureEncryptionScopeEnv = "ARM_ENCYRPTION_SCOPE" //nolint:misspell // OpenTofu exposes this misspelled name.

	// downloadDirPerms is the mode given to the download directory when the
	// dependency-output flow has to create it.
	downloadDirPerms = 0o700
)

type Dependencies []Dependency

// Struct to hold the decoded dependency blocks.
type dependencyOutputCache struct {
	Enabled *bool
	Inputs  cty.Value
}

type Dependency struct {
	Expansion *hclparse.ExpansionBlock `hcl:"expansion,block"`

	ConfigPath                          cty.Value  `hcl:"config_path,attr"                             cty:"config_path"`
	Enabled                             *bool      `hcl:"enabled,attr"                                 cty:"enabled"`
	SkipOutputs                         *bool      `hcl:"skip_outputs,attr"                            cty:"skip"`
	MockOutputs                         *cty.Value `hcl:"mock_outputs,attr"                            cty:"mock_outputs"`
	MockOutputsAllowedTerraformCommands *[]string  `hcl:"mock_outputs_allowed_terraform_commands,attr" cty:"mock_outputs_allowed_terraform_commands"`

	// MockOutputsMergeWithState is deprecated. Use MockOutputsMergeStrategyWithState
	MockOutputsMergeWithState *bool `hcl:"mock_outputs_merge_with_state,attr" cty:"mock_outputs_merge_with_state"`

	MockOutputsMergeStrategyWithState *MergeStrategyType `hcl:"mock_outputs_merge_strategy_with_state" cty:"mock_outputs_merge_strategy_with_state"`

	// Used to store the rendered outputs for use when the config is imported or read with `read_terragrunt_config`
	RenderedOutputs *cty.Value `cty:"outputs"`

	Inputs *cty.Value `cty:"inputs"`
	Name   string     `cty:"name"   hcl:",label"`
}

// DeepMerge will deep merge two Dependency configs, updating the target. Deep merge for Dependency configs is defined
// as follows:
//   - For simple attributes (bools and strings), the source will override the target.
//   - For MockOutputs, the two maps will be deeply merged together. This means that maps are recursively merged, while
//     lists are concatenated together.
//   - For MockOutputsAllowedTerraformCommands, the source will be concatenated to the target.
//   - For Expansion, the source block replaces the target block outright.
//
// Note that RenderedOutputs is ignored in the deep merge operation.
func (dep *Dependency) DeepMerge(sourceDepConfig *Dependency) error {
	if sourceDepConfig.Expansion != nil {
		dep.Expansion = sourceDepConfig.Expansion
	}

	if sourceDepConfig.ConfigPath.AsString() != "" {
		dep.ConfigPath = sourceDepConfig.ConfigPath
	}

	if sourceDepConfig.Enabled != nil {
		dep.Enabled = sourceDepConfig.Enabled
	}

	if sourceDepConfig.SkipOutputs != nil {
		dep.SkipOutputs = sourceDepConfig.SkipOutputs
	}

	if err := dep.mergeMockOutputs(sourceDepConfig); err != nil {
		return err
	}

	dep.mergeMockOutputsAllowedTerraformCommands(sourceDepConfig)

	return nil
}

// mergeMockOutputs deep-merges sourceDepConfig.MockOutputs into dep.MockOutputs, adopting the source pointer when dep is unset and skipping when both sides point at the same value.
func (dep *Dependency) mergeMockOutputs(sourceDepConfig *Dependency) error {
	if sourceDepConfig.MockOutputs == nil {
		return nil
	}

	if dep.MockOutputs == nil {
		dep.MockOutputs = sourceDepConfig.MockOutputs
		return nil
	}

	if dep.MockOutputs == sourceDepConfig.MockOutputs {
		return nil
	}

	merged, err := deepMergeCtyMaps(*dep.MockOutputs, *sourceDepConfig.MockOutputs)
	if err != nil {
		return err
	}

	dep.MockOutputs = merged

	return nil
}

// mergeMockOutputsAllowedTerraformCommands concatenates source commands onto dep's, adopting the source pointer when dep is unset and skipping when both sides point at the same slice.
func (dep *Dependency) mergeMockOutputsAllowedTerraformCommands(sourceDepConfig *Dependency) {
	if sourceDepConfig.MockOutputsAllowedTerraformCommands == nil {
		return
	}

	if dep.MockOutputsAllowedTerraformCommands == nil {
		dep.MockOutputsAllowedTerraformCommands = sourceDepConfig.MockOutputsAllowedTerraformCommands
		return
	}

	if dep.MockOutputsAllowedTerraformCommands == sourceDepConfig.MockOutputsAllowedTerraformCommands {
		return
	}

	merged := append(
		*dep.MockOutputsAllowedTerraformCommands,
		*sourceDepConfig.MockOutputsAllowedTerraformCommands...)
	dep.MockOutputsAllowedTerraformCommands = &merged
}

// getMockOutputsMergeStrategy returns the merge strategy for mock outputs, prioritizing mock_outputs_merge_strategy_with_state over the deprecated mock_outputs_merge_with_state boolean and defaulting to NoMerge.
func (dep *Dependency) getMockOutputsMergeStrategy() MergeStrategyType {
	if dep.MockOutputsMergeStrategyWithState != nil {
		return *dep.MockOutputsMergeStrategyWithState
	}

	if dep.MockOutputsMergeWithState != nil && (*dep.MockOutputsMergeWithState) {
		return ShallowMerge
	}

	return NoMerge
}

// Given a dependency config, we should only attempt to get the outputs if SkipOutputs is nil or false
func (dep *Dependency) shouldGetOutputs(ctx *ParsingContext) bool {
	return !ctx.SkipOutput && dep.isEnabled() && (dep.SkipOutputs == nil || !*dep.SkipOutputs)
}

// isEnabled returns true if the dependency is enabled
func (dep *Dependency) isEnabled() bool {
	if dep.Enabled == nil {
		return true
	}

	return *dep.Enabled
}

// isDisabled returns true if the dependency is disabled
func (dep *Dependency) isDisabled() bool {
	return !dep.isEnabled()
}

// instanceKey returns the key of the expansion element this dependency came from. A declared
// expansion is not proof of one: blocks decoded outside the expanding decoder (the autoinclude
// decode) carry the declaration with no key, and callers must read those as whole blocks.
func (dep *Dependency) instanceKey() (string, bool) {
	if dep.Expansion == nil {
		return "", false
	}

	return dep.Expansion.Key(), dep.Expansion.Expanded()
}

// mergeKey identifies a dependency when include merging matches blocks up. Every instance
// of an expanded block carries the same label, so matching on the label alone would fold a
// whole set into whichever instance came last.
func (dep *Dependency) mergeKey() string {
	key, expanded := dep.instanceKey()
	if !expanded {
		return dep.Name
	}

	return dep.Name + "[" + key + "]"
}

// Given a dependency config, we should only attempt to merge mocks outputs with the outputs if MockOutputsMergeWithState is not nil or true
func (dep *Dependency) shouldMergeMockOutputsWithState(ctx *ParsingContext) bool {
	allowedCommand :=
		dep.MockOutputsAllowedTerraformCommands == nil ||
			len(*dep.MockOutputsAllowedTerraformCommands) == 0 ||
			slices.Contains(*dep.MockOutputsAllowedTerraformCommands, ctx.OriginalTerraformCommand)

	return allowedCommand && dep.getMockOutputsMergeStrategy() != NoMerge
}

func (dep *Dependency) setRenderedOutputs(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
) error {
	if dep == nil {
		return nil
	}

	if dep.shouldGetOutputs(pctx) || dep.shouldReturnMockOutputs(pctx) {
		outputVal, err := getTerragruntOutputIfAppliedElseConfiguredDefault(ctx, pctx, l, dep)
		if err != nil {
			return err
		}

		dep.RenderedOutputs = outputVal
	}

	return nil
}

// outputLocksFromContext retrieves the KeyLocks from the context for synchronizing output retrieval.
// If not present in context, returns a new KeyLocks instance.
func outputLocksFromContext(ctx context.Context) *util.KeyLocks {
	if val, ok := ctx.Value(OutputLocksContextKey).(*util.KeyLocks); ok && val != nil {
		return val
	}

	return util.NewKeyLocks()
}

// decodeDependencyBlocks decodes a config's dependency blocks, returning one Dependency
// per iteration element. A block that declares no expansion yields a single Dependency.
func decodeDependencyBlocks(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	file *hclparse.File,
	evalContext *hcl.EvalContext,
	opts ...hclparse.ExpandOption,
) (Dependencies, error) {
	experiments := pctx.Experiments

	instances, err := file.ExpandBlocks(MetadataDependency, &Dependency{}, evalContext, opts...)
	if err != nil {
		return nil, err
	}

	if len(instances) == 0 {
		return nil, nil
	}

	dependencies := make(Dependencies, 0, len(instances))

	for _, instance := range instances {
		dep := instance.Value.(*Dependency)
		if dep.Expansion != nil {
			if !experiments.Evaluate(experiment.BlockIteration) {
				return nil, ExpansionRequiresExperimentError{
					ConfigPath: file.ConfigPath,
					BlockType:  MetadataDependency,
					BlockLabel: dep.Name,
				}
			}

			dep.Expansion.InstanceKey = instance.InstanceKey
			dep.Expansion.Source = instance.Source
		}

		dependencies = append(dependencies, *dep)
	}

	if err := validateUniqueDependencies(ctx, pctx, l, file.ConfigPath, dependencies); err != nil {
		return nil, err
	}

	return dependencies, nil
}

// validateUniqueDependencies reports two dependency blocks in one config that address the
// same dependency. HCL allows the repeated label, and nothing downstream reports it: the
// dependency map is keyed by address, so the last block silently wins and the ones before
// it become unreachable.
//
// Whether that is an error or a warning is left to the duplicate-dependency-labels strict
// control, since configs carrying a shadowed block have always run.
func validateUniqueDependencies(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	configPath string,
	deps Dependencies,
) error {
	address, found := duplicateDependencyAddress(deps)
	if !found {
		return nil
	}

	control := pctx.StrictControls.Find(controls.DuplicateDependencyLabels)
	if control == nil {
		return errors.New("failed to find control " + controls.DuplicateDependencyLabels)
	}

	if control.GetEnabled() {
		return DuplicateDependencyError{ConfigPath: configPath, Address: address}
	}

	return control.Evaluate(log.ContextWithLogger(ctx, l))
}

// duplicateDependencyAddress returns the first address that two dependency blocks both
// claim. Blocks are compared by address rather than by label, so the elements of an
// expanded block, which all carry the label the block was written with, stay distinct.
func duplicateDependencyAddress(deps Dependencies) (string, bool) {
	seen := make(map[string]struct{}, len(deps))

	for i := range deps {
		address := deps[i].mergeKey()

		if _, duplicate := seen[address]; duplicate {
			return address, true
		}

		seen[address] = struct{}{}
	}

	return "", false
}

// Decode the dependency blocks from the file, and then retrieve all the outputs from the remote state. Then encode the
// resulting map as a cty.Value object.
// TODO: In the future, consider allowing importing dependency blocks from included config
// NOTE FOR MAINTAINER: When implementing importation of other config blocks (e.g referencing inputs), carefully
//
//	consider whether or not the implementation of the cyclic dependency detection still makes sense.
func decodeAndRetrieveOutputs(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	file *hclparse.File,
) (*cty.Value, error) {
	evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath)
	if err != nil {
		return nil, err
	}

	dependencies, err := decodeDependencyBlocksWithAutoIncludeOverrides(ctx, pctx, l, file, evalParsingContext)
	if err != nil {
		return nil, err
	}

	decodedDependency := TerragruntDependency{Dependencies: dependencies}

	// In normal operation, if a dependency block does not have a `config_path` attribute, decoding returns an error since this attribute is required, but the `hclvalidate` command suppresses decoding errors and this causes a cycle between modules, so we need to filter out dependencies without a defined `config_path`.
	decodedDependency.Dependencies = decodedDependency.Dependencies.FilteredWithoutConfigPath()

	// Fold sibling autoinclude dependency blocks in before output retrieval so the unit body can reference them, like a regular include.
	decodedDependency.Dependencies, err = foldSiblingAutoIncludeDeps(
		ctx,
		pctx,
		l,
		decodedDependency.Dependencies,
	)
	if err != nil {
		return nil, err
	}

	// Validate that dependency config_path is not an empty string.
	// Skip null/unknown values and non-strings (which can appear during partial decode or hclvalidate).
	for _, dep := range decodedDependency.Dependencies {
		if dep.isDisabled() {
			continue
		}

		if !IsValidConfigPath(dep.ConfigPath) {
			// During hcl validate, config_path may be unresolvable (e.g., references an
			// unavailable local). Skip the invalid dependency instead of aborting — it will
			// get a cty.DynamicVal placeholder in dependencyBlocksToCtyValue.
			if pctx.SkipOutput {
				continue
			}

			return nil, DependencyInvalidConfigPathError{DependencyName: dep.Name}
		}
	}

	if err := checkForDependencyBlockCycles(
		ctx,
		pctx,
		l,
		pctx.TerragruntConfigPath,
		decodedDependency,
	); err != nil {
		return nil, err
	}

	updatedDependencies, err := decodeDependencies(ctx, pctx, l, decodedDependency)
	if err != nil {
		return nil, err
	}

	decodedDependency = *updatedDependencies

	// Merge in included dependencies
	if pctx.TrackInclude != nil {
		mergedDecodedDependency, err := handleIncludeForDependency(ctx, pctx, l, decodedDependency)
		if err != nil {
			return nil, err
		}

		decodedDependency = *mergedDecodedDependency
	}

	// Extract dependency names for tracing
	dependencyNames := make([]string, 0, len(decodedDependency.Dependencies))
	for _, dep := range decodedDependency.Dependencies {
		dependencyNames = append(dependencyNames, dep.Name)
	}

	var result *cty.Value

	err = TraceParseDependencies(
		ctx,
		l,
		file.ConfigPath,
		pctx.SkipOutputsResolution,
		len(decodedDependency.Dependencies),
		dependencyNames,
		func(ctx context.Context, l log.Logger) error {
			var depErr error

			result, depErr = dependencyBlocksToCtyValue(
				ctx,
				pctx,
				l,
				decodedDependency.Dependencies,
			)

			return depErr
		},
	)

	return result, err
}

// decodeDependencies decode dependencies and fetch inputs
func decodeDependencies(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	decodedDependency TerragruntDependency,
) (*TerragruntDependency, error) {
	updatedDependencies := TerragruntDependency{}
	depCache := cache.ContextCache[*dependencyOutputCache](ctx, DependencyOutputCacheContextKey)

	for _, dep := range decodedDependency.Dependencies {
		if !dep.isEnabled() {
			updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep)
			continue
		}

		if !IsValidConfigPath(dep.ConfigPath) {
			if pctx.SkipOutput {
				updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep)
				continue
			}

			return &updatedDependencies, DependencyInvalidConfigPathError{DependencyName: dep.Name}
		}

		depPath := getCleanedTargetConfigPath(
			pctx.Venv.FS,
			dep.ConfigPath.AsString(),
			pctx.TerragruntConfigPath,
		)

		if !vfs.Exists(pctx.Venv.FS, depPath) {
			updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep)

			continue
		}

		cacheKey := filepath.Join(pctx.WorkingDir, depPath)

		// Cache hit - reuse cached values
		if cachedDependency, found := depCache.Get(ctx, cacheKey); found {
			dep.Enabled = cachedDependency.Enabled
			dep.Inputs = &cachedDependency.Inputs
			updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep)

			continue
		}

		// Cache miss - parse and cache

		if !pctx.SkipOutputsResolution {
			l.Debugf(
				"Reading Terragrunt config file at %s",
				util.RelPathForLog(pctx.RootWorkingDir, depPath, pctx.LogShowAbsPaths),
			)
		}

		_, depCtx, err := pctx.WithDependencyConfigPath(l, depPath)
		if err != nil {
			return nil, err
		}

		if depCtx.IAMRoleOptions != depCtx.OriginalIAMRoleOptions {
			depCtx.IAMRoleOptions = iam.RoleOptions{}
		}

		depCtx = depCtx.WithDecodeList(TerragruntFlags).WithDiagnosticsSuppressed(l)

		depConfig, err := PartialParseConfigFile(ctx, depCtx, l, depPath, nil)
		if err != nil {
			l.Warnf(
				"Error reading partial config for dependency %s at %s: %v",
				dep.Name,
				depPath,
				err,
			)
			updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep)

			continue
		}

		inputsCty, err := convertToCtyWithJSON(depConfig.Inputs)
		if err != nil {
			return nil, fmt.Errorf("failed to convert inputs for dependency %q: %w", dep.Name, err)
		}

		cachedValue := dependencyOutputCache{
			Enabled: dep.Enabled,
			Inputs:  inputsCty,
		}
		depCache.Put(ctx, cacheKey, &cachedValue)

		dep.Inputs = &inputsCty

		updatedDependencies.Dependencies = append(updatedDependencies.Dependencies, dep)
	}

	return &updatedDependencies, nil
}

// Convert the list of parsed Dependency blocks into a list of module dependencies. Each output block should
// become a dependency of the current config, since that module has to be applied before we can read the output.
func dependencyBlocksToModuleDependencies(
	l log.Logger,
	decodedDependencyBlocks []Dependency,
) *ModuleDependencies {
	if len(decodedDependencyBlocks) == 0 {
		return nil
	}

	paths := []string{}

	for _, decodedDependencyBlock := range decodedDependencyBlocks {
		// skip dependency if is not enabled
		if !decodedDependencyBlock.isEnabled() {
			continue
		}

		// Skip if ConfigPath is not a known string value (can happen during discovery phase)
		if decodedDependencyBlock.ConfigPath.IsNull() ||
			!decodedDependencyBlock.ConfigPath.IsWhollyKnown() ||
			!decodedDependencyBlock.ConfigPath.Type().Equals(cty.String) {
			l.Debugf(
				"Skipping dependency %q: ConfigPath is not a valid known string value",
				decodedDependencyBlock.Name,
			)

			continue
		}

		paths = append(paths, decodedDependencyBlock.ConfigPath.AsString())
	}

	return &ModuleDependencies{Paths: paths}
}

// Check for cyclic dependency blocks to avoid infinite `terragrunt output` loops. To avoid reparsing the config, we
// kickstart the initial loop using what we already decoded.
func checkForDependencyBlockCycles(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	configPath string,
	decodedDependency TerragruntDependency,
) error {
	visitedPaths := []string{}
	currentTraversalPaths := []string{configPath}

	for _, dependency := range decodedDependency.Dependencies {
		if dependency.isDisabled() {
			continue
		}

		if !IsValidConfigPath(dependency.ConfigPath) {
			if pctx.SkipOutput {
				continue
			}

			return DependencyInvalidConfigPathError{DependencyName: dependency.Name}
		}

		dependencyPath := getCleanedTargetConfigPath(
			pctx.Venv.FS,
			dependency.ConfigPath.AsString(),
			configPath,
		)

		// Skip cycle checking for nonexistent dependency targets — there is nothing to traverse.
		if !vfs.Exists(pctx.Venv.FS, dependencyPath) {
			continue
		}

		l, dependencyContext, err := pctx.WithDependencyConfigPath(l, dependencyPath)
		if err != nil {
			return err
		}

		if err := checkForDependencyBlockCyclesUsingDFS(
			ctx,
			dependencyContext,
			l,
			dependencyPath,
			&visitedPaths,
			&currentTraversalPaths,
		); err != nil {
			return err
		}
	}

	return nil
}

// Helper function for checkForDependencyBlockCycles.
//
// Same implementation as configstack/graph.go:checkForCyclesUsingDepthFirstSearch, except walks the graph of
// dependencies by `dependency` blocks (which make explicit `terragrunt output` calls) instead of explicit dependencies.
func checkForDependencyBlockCyclesUsingDFS(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	dependencyPath string,
	visitedPaths *[]string,
	currentTraversalPaths *[]string,
) error {
	if slices.Contains(*visitedPaths, dependencyPath) {
		return nil
	}

	if slices.Contains(*currentTraversalPaths, dependencyPath) {
		return DependencyCycleError(append(*currentTraversalPaths, dependencyPath))
	}

	*currentTraversalPaths = append(*currentTraversalPaths, dependencyPath)

	dependencyPaths, err := getDependencyBlockConfigPathsByFilepath(ctx, pctx, l, dependencyPath)
	if err != nil {
		return err
	}

	for _, dependency := range dependencyPaths {
		dependencyPath := getCleanedTargetConfigPath(pctx.Venv.FS, dependency, dependencyPath)

		// Skip cycle checking for nonexistent dependency targets such as stack directories.
		if !vfs.Exists(pctx.Venv.FS, dependencyPath) {
			continue
		}

		l, dependencyContext, err := pctx.WithDependencyConfigPath(l, dependencyPath)
		if err != nil {
			return err
		}

		if err := checkForDependencyBlockCyclesUsingDFS(
			ctx,
			dependencyContext,
			l,
			dependencyPath,
			visitedPaths,
			currentTraversalPaths,
		); err != nil {
			return err
		}
	}

	*visitedPaths = append(*visitedPaths, dependencyPath)
	*currentTraversalPaths = slices.DeleteFunc(
		*currentTraversalPaths,
		func(path string) bool { return path == dependencyPath },
	)

	return nil
}

// Given the config path, return the list of config paths that are specified as dependency blocks in the config
func getDependencyBlockConfigPathsByFilepath(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	configPath string,
) ([]string, error) {
	// This will automatically parse everything needed to parse the dependency block configs, and load them as
	// TerragruntConfig.Dependencies. Note that since we aren't passing in `DependenciesBlock` to the
	// PartialDecodeSectionType list, the Dependencies attribute will not include any dependencies specified via the
	// dependencies block.
	tgConfig, err := PartialParseConfigFile(
		ctx,
		pctx.WithDecodeList(DependencyBlock).WithDiagnosticsSuppressed(l),
		l,
		configPath,
		nil,
	)
	if err != nil {
		return nil, err
	}

	if tgConfig.Dependencies == nil {
		return []string{}, nil
	}

	return tgConfig.Dependencies.Paths, nil
}

// Encode the list of dependency blocks into a single cty.Value object that maps the dependency block name to the
// encoded dependency mapping. The encoded dependency mapping should have the attributes:
//   - outputs: The map of outputs of the corresponding terraform module that lives at the target config of the
//     dependency.
//
// An expanded block instead maps its name to one such mapping per instance, keyed by the instance key, so that
// `dependency.foo["bar"].outputs` addresses a single element of the set.
//
// This routine will go through the process of obtaining the outputs using `terragrunt output` from the target config.
// The traceCtx parameter is the trace context from the parent span (parse_dependencies) to establish parent-child
// relationship for individual dependency traces.
func dependencyBlocksToCtyValue(
	traceCtx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	dependencyConfigs []Dependency,
) (*cty.Value, error) {
	paths := []string{}

	// dependencyMap is the top level map that maps dependency block names to the encoded version, which includes
	// various attributes for accessing information about the target config (including the module outputs).
	dependencyMap := map[string]cty.Value{}
	instanceMaps := map[string]map[string]cty.Value{}
	lock := sync.Mutex{}
	dependencyErrGroup, _ := errgroup.WithContext(traceCtx)

	for _, dependencyConfig := range dependencyConfigs {
		dependencyErrGroup.Go(func() error {
			// Tag the context with the dependency block name so dependency_output_fetch can attach it.
			ctx := contextWithDependencyName(traceCtx, dependencyConfig.Name)

			// Loose struct to hold the attributes of the dependency. This includes:
			// - outputs: The module outputs of the target config
			dependencyEncodingMap := map[string]cty.Value{}

			// Encode the outputs and nest under `outputs` attribute if we should get the outputs or the `mock_outputs`
			if err := dependencyConfig.setRenderedOutputs(ctx, pctx, l); err != nil {
				return fmt.Errorf("resolving dependency %q outputs: %w", dependencyConfig.Name, err)
			}

			if dependencyConfig.RenderedOutputs != nil {
				lock.Lock()

				paths = append(paths, dependencyConfig.ConfigPath.AsString())

				lock.Unlock()

				dependencyEncodingMap["outputs"] = *dependencyConfig.RenderedOutputs
			} else if pctx.SkipOutput {
				// During hcl validate, output resolution is skipped. Use cty.DynamicVal so that
				// attribute access on dependency outputs (e.g. dependency.x.outputs.y) evaluates
				// to unknown rather than producing an "Unsupported attribute" error.
				l.Debugf(
					"Setting outputs for dependency %s to DynamicVal (output resolution skipped)",
					dependencyConfig.Name,
				)

				dependencyEncodingMap["outputs"] = cty.DynamicVal
			}

			if dependencyConfig.Inputs != nil {
				dependencyEncodingMap["inputs"] = *dependencyConfig.Inputs
			} else if pctx.SkipOutput {
				l.Debugf(
					"Setting inputs for dependency %s to DynamicVal (output resolution skipped)",
					dependencyConfig.Name,
				)

				dependencyEncodingMap["inputs"] = cty.DynamicVal
			}

			// Once the dependency is encoded into a map, we need to convert to a cty.Value again so that it can be fed to
			// the higher order dependency map.
			dependencyEncodingMapEncoded, err := gocty.ToCtyValue(
				dependencyEncodingMap,
				generateTypeFromValuesMap(dependencyEncodingMap),
			)
			if err != nil {
				lock.Lock()
				err = TerragruntOutputListEncodingError{Paths: slices.Clone(paths), Err: err}
				lock.Unlock()

				return err
			}

			lock.Lock()
			defer lock.Unlock()

			instanceKey, expanded := dependencyConfig.instanceKey()
			if !expanded {
				dependencyMap[dependencyConfig.Name] = dependencyEncodingMapEncoded

				return nil
			}

			if instanceMaps[dependencyConfig.Name] == nil {
				instanceMaps[dependencyConfig.Name] = map[string]cty.Value{}
			}

			instanceMaps[dependencyConfig.Name][instanceKey] = dependencyEncodingMapEncoded

			return nil
		})
	}

	if err := dependencyErrGroup.Wait(); err != nil {
		return nil, err
	}

	for name, instances := range instanceMaps {
		if _, taken := dependencyMap[name]; taken {
			return nil, DependencyLabelCollisionError{Name: name}
		}

		encodedInstances, err := gocty.ToCtyValue(instances, generateTypeFromValuesMap(instances))
		if err != nil {
			return nil, TerragruntOutputListEncodingError{Paths: paths, Err: err}
		}

		dependencyMap[name] = encodedInstances
	}

	// We need to convert the value map to a single cty.Value at the end so that it can be used in the execution ctx
	convertedOutput, err := gocty.ToCtyValue(
		dependencyMap,
		generateTypeFromValuesMap(dependencyMap),
	)
	if err != nil {
		err = TerragruntOutputListEncodingError{Paths: paths, Err: err}
	}

	return &convertedOutput, err
}

// This will attempt to get the outputs from the target terragrunt config if it is applied. If it is not applied, the
// behavior is different depending on the configuration of the dependency:
//   - If the dependency block indicates a mock_outputs attribute, this will return that.
//     If the dependency block indicates a mock_outputs_merge_strategy_with_state attribute, mock_outputs and state outputs will be merged following the merge strategy
//   - If the dependency block does NOT indicate a mock_outputs attribute, this will return an error.
func getTerragruntOutputIfAppliedElseConfiguredDefault(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	dependencyConfig *Dependency,
) (*cty.Value, error) {
	if dependencyConfig.isDisabled() {
		l.Debugf("Skipping outputs reading for disabled dependency %s", dependencyConfig.Name)
		return dependencyConfig.MockOutputs, nil
	}

	if dependencyConfig.shouldGetOutputs(pctx) {
		outputVal, isEmpty, err := getTerragruntOutput(ctx, pctx, l, dependencyConfig)
		if err != nil {
			return nil, err
		}

		if !isEmpty && dependencyConfig.shouldMergeMockOutputsWithState(pctx) &&
			dependencyConfig.MockOutputs != nil {
			mockMergeStrategy := dependencyConfig.getMockOutputsMergeStrategy()

			switch mockMergeStrategy { // nolint:exhaustive
			case NoMerge:
				return outputVal, nil
			case ShallowMerge:
				return shallowMergeCtyMaps(*outputVal, *dependencyConfig.MockOutputs)
			case DeepMergeMapOnly:
				return deepMergeCtyMapsMapOnly(*dependencyConfig.MockOutputs, *outputVal)
			default:
				return nil, InvalidMergeStrategyTypeError(mockMergeStrategy)
			}
		} else if !isEmpty {
			return outputVal, err
		}
	}

	// When we get no output, it can be an indication that either the module has no outputs or the module is not
	// applied. In either case, check if there are default output values to return. If yes, return that. Else,
	// return error.
	targetConfig := getCleanedTargetConfigPath(
		pctx.Venv.FS,
		dependencyConfig.ConfigPath.AsString(),
		pctx.TerragruntConfigPath,
	)

	if dependencyConfig.shouldReturnMockOutputs(pctx) {
		l.Warnf(
			"Config %s is a dependency of %s that has no outputs, but mock outputs provided and returning those in dependency output.",
			targetConfig,
			pctx.TerragruntConfigPath,
		)

		return dependencyConfig.MockOutputs, nil
	}

	// At this point, we expect outputs to exist because there is a `dependency` block without skip_outputs = true, and
	// returning mocks is not allowed. So return a useful error message indicating that we expected outputs, but they
	// did not exist.
	err := TerragruntOutputTargetNoOutputs{
		targetName:    dependencyConfig.Name,
		targetPath:    dependencyConfig.ConfigPath.AsString(),
		targetConfig:  targetConfig,
		currentConfig: pctx.TerragruntConfigPath,
	}

	return nil, err
}

// We should only return default outputs if the mock_outputs attribute is set, and if we are running one of the
// allowed commands when `mock_outputs_allowed_terraform_commands` is set as well.
func (dep *Dependency) shouldReturnMockOutputs(pctx *ParsingContext) bool {
	if dep.isDisabled() {
		return true
	}

	defaultOutputsSet := dep.MockOutputs != nil

	allowedCommand :=
		dep.MockOutputsAllowedTerraformCommands == nil ||
			len(*dep.MockOutputsAllowedTerraformCommands) == 0 ||
			slices.Contains(*dep.MockOutputsAllowedTerraformCommands, pctx.OriginalTerraformCommand)

	return defaultOutputsSet && allowedCommand || isRenderJSONCommand(pctx) || isRenderCommand(pctx)
}

// Return the output from the state of another module, managed by terragrunt. This function will parse the provided
// terragrunt config and extract the desired output from the remote state. Note that this will error if the targeted
// module hasn't been applied yet.
func getTerragruntOutput(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	dependencyConfig *Dependency,
) (*cty.Value, bool, error) {
	// target config check: make sure the target config exists
	targetConfigPath := getCleanedTargetConfigPath(
		pctx.Venv.FS,
		dependencyConfig.ConfigPath.AsString(),
		pctx.TerragruntConfigPath,
	)

	// Check if config_path points to a directory containing a stack file. If so,
	// resolve outputs from all units in the stack as a nested map.
	stackOutput, handled, err := tryGetStackOutput(ctx, pctx, l, targetConfigPath, dependencyConfig)
	if handled {
		return stackOutput, stackOutput == nil, err
	}

	if !vfs.Exists(pctx.Venv.FS, targetConfigPath) {
		return nil, true, DependencyConfigNotFound{Path: targetConfigPath}
	}

	jsonBytes, err := getOutputJSONWithCaching(ctx, pctx, l, targetConfigPath)
	if err != nil {
		if !shouldFallBackToMockOutputs(pctx, err) {
			return nil, true, err
		}

		l.Warnf(
			"Failed to read outputs from %s referenced in %s as %s, fallback to mock outputs. Error: %v",
			targetConfigPath,
			pctx.TerragruntConfigPath,
			dependencyConfig.Name,
			err,
		)

		jsonBytes, err = json.Marshal(dependencyConfig.MockOutputs)
		if err != nil {
			return nil, true, err
		}
	}

	isEmpty := string(jsonBytes) == "{}"

	outputMap, err := TerraformOutputJSONToCtyValueMap(targetConfigPath, jsonBytes)
	if err != nil {
		return nil, isEmpty, err
	}

	// We need to convert the value map to a single cty.Value at the end for use in the terragrunt config.
	convertedOutput, err := gocty.ToCtyValue(outputMap, generateTypeFromValuesMap(outputMap))
	if err != nil {
		err = TerragruntOutputEncodingError{Path: targetConfigPath, Err: err}
	}

	return &convertedOutput, isEmpty, err
}

// collectStackUnitOutputs aggregates per-unit outputs keyed by unit name for dependency.<stack>.outputs.<unit>.<key> resolution.
func collectStackUnitOutputs(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	stackDir string,
	units []*Unit,
	dependencyConfig *Dependency,
) (map[string]cty.Value, error) {
	unitOutputs := make(map[string]cty.Value)

	for _, unit := range units {
		if !unit.IsEnabled() {
			continue
		}

		unitDir := unit.GeneratedPath(stackDir)
		unitConfigPath := filepath.Join(unitDir, DefaultTerragruntConfigPath)

		if !vfs.Exists(pctx.Venv.FS, unitConfigPath) {
			l.Warnf("Stack unit %s config not found at %s, skipping", unit.Name, unitConfigPath)

			continue
		}

		jsonBytes, err := getOutputJSONWithCaching(ctx, pctx, l, unitConfigPath)
		if err != nil {
			if !shouldFallBackToMockOutputs(pctx, err) ||
				!dependencyConfig.shouldReturnMockOutputs(pctx) {
				return nil, StackUnitOutputFetchError{UnitName: unit.Name, Err: err}
			}

			mock, ok, mockErr := unitMockOutput(dependencyConfig, unit.Name)
			if mockErr != nil {
				return nil, mockErr
			}

			if ok {
				unitOutputs[unit.Name] = mock
				continue
			}

			l.Warnf(
				"Stack unit %s has no remote state at %s yet, skipping",
				unit.Name,
				unitConfigPath,
			)

			continue
		}

		outputMap, err := TerraformOutputJSONToCtyValueMap(unitConfigPath, jsonBytes)
		if err != nil {
			return nil, fmt.Errorf("stack unit %s output parse failed: %w", unit.Name, err)
		}

		if len(outputMap) > 0 {
			convertedOutput, err := gocty.ToCtyValue(
				outputMap,
				generateTypeFromValuesMap(outputMap),
			)
			if err != nil {
				return nil, fmt.Errorf("stack unit %s output convert failed: %w", unit.Name, err)
			}

			unitOutputs[unit.Name] = convertedOutput
		}
	}

	return unitOutputs, nil
}

// unitMockOutput returns the mock declared for a named stack unit in the dependency's mock_outputs.
// It lets a partially applied stack resolve: applied units contribute real outputs while unapplied
// ones fall back to their mock.
//
// Callers are responsible for checking that mocks are allowed for the current command. ok is false
// when mock_outputs is absent or declares no entry for the unit. A mock_outputs that can't be keyed
// by unit name at all is a config error rather than a missing mock, so it returns an error instead
// of leaving the caller to drop the unit and surface the mistake as an unresolved attribute later.
func unitMockOutput(dep *Dependency, unitName string) (cty.Value, bool, error) {
	if dep.MockOutputs == nil {
		return cty.NilVal, false, nil
	}

	mock := *dep.MockOutputs
	if mock.IsNull() || !mock.IsKnown() {
		return cty.NilVal, false, nil
	}

	if mockType := mock.Type(); !mockType.IsObjectType() && !mockType.IsMapType() {
		return cty.NilVal, false, StackMockOutputsTypeError{
			DependencyName: dep.Name,
			UnitName:       unitName,
			Actual:         mockType.FriendlyName(),
		}
	}

	unitMock, ok := mock.AsValueMap()[unitName]

	return unitMock, ok, nil
}

// tryGetStackOutput checks if targetConfigPath points to a stack directory
// (contains terragrunt.stack.hcl) and resolves aggregated outputs from all
// units in the stack. Returns (output, handled, error) where handled=true
// means the path was a stack and was processed (even if there was an error).
func tryGetStackOutput(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	targetConfigPath string,
	dependencyConfig *Dependency,
) (*cty.Value, bool, error) {
	stackFilePath, isStackCandidate := resolveStackFilePath(
		dependencyConfig.ConfigPath.AsString(),
		targetConfigPath,
	)
	if !isStackCandidate {
		return nil, false, nil
	}

	if !vfs.Exists(pctx.Venv.FS, stackFilePath) {
		return nil, false, nil
	}

	l.Debugf(
		"Dependency %s points to stack %s, resolving stack outputs",
		dependencyConfig.Name,
		stackFilePath,
	)

	stackDir := filepath.Dir(stackFilePath)

	// Load values from the target stack's directory before parsing,
	// mirroring the GenerateStackFile flow so stacks using values.* work.
	stackValues, err := ReadValues(ctx, pctx, l, stackDir)
	if err != nil {
		return nil, true, fmt.Errorf("failed to read values for stack %s: %w", stackFilePath, err)
	}

	// Parse the stack config to discover units
	stackConfig, err := ReadStackConfigFile(ctx, l, pctx, stackFilePath, stackValues)
	if err != nil {
		return nil, true, fmt.Errorf("failed to parse stack config %s: %w", stackFilePath, err)
	}

	unitOutputs, err := collectStackUnitOutputs(
		ctx,
		pctx,
		l,
		stackDir,
		stackConfig.Units,
		dependencyConfig,
	)
	if err != nil {
		return nil, true, fmt.Errorf(
			"failed to collect stack unit outputs for %s: %w",
			stackFilePath,
			err,
		)
	}

	if len(unitOutputs) == 0 {
		return nil, true, nil
	}

	result := cty.ObjectVal(unitOutputs)

	return &result, true, nil
}

// resolveStackFilePath returns the candidate terragrunt.stack.hcl path for a dependency target; ok=false when the dep points at a unit config.
func resolveStackFilePath(rawConfigPath, targetConfigPath string) (string, bool) {
	switch filepath.Base(filepath.Clean(rawConfigPath)) {
	case DefaultStackFile:
		// Honor the contract even when target is malformed: anchor on target's directory.
		return filepath.Join(filepath.Dir(targetConfigPath), DefaultStackFile), true
	case DefaultTerragruntConfigPath, DefaultTerragruntJSONConfigPath:
		return "", false
	}

	switch filepath.Base(targetConfigPath) {
	case DefaultStackFile:
		return targetConfigPath, true
	case DefaultTerragruntConfigPath, DefaultTerragruntJSONConfigPath:
		return filepath.Join(filepath.Dir(targetConfigPath), DefaultStackFile), true
	default:
		return filepath.Join(targetConfigPath, DefaultStackFile), true
	}
}

// isRemoteStateMissing reports whether err means a supported backend's state object or its
// containing bucket/container does not exist yet.
func isRemoteStateMissing(err error) bool {
	// A client-construction failure is not an absent state, so it must not yield mock outputs.
	if _, ok := errors.AsType[*azurermbackend.StateClientSetupError](err); ok {
		return false
	}

	return isAwsS3StateMissing(err) ||
		errors.Is(err, storage.ErrObjectNotExist) ||
		errors.Is(err, storage.ErrBucketNotExist) ||
		azurehelper.IsNotFound(err)
}

// shouldFallBackToMockOutputs reports whether a failed dependency output fetch should fall back to
// mock outputs instead of being fatal: either the target's remote state doesn't exist yet, or the
// command is a render, which tolerates unresolved outputs.
func shouldFallBackToMockOutputs(pctx *ParsingContext, err error) bool {
	return isRemoteStateMissing(err) || isRenderJSONCommand(pctx) || isRenderCommand(pctx)
}

// isRenderJSONCommand This function will true if terragrunt was invoked with render-json
func isRenderJSONCommand(pctx *ParsingContext) bool {
	if pctx.TerraformCliArgs == nil {
		return false
	}

	return pctx.TerraformCliArgs.Contains(renderJSONCommand)
}

// isRenderCommand will return true if terragrunt was invoked with render
func isRenderCommand(pctx *ParsingContext) bool {
	if pctx.TerraformCliArgs == nil {
		return false
	}

	return pctx.TerraformCliArgs.Contains(renderCommand)
}

type dependencyNameKey struct{}

// contextWithDependencyName tags ctx with the HCL `dependency "<name>"` block label so
// dependency_output_fetch can include it as a span attribute.
func contextWithDependencyName(ctx context.Context, name string) context.Context {
	return context.WithValue(ctx, dependencyNameKey{}, name)
}

// dependencyNameFromContext returns the dependency block name attached to ctx, or "".
func dependencyNameFromContext(ctx context.Context) string {
	if v, ok := ctx.Value(dependencyNameKey{}).(string); ok {
		return v
	}

	return ""
}

// getOutputJSONWithCaching will run terragrunt output on the target config if it is not already cached.
func getOutputJSONWithCaching(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	targetConfig string,
) ([]byte, error) {
	locks := outputLocksFromContext(ctx)

	locks.Lock(targetConfig)
	defer locks.Unlock(targetConfig)

	l.Debugf(
		"Getting output of dependency %s for config %s",
		util.RelPathForLog(pctx.RootWorkingDir, targetConfig, pctx.LogShowAbsPaths),
		util.RelPathForLog(pctx.RootWorkingDir, pctx.TerragruntConfigPath, pctx.LogShowAbsPaths),
	)

	var newJSONBytes []byte

	fetchAttrs := map[string]any{
		"target": targetConfig,
		"caller": pctx.TerragruntConfigPath,
	}
	if name := dependencyNameFromContext(ctx); name != "" {
		fetchAttrs["dependency_name"] = name
	}

	err := telemetry.TelemeterFromContext(ctx).
		Collect(ctx, l, "dependency_output_fetch", fetchAttrs, func(fetchCtx context.Context, l log.Logger) error {
			jsonCache := cache.ContextCache[[]byte](fetchCtx, JSONOutputCacheContextKey)
			if cached, found := jsonCache.Get(fetchCtx, targetConfig); found {
				l.Debugf("%s was run before. Using cached output.", targetConfig)

				if span := trace.SpanFromContext(fetchCtx); span.IsRecording() {
					span.SetAttributes(attribute.Bool("cache_hit", true))
				}

				newJSONBytes = cached

				return nil
			}

			fetched, strategy, fetchErr := resolveOutputJSON(fetchCtx, pctx, l, targetConfig)

			if span := trace.SpanFromContext(fetchCtx); span.IsRecording() {
				span.SetAttributes(attribute.Bool("cache_hit", false))

				if strategy != "" {
					span.SetAttributes(attribute.String("strategy", strategy))
				}
			}

			if fetchErr != nil {
				return fetchErr
			}

			// `tofu/terraform output -json` stdout can be polluted with non-JSON text on either side of the JSON object:
			//   - Leading: AWS Client Side Monitoring (CSM) logs (e.g., "2023/05/04 20:22:43 Enabling CSM"),
			//     ANSI color escape sequences from warning blocks.
			//     Refs: https://github.com/aws/aws-sdk-go/blob/81d1cbbc6a2028023aff7bcab0fe1be320cd39f7/aws/session/session.go#L444
			//           https://github.com/gruntwork-io/terragrunt/issues/2233
			//   - Trailing: Terraform 1.15+ emits backend deprecation warnings (e.g., for the S3
			//     `dynamodb_table` parameter) on stdout after the JSON has already been printed.
			//     Refs: https://github.com/gruntwork-io/terragrunt/issues/6001
			//
			// To make parsing robust to either, isolate the first JSON object in the buffer.
			trimmed, trimErr := extractFirstJSONObject(fetched)
			if trimErr != nil {
				return TerragruntOutputParsingError{Path: targetConfig, Err: trimErr}
			}

			newJSONBytes = trimmed
			jsonCache.Put(fetchCtx, targetConfig, newJSONBytes)

			return nil
		})
	if err != nil {
		return nil, err
	}

	return newJSONBytes, nil
}

// extractFirstJSONObject returns the first complete JSON object found in data, ignoring any
// non-JSON content that precedes or follows it. This is needed because `tofu/terraform output -json`
// can intermix log lines, ANSI escape codes, or deprecation warnings with the JSON output, depending
// on the version and backend in use.
//
// If data contains no `{`, the original bytes are returned so downstream JSON parsing surfaces the
// usual "unexpected end of JSON input" error rather than a cryptic message from this helper.
func extractFirstJSONObject(data []byte) ([]byte, error) {
	start := bytes.IndexByte(data, '{')
	if start < 0 {
		return data, nil
	}

	dec := json.NewDecoder(bytes.NewReader(data[start:]))

	var raw json.RawMessage
	if err := dec.Decode(&raw); err != nil {
		return nil, err
	}

	return raw, nil
}

// adjustSourceForTargetModule rewrites a `--source` CLI override so it points at targetConfig's own module subdir
// rather than the original unit's. The override carries only the module root ("everything before //"); the subdir
// comes from the target's terraform source. A no-op when `--source` was not set or the target has no source.
func adjustSourceForTargetModule(
	pctx *ParsingContext,
	targetConfig string,
	targetCfg *TerragruntConfig,
) error {
	if pctx.Source == "" {
		return nil
	}

	moduleURL, _ := getter.SourceDirSubdir(pctx.Source)

	targetSource, err := GetTerragruntSourceForModule(
		moduleURL,
		filepath.Dir(targetConfig),
		targetCfg,
	)
	if err != nil {
		return err
	}

	pctx.Source = targetSource

	return nil
}

// resolveOutputJSON retrieves the outputs from the terraform state in the target configuration. It
// attempts to optimize retrieval if the following conditions are true:
//   - State backends are managed with a `remote_state` block.
//   - The `remote_state` block does not depend on any `dependency` outputs.
//
// If these conditions are met, terragrunt can avoid recursively retrieving dependency outputs by
// pulling the state file directly. Otherwise it falls back to running `terragrunt output`.
//
// The returned strategy string ("run", "state", or "init_folder") is purely for telemetry
// annotation on the caller's span; callers should not branch on it.
func resolveOutputJSON(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	targetConfig string,
) ([]byte, string, error) {
	l, pctx, err := pctx.WithDependencyConfigPath(l, targetConfig)
	if err != nil {
		return nil, "", err
	}

	// Set dependency-specific fields
	pctx.ForwardTFStdout = false
	pctx.CheckDependentUnits = false
	pctx.TerraformCommand = "output"
	pctx.TerraformCliArgs = iacargs.New().SetCommand("output").AppendFlag("-json")

	// Clear IAM if changed from original
	if pctx.IAMRoleOptions != pctx.OriginalIAMRoleOptions {
		pctx.IAMRoleOptions = iam.RoleOptions{}
	}

	pctx.dependencyOutputEnvKeys = nil

	credentialGetter, err := creds.ObtainCredsForParsing(
		ctx,
		l,
		pctx.Venv,
		pctx.AuthProviderCmd,
		shellRunOptsFromPctx(pctx),
	)
	if err != nil {
		return nil, "", err
	}

	authProviderEnv := maps.Clone(pctx.Venv.Env)

	// Decode dependency blocks, terraform source and extra_arguments, and terraform_binary, skipping hooks that may
	// reference the dependency namespace.
	partialTerragruntConfig, err := PartialParseConfigFile(
		ctx,
		pctx.WithDecodeList(DependencyBlock, TerraformExtraArgs, TerragruntVersionConstraints).WithDiagnosticsSuppressed(l),
		l,
		targetConfig,
		nil,
	)
	if err != nil {
		// The config being parsed may reference dependency outputs that this lightweight parse can't resolve; the
		// full output run can, so fall back instead of failing the dependent unit. That parse error is often the
		// misleading "no variable named dependency" diagnostic, so it is logged for debugging but not returned; the
		// full run is authoritative and reproduces any genuine error. runTerragruntOutputJSON retargets any `--source`
		// override to the target module from its fully resolved source, so a stale subdir from the calling unit — even
		// one whose source itself consumes a dependency output — is not carried into the run.
		l.Debugf(
			"Could not partially parse terraform block from target config %s: %v",
			pctx.TerragruntConfigPath,
			err,
		)
		l.Debugf("Falling back to terragrunt output.")

		out, runErr := runTerragruntOutputJSON(ctx, pctx, l, targetConfig, credentialGetter)

		return out, "run", runErr
	}

	// Only override TFPath if it was not explicitly set by the user via CLI or environment variable
	if !pctx.TFPathExplicitlySet && partialTerragruntConfig.TerraformBinary != "" {
		pctx.TFPath = partialTerragruntConfig.TerraformBinary
	}

	// Apply extra_arguments env_vars for the output command so env dependent backends can be read.
	applyExtraArgsEnvVarsForOutput(pctx, partialTerragruntConfig.Terraform)

	// If the Source is set, then we need to recompute it in the ctx of the target config.
	if err := adjustSourceForTargetModule(pctx, targetConfig, partialTerragruntConfig); err != nil {
		return nil, "", err
	}

	// First attempt to parse the `remote_state` blocks without parsing/getting dependency outputs. If this is possible,
	// proceed to routine that fetches remote state directly. Otherwise, fallback to calling `terragrunt output`
	// directly.

	// we need to suspend logging diagnostic errors on this attempt
	parseOptions := slices.Concat(
		pctx.ParserOptions,
		[]hclparse.Option{hclparse.WithDiagnosticsWriter(pctx.Venv, io.Discard, true)},
	)

	remoteStateTGConfig, err := PartialParseConfigFile(
		ctx,
		pctx.WithParseOption(parseOptions).WithDecodeList(
			RemoteStateBlock,
			TerragruntFlags,
			EngineBlock,
		),
		l,
		targetConfig,
		nil,
	)

	// Check err before dereferencing the config: a remote_state block that references the dependency namespace makes
	// this parse fail and return a nil config, so the short-circuit avoids a nil pointer panic.
	if err != nil || !canGetRemoteState(remoteStateTGConfig.RemoteState) {
		l.Debugf(
			"Could not parse remote_state block from target config %s",
			pctx.TerragruntConfigPath,
		)
		l.Debugf("Falling back to terragrunt output.")

		out, runErr := runTerragruntOutputJSON(ctx, pctx, l, targetConfig, credentialGetter)

		return out, "run", runErr
	}

	// In optimization mode, see if there is already an init-ed folder that terragrunt can use, and if so, run
	// `terraform output` in the working directory.
	isInit, workingDir, err := terragruntAlreadyInit(ctx, l, pctx, targetConfig)
	if err != nil {
		return nil, "", err
	}

	// Fetch engine options so they can be passed to the dependency functions
	engineOpts, err := remoteStateTGConfig.EngineOptions()
	if err != nil {
		return nil, "", err
	}

	pctx.EngineConfig = engineOpts

	// Keep output-specific environment variables out of the IAM credential source.
	clear(pctx.Venv.Env)
	maps.Copy(pctx.Venv.Env, authProviderEnv)

	mergedIAM := iam.MergeRoleOptions(
		remoteStateTGConfig.GetIAMRoleOptions(),
		pctx.OriginalIAMRoleOptions,
	)
	if err = credentialGetter.ObtainAndUpdateEnvIfNecessary(
		ctx,
		l,
		pctx.Venv,
		amazonsts.NewProvider(l, mergedIAM, pctx.Venv.Env),
	); err != nil {
		return nil, "", err
	}

	// Match the normal runner: output-specific environment variables are the
	// final override after auth-provider and IAM/STS credentials.
	applyExtraArgsEnvVarsForOutput(pctx, partialTerragruntConfig.Terraform)

	workspace := ""
	if shouldFetchDependencyOutputFromState(pctx, remoteStateTGConfig.RemoteState) {
		workspace, err = dependencyStateWorkspace(pctx, workingDir)
		if err != nil {
			l.Debugf("Could not determine dependency workspace for direct state retrieval: %v", err)
			l.Debugf("Falling back to native output retrieval.")
		}
	}

	if workspace != "" {
		out, fetchErr := getTerragruntOutputJSONFromRemoteState(
			ctx,
			pctx,
			l,
			targetConfig,
			remoteStateTGConfig.RemoteState,
			workspace,
		)

		return out, "state", fetchErr
	}

	if isInit {
		out, fetchErr := getTerragruntOutputJSONFromInitFolder(
			ctx,
			pctx,
			l,
			workingDir,
		)

		return out, "init_folder", fetchErr
	}

	out, fetchErr := getTerragruntOutputJSONFromRemoteState(
		ctx,
		pctx,
		l,
		targetConfig,
		remoteStateTGConfig.RemoteState,
		workspace,
	)

	return out, "state", fetchErr
}

// canGetRemoteState returns true if the remote state block is not nil and dependency optimization is not disabled
func canGetRemoteState(remoteState *remotestate.RemoteState) bool {
	return remoteState != nil && !remoteState.DisableDependencyOptimization
}

// directStateBackend groups direct-state eligibility and retrieval for one backend.
type directStateBackend struct {
	supported func(*ParsingContext, *remotestate.RemoteState) bool
	read      func(context.Context, log.Logger, *ParsingContext, *remotestate.RemoteState, string) ([]byte, error)
}

// directStateBackends contains the backends with direct dependency-state support.
var directStateBackends = map[string]directStateBackend{
	s3backend.BackendName: {
		supported: s3DirectStateReadSupported,
		read:      getTerragruntOutputJSONFromRemoteStateS3,
	},
	gcsbackend.BackendName: {
		supported: gcsDirectStateReadSupported,
		read:      getTerragruntOutputJSONFromRemoteStateGCS,
	},
	azurermbackend.BackendName: {
		supported: func(pctx *ParsingContext, remoteState *remotestate.RemoteState) bool {
			return pctx.Experiments.Evaluate(experiment.AzureBackend) &&
				azureDirectStateReadSupported(pctx, remoteState)
		},
		read: getTerragruntOutputJSONFromRemoteStateAzurerm,
	},
}

// shouldFetchDependencyOutputFromState reports whether a registered backend supports a direct state read.
func shouldFetchDependencyOutputFromState(pctx *ParsingContext, remoteState *remotestate.RemoteState) bool {
	if remoteState == nil ||
		!pctx.Experiments.Evaluate(experiment.DependencyFetchOutputFromState) ||
		pctx.NoDependencyFetchOutputFromState {
		return false
	}

	stateBackend, ok := directStateBackends[remoteState.BackendName]
	if !ok {
		return false
	}

	return stateBackend.supported(pctx, remoteState)
}

func backendConfigValueSet(config backend.Config, key string) bool {
	value, ok := config[key]
	if !ok || value == nil {
		return false
	}

	if value, ok := value.(string); ok {
		return value != ""
	}

	return true
}

type backendConfigStringResult struct {
	value      string
	configured bool
	valid      bool
}

func backendConfigString(config backend.Config, key string) backendConfigStringResult {
	value, configured := config[key]
	if !configured {
		return backendConfigStringResult{valid: true}
	}

	parsed, valid := value.(string)

	return backendConfigStringResult{value: parsed, configured: true, valid: valid}
}

func backendConfigStringWithEnv(
	config backend.Config,
	key string,
	env map[string]string,
	envKeys ...string,
) (string, bool) {
	result := backendConfigString(config, key)
	if !result.valid {
		return "", false
	}

	if result.configured {
		return result.value, true
	}

	return firstNonEmptyFromMap(env, envKeys...), true
}

type backendConfigBoolResult struct {
	value      bool
	configured bool
	valid      bool
}

func backendConfigBool(config backend.Config, key string) backendConfigBoolResult {
	value, ok := config[key]
	if !ok {
		return backendConfigBoolResult{}
	}

	switch value := value.(type) {
	case bool:
		return backendConfigBoolResult{value: value, configured: true, valid: true}
	case string:
		parsed, err := strconv.ParseBool(value)
		if err != nil {
			return backendConfigBoolResult{configured: true}
		}

		return backendConfigBoolResult{value: parsed, configured: true, valid: true}
	default:
		return backendConfigBoolResult{configured: true}
	}
}

func backendConfigBoolWithEnv(
	config backend.Config,
	key string,
	envValue string,
) backendConfigBoolResult {
	result := backendConfigBool(config, key)
	if result.configured {
		return result
	}

	if envValue == "" {
		return backendConfigBoolResult{valid: true}
	}

	parsed, err := strconv.ParseBool(envValue)

	return backendConfigBoolResult{value: parsed, configured: true, valid: err == nil}
}

func backendConfigStrictBoolWithEnv(
	config backend.Config,
	key string,
	envValue string,
	defaultValue bool,
) backendConfigBoolResult {
	if value, configured := config[key]; configured {
		parsed, valid := value.(bool)

		return backendConfigBoolResult{value: parsed, configured: true, valid: valid}
	}

	if envValue == "" {
		return backendConfigBoolResult{value: defaultValue, valid: true}
	}

	parsed, err := strconv.ParseBool(envValue)

	return backendConfigBoolResult{value: parsed, configured: true, valid: err == nil}
}

func firstNonEmptyFromMap(env map[string]string, keys ...string) string {
	for _, key := range keys {
		if value := env[key]; value != "" {
			return value
		}
	}

	return ""
}

// dependencyStateWorkspace mirrors OpenTofu/Terraform workspace selection: a non-empty
// TF_WORKSPACE overrides the selection persisted in TF_DATA_DIR/environment, and an absent
// or empty selection means the default workspace. Errors are returned so callers can keep
// the native output path rather than guessing a state object.
func dependencyStateWorkspace(pctx *ParsingContext, workingDir string) (string, error) {
	if pctx.Venv == nil {
		panic(ErrParsingContextVenvNil)
	}

	if workspace := pctx.Venv.Env["TF_WORKSPACE"]; workspace != "" {
		// PathEscape leaves "." and ".." unchanged, but path.Join then drops those
		// segments and can select the wrong state object.
		if workspace == "." || workspace == ".." || url.PathEscape(workspace) != workspace {
			return "", InvalidTFWorkspaceError{Workspace: workspace}
		}

		return workspace, nil
	}

	dataDir := tf.DefaultTFDataDir
	if configured := pctx.Venv.Env["TF_DATA_DIR"]; configured != "" {
		dataDir = configured
	}

	if !filepath.IsAbs(dataDir) {
		dataDir = filepath.Join(workingDir, dataDir)
	}

	workspaceFile := filepath.Join(dataDir, "environment")

	exists, err := vfs.FileExists(pctx.Venv.FS, workspaceFile)
	if err != nil {
		return "", fmt.Errorf("checking dependency workspace file %s: %w", workspaceFile, err)
	}

	if !exists {
		return defaultStateWorkspace, nil
	}

	contents, err := vfs.ReadFile(pctx.Venv.FS, workspaceFile)
	if err != nil {
		return "", fmt.Errorf("reading dependency workspace file %s: %w", workspaceFile, err)
	}

	workspace := string(bytes.TrimSpace(contents))
	if workspace == "" {
		return defaultStateWorkspace, nil
	}

	return workspace, nil
}

// applyExtraArgsEnvVarsForOutput merges extra_arguments env_vars whose commands include output into pctx.Venv.Env
func applyExtraArgsEnvVarsForOutput(pctx *ParsingContext, terraformConfig *TerraformConfig) {
	if terraformConfig == nil {
		return
	}

	for i := range terraformConfig.ExtraArgs {
		arg := &terraformConfig.ExtraArgs[i]
		if arg.EnvVars == nil || !slices.Contains(arg.Commands, tf.CommandNameOutput) {
			continue
		}

		for key := range *arg.EnvVars {
			pctx.markDependencyOutputEnvKey(key)
		}

		maps.Copy(pctx.Venv.Env, *arg.EnvVars)
	}
}

func (ctx *ParsingContext) markDependencyOutputEnvKey(key string) {
	if ctx.dependencyOutputEnvKeys == nil {
		ctx.dependencyOutputEnvKeys = make(map[string]struct{})
	}

	ctx.dependencyOutputEnvKeys[key] = struct{}{}
}

func (ctx *ParsingContext) dependencyOutputEnvOverridden(key string) bool {
	if ctx == nil || ctx.dependencyOutputEnvKeys == nil {
		return false
	}

	_, overridden := ctx.dependencyOutputEnvKeys[key]

	return overridden
}

// terragruntAlreadyInit returns true if it detects that the module specified by the given terragrunt configuration is
// already initialized with the terraform source. This will also return the working directory where you can run
// terraform.
func terragruntAlreadyInit(
	ctx context.Context,
	l log.Logger,
	pctx *ParsingContext,
	configPath string,
) (bool, string, error) {
	// We need to first determine the working directory where the terraform source should be located. This is dependent
	// on the source field of the terraform block in the config.
	terraformBlockTGConfig, err := PartialParseConfigFile(
		ctx,
		pctx.WithDecodeList(TerraformSource),
		l,
		configPath,
		nil,
	)
	if err != nil {
		return false, "", err
	}

	sourceURL, err := GetTerraformSourceURL(
		pctx.Source,
		pctx.SourceMap,
		pctx.OriginalTerragruntConfigPath,
		terraformBlockTGConfig,
	)
	if err != nil {
		return false, "", err
	}

	// sourceURL will always be at least "." (current directory) to ensure cache is always used.
	// Always compute the cache working directory using NewSource.
	walkWithSymlinks := pctx.Experiments.Evaluate(experiment.Symlinks)
	// Apply the rewrite so the .terraform existence check below targets the
	// same working dir the downloader writes to. Without it, plain
	// https://www.googleapis.com/storage/... sources force a redundant init
	// on every run.
	sourceURL = tf.RewriteLegacyGCSPublicSource(ctx, l, sourceURL, pctx.StrictControls)

	terraformSource, err := tf.NewSource(
		l,
		pctx.Venv.FS,
		sourceURL,
		pctx.DownloadDir,
		pctx.WorkingDir,
		walkWithSymlinks,
	)
	if err != nil {
		return false, "", err
	}
	// We're only interested in the computed working dir.
	workingDir := terraformSource.WorkingDir
	// Terragrunt is already init-ed if the terraform state dir (.terraform) exists in the working dir.
	// NOTE: if the ref changes, the workingDir would be different as the download dir includes a base64 encoded hash of
	// the source URL with ref. This would ensure that this routine would not return true if the new ref is not already
	// init-ed.
	return vfs.Exists(pctx.Venv.FS, filepath.Join(workingDir, ".terraform")), workingDir, nil
}

// getTerragruntOutputJSONFromInitFolder will retrieve the outputs directly from the module's working directory without
// running init. Callers must populate pctx.Venv.Env with auth-provider-cmd credentials and any
// TG_IAM_ROLE assumption beforehand.
func getTerragruntOutputJSONFromInitFolder(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	terraformWorkingDir string,
) ([]byte, error) {
	targetConfigPath := pctx.TerragruntConfigPath

	tfRunOpts := setupTFRunOptsForBareTerraform(pctx, terraformWorkingDir)

	l.Debugf(
		"Unit '%s' is already init-ed. "+
			"Retrieving outputs directly from working directory.",
		util.RelPathForLog(
			pctx.RootWorkingDir,
			filepath.Dir(targetConfigPath),
			pctx.LogShowAbsPaths,
		),
	)

	bareCtx := tf.ContextWithTerraformCommandHook(ctx, nil)

	// Discard streamed stdout; the JSON is read from the returned CmdOutput.
	discardV := pctx.Venv.WithWriter(io.Discard)

	out, err := tf.RunCommandWithOutput(
		bareCtx,
		l,
		discardV,
		tfRunOpts,
		tf.CommandNameOutput,
		"-json",
	)
	if err != nil {
		return nil, err
	}

	jsonString := strings.TrimSpace(out.Stdout.String())
	jsonBytes := []byte(jsonString)

	l.Debugf(
		"Retrieved output from %s as json: %s",
		util.RelPathForLog(
			pctx.RootWorkingDir,
			targetConfigPath,
			pctx.LogShowAbsPaths,
		),
		jsonString,
	)

	return jsonBytes, nil
}

// getTerragruntOutputJSONFromRemoteState will retrieve the outputs directly by using just the remote state block. This
// uses terraform's feature where `output` and `init` can work without the real source, as long as you have the
// `backend` configured.
// To do this, this function will:
// - Create a temporary folder
// - Generate the backend.tf file with the backend configuration from the remote_state block
// - Copy the provider lock file, if there is one in the dependency's working directory
// - Run terraform init and terraform output
// - Clean up folder once json file is generated
// NOTE: terragruntOptions should be in the ctx of the targetConfig already.
func getTerragruntOutputJSONFromRemoteState(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	targetConfigPath string,
	remoteState *remotestate.RemoteState,
	workspace string,
) ([]byte, error) {
	l.Debugf(
		"Detected remote state block with generate config. Resolving dependency by pulling remote state.",
	)
	// Create working directory where we will run terraform in. We will create the temporary directory in the download
	// directory for consistency with other file generation capabilities of terragrunt. Make sure it is cleaned up
	// before the function returns.
	//
	// The parent has to be created on the venv filesystem rather than the host:
	// MkdirTemp below and CopyLockFile further down both work through it, and a
	// memory-backed filesystem has no parent to place the temp dir in otherwise.
	if err := pctx.Venv.FS.MkdirAll(pctx.DownloadDir, downloadDirPerms); err != nil {
		return nil, err
	}

	tempWorkDir, err := vfs.MkdirTemp(pctx.Venv.FS, pctx.DownloadDir, "")
	if err != nil {
		return nil, err
	}

	defer func(path string) {
		err := pctx.Venv.FS.RemoveAll(path)
		if err != nil {
			l.Warnf("Failed to remove %s: %v", path, err)
		}
	}(tempWorkDir)

	l.Debugf("Setting dependency working directory to %s", tempWorkDir)

	tfRunOpts := setupTFRunOptsForBareTerraform(pctx, tempWorkDir)

	// To speed up dependencies processing it is possible to retrieve its output directly from the backend without init dependencies
	// A non-empty workspace means the caller already found a supported backend, so
	// the reader lookup below cannot miss.
	if stateBackend, supported := directStateBackends[remoteState.BackendName]; supported && workspace != "" {
		jsonBytes, readErr := stateBackend.read(ctx, l, pctx, remoteState, workspace)
		if readErr != nil {
			return nil, readErr
		}

		l.Debugf(
			"Retrieved dependency outputs for %s directly from %s state",
			pctx.TerragruntConfigPath,
			remoteState.BackendName,
		)

		return jsonBytes, nil
	}

	// Generate the backend configuration in the working dir. If no generate config is set on the remote state block,
	// set a temporary generate config so we can generate the backend code.
	if remoteState.Generate == nil {
		remoteState.Generate = &remotestate.ConfigGenerate{
			Path:     "backend.tf",
			IfExists: codegen.ExistsOverwriteTerragruntStr,
		}
	}

	if err := remoteState.GenerateOpenTofuCode(ctx, l, pctx.Venv, tempWorkDir); err != nil {
		return nil, err
	}

	l.Debugf("Generated remote state configuration in working dir %s", tempWorkDir)

	// Check for a provider lock file and copy it to the working dir if it exists.
	terragruntDir := filepath.Dir(pctx.TerragruntConfigPath)
	if err := runcfg.CopyLockFile(
		l,
		pctx.Venv.FS,
		pctx.RootWorkingDir,
		pctx.LogShowAbsPaths,
		terragruntDir,
		tempWorkDir,
	); err != nil {
		return nil, err
	}

	// The working directory is now set up to interact with the state, so pull it down to get the json output.

	// Clone pctx and discard init stdout so it doesn't leak into the caller's output buffer.
	initPctx := pctx.Clone()
	initPctx.Venv.Writers = initPctx.Venv.Writers.WithWriter(io.Discard)

	// First run init to setup the backend configuration so that we can run output.
	runTerraformInitForDependencyOutput(ctx, initPctx, l, tempWorkDir)

	// Now that the backend is initialized, run terraform output to get the data and return it.
	bareCtx := tf.ContextWithTerraformCommandHook(ctx, nil)

	// Discard streamed stdout; the JSON is read from the returned CmdOutput.
	discardV := pctx.Venv.WithWriter(io.Discard)

	out, err := tf.RunCommandWithOutput(
		bareCtx,
		l,
		discardV,
		tfRunOpts,
		tf.CommandNameOutput,
		"-json",
	)
	if err != nil {
		return nil, err
	}

	jsonString := strings.TrimSpace(out.Stdout.String())
	jsonBytes := []byte(jsonString)
	l.Debugf("Retrieved output from %s as json: %s", targetConfigPath, jsonString)

	return jsonBytes, nil
}

// setupTFRunOptsForBareTerraform builds a *tf.TFOptions for running terraform without
// going through the full RunTerragrunt operation. Callers must obtain credentials
// (auth-provider-cmd and any TG_IAM_ROLE assumption) before invoking, so those steps
// run from the unit's directory rather than the bare-terraform working directory.
func setupTFRunOptsForBareTerraform(pctx *ParsingContext, workingDir string) *tf.TFOptions {
	shellOpts := shellRunOptsFromPctx(pctx)
	shellOpts.WorkingDir = workingDir

	return &tf.TFOptions{
		JSONLogFormat:                pctx.JSONLogFormat,
		OriginalTerragruntConfigPath: pctx.OriginalTerragruntConfigPath,
		ShellOptions:                 shellOpts,
	}
}

// runTerragruntOutputJSON uses terragrunt running functions to extract the json output from the target config.
func runTerragruntOutputJSON(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	targetConfig string,
	credentialGetter *creds.Getter,
) ([]byte, error) {
	// Update the stdout buffer so we can capture the output
	var stdoutBuffer bytes.Buffer

	stdoutBufferWriter := bufio.NewWriter(&stdoutBuffer)

	// Override pctx for this specific operation
	pctx = pctx.Clone()
	pctx.ForwardTFStdout = false
	pctx.JSONLogFormat = false
	pctx.Venv.Writers = pctx.Venv.Writers.WithWriter(stdoutBufferWriter)

	cfg, err := ParseConfigFile(ctx, pctx, l, pctx.TerragruntConfigPath, nil)
	if err != nil {
		return nil, err
	}

	// [resolveOutputJSON] falls back here when its lightweight parse could not read the target's config, so the parse
	// above is the first look at the binary the target configures. Without this the run would read the target's state
	// through whichever binary the calling unit resolved.
	if !pctx.TFPathExplicitlySet && cfg.TerraformBinary != "" {
		pctx.TFPath = cfg.TerraformBinary
	}

	// A `--source` override carries the module subdir of whichever unit the run started from, not the dependency being
	// resolved here, so retarget it to this module's own subdir before running. The full parse above resolves the
	// target's real source even when it references a dependency output (which the lightweight parse in resolveOutputJSON
	// cannot), making it the authoritative input. Without this, a dependency whose source consumes a dependency output
	// would run against the caller's stale subdir and read the wrong module's outputs.
	if err := adjustSourceForTargetModule(pctx, targetConfig, cfg); err != nil {
		return nil, err
	}

	runCfg := cfg.ToRunConfig(l, pctx.Venv.FS)

	// Build run.Options directly from ParsingContext fields.
	runOpts := run.NewOptions()
	runOpts.LogShowAbsPaths = pctx.LogShowAbsPaths
	runOpts.LogDisableErrorSummary = pctx.LogDisableErrorSummary
	runOpts.TerragruntConfigPath = pctx.TerragruntConfigPath
	runOpts.OriginalTerragruntConfigPath = pctx.OriginalTerragruntConfigPath
	runOpts.UnitDir = pctx.WorkingDir
	runOpts.CacheDir = pctx.WorkingDir
	runOpts.RootWorkingDir = pctx.RootWorkingDir
	runOpts.DownloadDir = pctx.DownloadDir
	runOpts.Source = pctx.Source
	runOpts.SourceMap = pctx.SourceMap
	runOpts.TerraformCommand = pctx.TerraformCommand
	runOpts.OriginalTerraformCommand = pctx.OriginalTerraformCommand
	runOpts.TerraformCliArgs = pctx.TerraformCliArgs
	runOpts.IAMRoleOptions = pctx.IAMRoleOptions
	runOpts.OriginalIAMRoleOptions = pctx.OriginalIAMRoleOptions
	runOpts.Experiments = pctx.Experiments
	runOpts.StrictControls = pctx.StrictControls
	runOpts.FeatureFlags = pctx.FeatureFlags
	runOpts.EngineConfig = pctx.EngineConfig
	runOpts.EngineOptions = pctx.EngineOptions
	runOpts.TFPath = pctx.TFPath
	runOpts.TofuImplementation = pctx.TofuImplementation
	runOpts.Headless = pctx.Headless
	runOpts.Debug = pctx.Debug
	runOpts.AutoInit = pctx.AutoInit
	runOpts.BackendBootstrap = pctx.BackendBootstrap
	runOpts.Telemetry = pctx.Telemetry
	runOpts.AuthProviderCmd = pctx.AuthProviderCmd
	runOpts.CASCloneDepth = pctx.CASCloneDepth

	err = run.Run(ctx, l, pctx.Venv, runOpts, report.NewReport(), runCfg, credentialGetter)
	if err != nil {
		return nil, err
	}

	err = stdoutBufferWriter.Flush()
	if err != nil {
		return nil, err
	}

	jsonString := strings.TrimSpace(stdoutBuffer.String())
	jsonBytes := []byte(jsonString)

	l.Debugf("Retrieved output from %s as json: %s", targetConfig, jsonString)

	return jsonBytes, nil
}

// shellRunOptsFromPctx builds a *shell.ShellOptions from ParsingContext flat fields.
func shellRunOptsFromPctx(pctx *ParsingContext) *shell.ShellOptions {
	s := shell.NewShellOptions(pctx.Venv.Env).
		WithWorkingDir(pctx.WorkingDir).
		WithTelemetry(pctx.Telemetry).
		WithEngine(pctx.EngineConfig, pctx.EngineOptions).
		WithTFPath(pctx.TFPath).
		WithRootWorkingDir(pctx.RootWorkingDir).
		WithExperiments(pctx.Experiments).
		WithHeadless(pctx.Headless).
		WithForwardTFStdout(pctx.ForwardTFStdout)
	s.LogShowAbsPaths = pctx.LogShowAbsPaths
	s.LogDisableErrorSummary = pctx.LogDisableErrorSummary

	return s
}

// tfRunOptsFromPctx builds a *tf.RunOptions from ParsingContext flat fields.
func tfRunOptsFromPctx(pctx *ParsingContext) *tf.TFOptions {
	return &tf.TFOptions{
		JSONLogFormat:                pctx.JSONLogFormat,
		OriginalTerragruntConfigPath: pctx.OriginalTerragruntConfigPath,
		ShellOptions:                 shellRunOptsFromPctx(pctx),
	}
}

// TerraformOutputJSONToCtyValueMap takes the terraform output json and converts to a mapping between output keys to the
// parsed cty.Value encoding of the json objects.
func TerraformOutputJSONToCtyValueMap(
	targetConfigPath string,
	jsonBytes []byte,
) (map[string]cty.Value, error) {
	// When getting all outputs, terraform returns a json with the data containing metadata about the types, so we
	// can't quite return the data directly. Instead, we will need further processing to get the output we want.
	// To do so, we first Unmarshal the json into a simple go map to a OutputMeta struct.
	type OutputMeta struct {
		Type      json.RawMessage `json:"type"`
		Value     json.RawMessage `json:"value"`
		Sensitive bool            `json:"sensitive"`
	}

	var outputs map[string]OutputMeta

	err := json.Unmarshal(jsonBytes, &outputs)
	if err != nil {
		return nil, TerragruntOutputParsingError{Path: targetConfigPath, Err: err}
	}

	flattenedOutput := map[string]cty.Value{}

	for k, v := range outputs {
		outputType, err := ctyjson.UnmarshalType(v.Type)
		if err != nil {
			return nil, TerragruntOutputParsingError{Path: targetConfigPath, Err: err}
		}

		outputVal, err := ctyjson.Unmarshal(v.Value, outputType)
		if err != nil {
			return nil, TerragruntOutputParsingError{Path: targetConfigPath, Err: err}
		}

		flattenedOutput[k] = outputVal
	}

	return flattenedOutput, nil
}

// runTerraformInitForDependencyOutput will run terraform init in a mode that doesn't pull down plugins or modules. Note
// that this will cause the command to fail for most modules as terraform init does a validation check to make sure the
// plugins are available, even though we don't need it for our purposes (terraform output does not depend on any of the
// plugins being available). As such this command will ignore errors in the init command.
// To help with debuggability, the errors will be printed to the console when TG_LOG=debug is set.
func runTerraformInitForDependencyOutput(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	workingDir string,
) {
	stderr := bytes.Buffer{}

	initRunOpts := tfRunOptsFromPctx(pctx)
	initRunOpts.ShellOptions.WorkingDir = workingDir

	initV := pctx.Venv.WithErrWriter(&stderr)

	bareCtx := tf.ContextWithTerraformCommandHook(ctx, nil)

	if err := tf.RunCommand(
		bareCtx,
		l,
		initV,
		initRunOpts,
		tf.CommandNameInit,
		"-get=false",
	); err != nil {
		l.Debugf("Ignoring expected error from dependency init call")
		l.Debugf("Init call stderr:")
		l.Debugf("%s", stderr.String())
	}
}

func (deps Dependencies) FilteredWithoutConfigPath() Dependencies {
	var filteredDeps Dependencies

	for _, dep := range deps {
		if !dep.ConfigPath.IsNull() {
			filteredDeps = append(filteredDeps, dep)
		}
	}

	return filteredDeps
}

// foldSiblingAutoIncludeDeps merges the registered sibling autoinclude dependency blocks over the unit's own same-name blocks (shallow, by name, with the autoinclude winning), mirroring a regular include's default merge strategy.
func foldSiblingAutoIncludeDeps(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	deps []Dependency,
) ([]Dependency, error) {
	if pctx.TrackInclude == nil || pctx.TrackInclude.AutoIncludeOverride == nil {
		return deps, nil
	}

	autoIncludePath := pctx.TrackInclude.AutoIncludeOverride.Path

	autoFile, err := parseAutoIncludeFileCached(ctx, pctx, autoIncludePath)
	if err != nil {
		return nil, err
	}

	// Rescope to the autoinclude's own locals so the unit's locals do not leak into the autoinclude decode.
	autoPctx := pctx.Clone()
	// Files the autoinclude pulls in through its own includes must not re-merge a sibling autoinclude.
	autoPctx.skipAutoIncludeMerge = true

	baseBlocks, err := DecodeBaseBlocks(ctx, autoPctx, l, autoFile, nil)
	if err != nil {
		return nil, err
	}

	if baseBlocks != nil {
		autoPctx = autoPctx.WithTrackInclude(baseBlocks.TrackInclude)
		autoPctx = autoPctx.WithFeatures(baseBlocks.FeatureFlags)
		autoPctx = autoPctx.WithLocals(baseBlocks.Locals)
	}

	evalCtx, err := createTerragruntEvalContext(ctx, autoPctx, l, autoIncludePath)
	if err != nil {
		return nil, err
	}

	decoded := TerragruntDependency{}
	if err := autoFile.Decode(&decoded, evalCtx); err != nil {
		return nil, err
	}

	// Fold in dependency blocks the autoinclude inherits through its own include blocks, mirroring the unit decode path so inherited deps resolve before the unit body is evaluated.
	if autoPctx.TrackInclude != nil && len(autoPctx.TrackInclude.CurrentList) > 0 {
		merged, err := handleIncludeForDependency(ctx, autoPctx, l, decoded)
		if err != nil {
			return nil, err
		}

		decoded = *merged
	}

	autoDeps := decoded.Dependencies.FilteredWithoutConfigPath()
	if len(autoDeps) == 0 {
		return deps, nil
	}

	return mergeDependencyBlocks(deps, autoDeps), nil
}

// parseAutoIncludeFileCached parses the sibling autoinclude through the run-scoped HCL file cache so repeated dependency-output decodes reuse one parse, rebinding the shared AST to a fresh parser per call.
func parseAutoIncludeFileCached(
	ctx context.Context,
	pctx *ParsingContext,
	autoIncludePath string,
) (*hclparse.File, error) {
	fileInfo, err := pctx.Venv.FS.Stat(autoIncludePath)
	if err != nil {
		return nil, err
	}

	hclCache := cache.ContextCache[*hclparse.File](ctx, HclCacheContextKey)
	// Prefix the key so it cannot collide with the unit-config keys ParseConfigFile stores.
	cacheKey := fmt.Sprintf("autoinclude-%v-%v", autoIncludePath, fileInfo.ModTime().UnixMicro())

	if cached, found := hclCache.Get(ctx, cacheKey); found {
		return cached.Rebind(hclparse.NewParser(pctx.ParserOptions...)), nil
	}

	file, err := hclparse.NewParser(pctx.ParserOptions...).
		ParseFromFile(pctx.Venv.FS, autoIncludePath)
	if err != nil {
		return nil, err
	}

	hclCache.Put(ctx, cacheKey, file)

	return file, nil
}

// decodeDependencyBlocksWithAutoIncludeOverrides decodes the file's dependency blocks, leaving
// out the ones a sibling autoinclude replaces wholesale.
//
// A replaced block contributes nothing to the final config, so evaluating it can only raise
// errors about a body that is about to be thrown away. A unit source whose config_path reads a
// values attribute the stack stopped setting hits exactly that.
func decodeDependencyBlocksWithAutoIncludeOverrides(
	ctx context.Context,
	pctx *ParsingContext,
	l log.Logger,
	file *hclparse.File,
	evalContext *hcl.EvalContext,
) (Dependencies, error) {
	overrides, err := siblingAutoIncludeDepOverrides(ctx, pctx)
	if err != nil {
		return nil, err
	}

	return decodeDependencyBlocks(
		ctx,
		pctx,
		l,
		file,
		evalContext,
		hclparse.WithSkipLabels(overrides),
	)
}

// siblingAutoIncludeDepOverrides returns the names of the dependency blocks the sibling
// autoinclude replaces wholesale, or nil when no autoinclude is registered.
//
// [mergeDependencyBlocks] keys the merge on a dependency's name, so an unexpanded autoinclude
// block named vpc displaces the unit's unexpanded vpc outright. Expansion keys the merge per
// instance instead, so WithSkipLabels leaves expanded unit blocks alone. Encoding then rejects a
// bare autoinclude label that would sit beside those instances. [hclparse.File.UnexpandedLabels]
// reports only the unexpanded names for that reason.
func siblingAutoIncludeDepOverrides(
	ctx context.Context,
	pctx *ParsingContext,
) (map[string]struct{}, error) {
	if !hasSiblingAutoInclude(pctx) {
		return nil, nil
	}

	autoFile, err := parseAutoIncludeFileCached(ctx, pctx, pctx.TrackInclude.AutoIncludeOverride.Path)
	if err != nil {
		return nil, err
	}

	return autoFile.UnexpandedLabels(MetadataDependency, &Dependency{})
}

// IsValidConfigPath checks if a cty.Value is a valid, usable config path.
func IsValidConfigPath(v cty.Value) bool {
	if v.IsNull() || !v.IsWhollyKnown() || !v.Type().Equals(cty.String) {
		return false
	}

	// Empty string is not a valid config path
	if v.AsString() == "" {
		return false
	}

	return true
}
