// Package options provides a set of options that configure the behavior of the Terragrunt program.
package options

import (
	"context"
	"encoding/json"
	"fmt"
	"math"
	"os"
	"path/filepath"
	"time"

	"errors"

	"github.com/gruntwork-io/terragrunt/internal/cloner"
	"github.com/gruntwork-io/terragrunt/internal/engine"
	"github.com/gruntwork-io/terragrunt/internal/errorconfig"
	"github.com/gruntwork-io/terragrunt/internal/experiment"
	"github.com/gruntwork-io/terragrunt/internal/filter"
	"github.com/gruntwork-io/terragrunt/internal/iacargs"
	"github.com/gruntwork-io/terragrunt/internal/iam"
	pcoptions "github.com/gruntwork-io/terragrunt/internal/providercache/options"
	"github.com/gruntwork-io/terragrunt/internal/report"
	"github.com/gruntwork-io/terragrunt/internal/strict"
	"github.com/gruntwork-io/terragrunt/internal/strict/controls"
	"github.com/gruntwork-io/terragrunt/internal/telemetry"
	"github.com/gruntwork-io/terragrunt/internal/tfimpl"
	"github.com/gruntwork-io/terragrunt/internal/tips"
	"github.com/gruntwork-io/terragrunt/internal/util"
	"github.com/gruntwork-io/terragrunt/internal/vexec"
	"github.com/gruntwork-io/terragrunt/pkg/log"
	"github.com/gruntwork-io/terragrunt/pkg/log/format"
	"github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders"
	"github.com/hashicorp/go-version"
	"github.com/puzpuzpuz/xsync/v4"
)

const ContextKey ctxKey = iota

const (
	DefaultMaxFoldersToCheck = 100

	// no limits on parallelism by default (limited by GOPROCS)
	DefaultParallelism = math.MaxInt32

	// TofuDefaultPath command to run tofu
	TofuDefaultPath = "tofu"

	// TerraformDefaultPath just takes terraform from the path
	TerraformDefaultPath = "terraform"

	// Default to naming it `terragrunt_rendered.json` in the terragrunt config directory.
	DefaultJSONOutName = "terragrunt_rendered.json"

	DefaultSignalsFile = "error-signals.json"

	DefaultTFDataDir = ".terraform"

	defaultExcludesFile = ".terragrunt-excludes"
	defaultFiltersFile  = ".terragrunt-filters"

	DefaultLogLevel = log.InfoLevel
)

var (
	DefaultWrappedPath = identifyDefaultWrappedExecutable(context.Background())

	defaultVersionManagerFileName = []string{
		".terraform-version",
		".tool-versions",
		"mise.toml",
		".mise.toml",
	}
)

type ctxKey byte

// TerragruntOptions represents options that configure the behavior of the Terragrunt program
type TerragruntOptions struct {
	// Version of terragrunt
	TerragruntVersion *version.Version `clone:"shadowcopy"`
	// FeatureFlags is a map of feature flags to enable.
	FeatureFlags *xsync.Map[string, string] `clone:"shadowcopy"`
	// EngineConfig holds the resolved engine configuration from HCL.
	EngineConfig *engine.EngineConfig
	// EngineOptions groups CLI-supplied engine options.
	EngineOptions *engine.EngineOptions
	// Telemetry are telemetry options.
	Telemetry *telemetry.Options
	// Attributes to override in AWS provider nested within modules as part of the aws-provider-patch command.
	AwsProviderPatchOverrides map[string]string
	// Version of terraform (obtained by running 'terraform version')
	TerraformVersion *version.Version `clone:"shadowcopy"`
	// Errors is a configuration for error handling.
	Errors *errorconfig.Config
	// Map to replace terraform source locations.
	SourceMap map[string]string
	// StackAction is the action that should be performed on the stack.
	StackAction string
	// IAM Role options that should be used when authenticating to AWS.
	IAMRoleOptions iam.RoleOptions
	// IAM Role options set from command line.
	OriginalIAMRoleOptions iam.RoleOptions
	// Current Terraform command being executed by Terragrunt
	TerraformCommand string
	// StackOutputFormat format how the stack output is rendered.
	StackOutputFormat         string
	TerragruntStackConfigPath string
	// Location of the original Terragrunt config file.
	OriginalTerragruntConfigPath string
	// Unlike `WorkingDir`, this path is the same for all dependencies and
	// points to the root working directory specified in the CLI.
	RootWorkingDir string
	// Download Terraform configurations from the specified source location into a temporary folder
	Source string
	// The working directory in which to run Terraform
	WorkingDir string
	// Location (or name) of the OpenTofu/Terraform binary
	TFPath string
	// Download Terraform configurations specified in the Source parameter into this folder
	DownloadDir string
	// Original Terraform command being executed by Terragrunt.
	OriginalTerraformCommand string
	// Terraform implementation tool (e.g. terraform, tofu) that terragrunt is wrapping
	TofuImplementation tfimpl.Type
	// The file path that terragrunt should use when rendering the terragrunt.hcl config as json.
	JSONOut string
	// The command and arguments that can be used to fetch authentication configurations.
	AuthProviderCmd string
	// Folder to store JSON representation of output files.
	JSONOutputFolder string
	// Folder to store output files.
	OutputFolder string
	// The file which hclfmt should be specifically run on
	HclFile string
	// Location of the Terragrunt config file
	TerragruntConfigPath string
	// Name of the root Terragrunt configuration file, if used.
	ScaffoldRootFileName string
	// Path to an additional ignore file layered on top of a repo's
	// .terragrunt-catalog-ignore during catalog discovery.
	CatalogIgnoreFile string
	// Path to a file with a list of directories that need to be excluded when running *-all commands.
	ExcludesFile string
	// Path to folder of scaffold output
	ScaffoldOutputFolder string
	// Root directory for graph command.
	GraphRoot string
	// Path to the report file.
	ReportFile string
	// Path to a file containing filter queries, one per line. Default is .terragrunt-filters.
	FiltersFile string
	// Report format.
	ReportFormat report.Format
	// Path to the report schema file.
	ReportSchemaFile string
	// CLI args that are intended for Terraform (i.e. all the CLI args except the --terragrunt ones)
	TerraformCliArgs *iacargs.IacArgs
	// Files with variables to be used in modules scaffolding.
	ScaffoldVarFiles []string
	// If set hclfmt will skip files in given directories.
	HclExclude []string
	// Variables for usage in scaffolding.
	ScaffoldVars []string
	// StrictControls is a slice of strict controls.
	StrictControls strict.Controls `clone:"shadowcopy"`
	// Filters contains parsed filter objects for component selection.
	Filters filter.Filters `clone:"shadowcopy"`
	// When set, it will be used to compute the cache key for `-version` checks.
	VersionManagerFileName []string
	// Experiments is a map of experiments, and their status.
	Experiments experiment.Experiments `clone:"shadowcopy"`
	// Tips is a collection of tips that can be shown to users.
	Tips tips.Tips `clone:"shadowcopy"`
	// ProviderCacheOptions groups all provider-cache-specific configuration.
	ProviderCacheOptions pcoptions.ProviderCacheOptions
	// Parallelism limits the number of commands to run concurrently during *-all commands
	Parallelism int
	// When searching the directory tree, this is the max folders to check before exiting with an error.
	MaxFoldersToCheck int
	// CASCloneDepth is passed to git clone as --depth when CAS clones a remote
	// repository. Defaults to 1 (see internal/cas.DefaultCASCloneDepth). Values must be
	// positive (git rejects --depth 0) or negative (e.g. -1) for a full clone without --depth.
	CASCloneDepth int
	// Output Terragrunt logs in JSON format
	JSONLogFormat bool
	// True if terragrunt should run in debug mode
	Debug bool
	// Disable TF output formatting
	ForwardTFStdout bool
	// Fail execution if is required to create S3 bucket
	FailIfBucketCreationRequired bool
	// FilterAllowDestroy allows destroy runs when using Git-based filters
	FilterAllowDestroy bool
	// Controls if s3 bucket should be updated or skipped
	DisableBucketUpdate bool
	// Disables validation terraform command
	DisableCommandValidation bool
	// If True then HCL from StdIn must should be formatted.
	HclFromStdin bool
	// Show diff, by default it's disabled.
	Diff bool
	// Do not include root unit in scaffolding.
	ScaffoldNoIncludeRoot bool
	// Enable check mode, by default it's disabled.
	Check bool
	// Enables caching of includes during partial parsing operations.
	UsePartialParseConfigCache bool
	// True if is required to show dependent units and confirm action
	CheckDependentUnits bool
	// True if is required to check for dependent modules during destroy operations
	DestroyDependenciesCheck bool
	// Include fields metadata in render-json
	RenderJSONWithMetadata bool
	// Whether we should automatically retry errored Terraform commands
	AutoRetry bool
	// Whether we should automatically run terraform init if necessary when executing other commands
	AutoInit bool
	// When false (set via --no-discovery-auth-provider-cmd), skip running the
	// auth provider command during the discovery parse phase. Requires the
	// opt-out-auth experiment. Default true preserves existing behavior.
	DiscoveryAuthProviderCmd bool
	// Allows to skip the output of all dependencies.
	SkipOutput bool
	// Whether we should prompt the user for confirmation or always assume "yes"
	NonInteractive bool
	// If set to true, ignore the dependency order when running *-all command.
	IgnoreDependencyOrder bool
	// If set to true, continue running *-all commands even if a dependency has errors.
	IgnoreDependencyErrors bool
	// Whether we should automatically run terraform with -auto-apply in run --all mode.
	RunAllAutoApprove bool
	// If set to true, delete the contents of the temporary folder before downloading Terraform source code into it
	SourceUpdate bool
	// HCLValidateStrict is a strict mode for HCL validation files. When
	// it's set to false the command will only return an error if required
	// inputs are missing from all input sources (env vars, var files, etc).
	// When it's set to true, an error will be returned if required inputs
	// are missing or if unused variables are passed to Terragrunt.
	HCLValidateStrict bool
	// HCLValidateInputs checks if the terragrunt configured inputs align with the terraform defined variables.
	HCLValidateInputs bool
	// HCLValidateShowConfigPath shows the paths of the hcl invalid configs.
	HCLValidateShowConfigPath bool
	// HCLValidateJSONOutput outputs the hcl validate result as a JSON string.
	HCLValidateJSONOutput bool
	// If true, logs will be displayed in formatter key/value, by default logs are formatted in human-readable formatter.
	DisableLogFormatting bool
	// Headless is set when Terragrunt is running in headless mode.
	Headless bool
	// NoStackGenerate disable stack generation.
	NoStackGenerate bool
	// NoStackValidate disable generated stack validation.
	NoStackValidate bool
	// NoCAS disables the CAS feature even when the experiment is enabled.
	NoCAS bool
	// RunAll runs the provided OpenTofu/Terraform command against a stack.
	RunAll bool
	// Graph runs the provided OpenTofu/Terraform against the graph of
	// dependencies for the unit in the current working directory.
	Graph bool
	// BackendBootstrap automatically bootstraps backend infrastructure before attempting to use it.
	BackendBootstrap bool
	// DeleteBucket determines whether to delete entire bucket.
	DeleteBucket bool
	// ForceBackendDelete forces the backend to be deleted, even if the bucket is not versioned.
	ForceBackendDelete bool
	// ForceBackendMigrate forces the backend to be migrated, even if the bucket is not versioned.
	ForceBackendMigrate bool
	// SummaryDisable disables the summary output at the end of a run.
	SummaryDisable bool
	// SummaryPerUnit enables showing duration information for each unit in the summary.
	SummaryPerUnit bool
	// NoAutoProviderCacheDir disables the auto-provider-cache-dir feature even when the experiment is enabled.
	NoAutoProviderCacheDir bool
	// NoDependencyFetchOutputFromState disables the
	// dependency-fetch-output-from-state feature even when the experiment
	// is enabled.
	NoDependencyFetchOutputFromState bool
	// NoRunHooks disables before, after, and error hooks during run.
	NoRunHooks bool
	// TFPathExplicitlySet is set to true if the user has explicitly set the TFPath via the --tf-path flag.
	TFPathExplicitlySet bool
	// FailFast is a flag to stop execution on the first error in apply of units.
	FailFast bool
	// NoDependencyPrompt disables prompt requiring confirmation for base
	// and leaf file dependencies when using scaffolding.
	NoDependencyPrompt bool
	// NoShell disables shell commands when using boilerplate templates in catalog and scaffold commands.
	NoShell bool
	// NoHooks disables hooks when using boilerplate templates in catalog and scaffold commands.
	NoHooks bool
	// If set, disable automatic reading of .terragrunt-filters file.
	NoFiltersFile bool
	// LogShowAbsPaths disables replacing full paths in logs with short
	// relative paths.
	LogShowAbsPaths bool
	// LogDisableErrorSummary suppresses the trailing error summary.
	LogDisableErrorSummary bool
}

// TerragruntOptionsFunc is a functional option type used to pass options in certain integration tests
type TerragruntOptionsFunc func(*TerragruntOptions)

// WithIAMRoleARN adds the provided role ARN to IamRoleOptions
func WithIAMRoleARN(arn string) TerragruntOptionsFunc {
	return func(t *TerragruntOptions) {
		t.IAMRoleOptions.RoleARN = arn
	}
}

// WithIAMWebIdentityToken adds the provided WebIdentity token to IamRoleOptions
func WithIAMWebIdentityToken(token string) TerragruntOptionsFunc {
	return func(t *TerragruntOptions) {
		t.IAMRoleOptions.WebIdentityToken = token
	}
}

// NewTerragruntOptions creates a new TerragruntOptions object with
// reasonable defaults for real usage.
func NewTerragruntOptions() *TerragruntOptions {
	return &TerragruntOptions{
		TFPath:                   DefaultWrappedPath,
		ExcludesFile:             defaultExcludesFile,
		FiltersFile:              defaultFiltersFile,
		AutoInit:                 true,
		RunAllAutoApprove:        true,
		DiscoveryAuthProviderCmd: true,
		SourceMap:                map[string]string{},
		TerraformCliArgs:         iacargs.New(),
		MaxFoldersToCheck:        DefaultMaxFoldersToCheck,
		AutoRetry:                true,
		Parallelism:              DefaultParallelism,
		JSONOut:                  DefaultJSONOutName,
		TofuImplementation:       tfimpl.Unknown,
		ProviderCacheOptions:     pcoptions.ProviderCacheOptions{RegistryNames: pcoptions.DefaultRegistryNames},
		FeatureFlags:             xsync.NewMap[string, string](),
		Errors:                   defaultErrorsConfig(),
		StrictControls:           controls.New(),
		Experiments:              experiment.NewExperiments(),
		Tips:                     tips.NewTips(),
		Telemetry:                new(telemetry.Options),
		EngineOptions:            new(engine.EngineOptions),
		VersionManagerFileName:   defaultVersionManagerFileName,
		CASCloneDepth:            1,
	}
}

func NewTerragruntOptionsWithConfigPath(terragruntConfigPath string) (*TerragruntOptions, error) {
	opts := NewTerragruntOptions()

	// Ensure config path is absolute so downstream code can rely on it.
	// Skip resolution for empty paths (sentinel meaning "not set").
	if terragruntConfigPath != "" {
		if !filepath.IsAbs(terragruntConfigPath) {
			absPath, err := filepath.Abs(terragruntConfigPath)
			if err != nil {
				return nil, err
			}

			terragruntConfigPath = absPath
		}

		terragruntConfigPath = filepath.Clean(terragruntConfigPath)
	}

	opts.TerragruntConfigPath = terragruntConfigPath

	workingDir, downloadDir := util.DefaultWorkingAndDownloadDirs(terragruntConfigPath)

	opts.WorkingDir = workingDir
	opts.RootWorkingDir = workingDir
	opts.DownloadDir = downloadDir

	return opts, nil
}

// GetDefaultIAMAssumeRoleSessionName gets the default IAM assume role session name.
func GetDefaultIAMAssumeRoleSessionName() string {
	return fmt.Sprintf("terragrunt-%d", time.Now().UTC().UnixNano())
}

// NewTerragruntOptionsForTest creates a new TerragruntOptions object with reasonable defaults for test usage.
func NewTerragruntOptionsForTest(
	terragruntConfigPath string, options ...TerragruntOptionsFunc,
) (*TerragruntOptions, error) {
	formatter := format.NewFormatter(format.NewKeyValueFormatPlaceholders())
	formatter.SetDisabledColors(true)

	opts, err := NewTerragruntOptionsWithConfigPath(terragruntConfigPath)
	if err != nil {
		log.WithOptions(log.WithLevel(log.DebugLevel), log.WithFormatter(formatter)).Errorf("%v\n", err)

		return nil, err
	}

	opts.NonInteractive = true

	for _, opt := range options {
		opt(opts)
	}

	return opts, nil
}

// OptionsFromContext tries to retrieve options from context, otherwise, returns its own instance.
func (opts *TerragruntOptions) OptionsFromContext(ctx context.Context) *TerragruntOptions {
	if val := ctx.Value(ContextKey); val != nil {
		if opts, ok := val.(*TerragruntOptions); ok {
			return opts
		}
	}

	return opts
}

// Clone performs a deep copy of `opts` with shadow copies of: interfaces, and funcs.
// Fields with "clone" tags can override this behavior.
func (opts *TerragruntOptions) Clone() *TerragruntOptions {
	newOpts := cloner.Clone(opts)

	return newOpts
}

// CloneWithConfigPath creates a copy of this TerragruntOptions, but with
// different values for the given variables. This is useful for
// creating a TerragruntOptions that behaves the same way, but is used for a Terraform module in a different folder.
//
// It also adjusts the given logger, as each cloned option has to use a working directory specific logger to enrich
// log output correctly.
func (opts *TerragruntOptions) CloneWithConfigPath(
	l log.Logger, configPath string,
) (log.Logger, *TerragruntOptions, error) {
	newOpts := opts.Clone()

	// Ensure configPath is absolute and normalized for consistent path handling
	configPath = filepath.Clean(configPath)
	if !filepath.IsAbs(configPath) {
		configPath = filepath.Clean(filepath.Join(opts.WorkingDir, configPath))
	}

	workingDir := filepath.Dir(configPath)

	// Only update logger field if the working directory actually changed
	// This preserves any custom display path (e.g., relative path) set on the logger
	if workingDir != opts.WorkingDir {
		l = l.WithField(placeholders.WorkDirKeyName, workingDir)
	}

	newOpts.TerragruntConfigPath = configPath
	newOpts.WorkingDir = workingDir

	return l, newOpts, nil
}

// InsertTerraformCliArgs inserts the given argsToInsert after the terraform
// command argument, but before the remaining args.
// Uses IacArgs parsing to properly distinguish flags from arguments.
func (opts *TerragruntOptions) InsertTerraformCliArgs(argsToInsert ...string) {
	// Ensure TerraformCliArgs is initialized. This allows callers to use
	// Insert/AppendTerraformCliArgs without pre-initializing the struct,
	// which is common when building options incrementally or in tests.
	if opts.TerraformCliArgs == nil {
		opts.TerraformCliArgs = iacargs.New()
	}

	// Parse args using IacArgs to properly separate flags from arguments
	parsed := iacargs.New(argsToInsert...)

	// Insert flags at beginning
	opts.TerraformCliArgs.InsertFlag(0, parsed.Flags...)

	// Merge command and subcommands from parsed args
	opts.mergeCommandAndSubCommand(parsed)

	// Arguments: insert at the beginning
	opts.TerraformCliArgs.InsertArguments(0, parsed.Arguments...)
}

// mergeCommandAndSubCommand handles command and subcommand merging during arg insertion.
// Command rules:
//   - If opts has no command, use parsed.Command
//   - If parsed.Command matches opts command, do nothing
//   - If parsed.Command is a known subcommand, add to SubCommand
//   - Otherwise treat parsed.Command as positional argument
//
// SubCommand rules:
//   - If parsed has explicit subcommands, use them (last writer wins)
//   - Otherwise keep any subcommand set during command merging
func (opts *TerragruntOptions) mergeCommandAndSubCommand(parsed *iacargs.IacArgs) {
	// Handle command field
	switch {
	case opts.TerraformCliArgs.Command == "":
		opts.TerraformCliArgs.Command = parsed.Command
	case parsed.Command == "" || parsed.Command == opts.TerraformCliArgs.Command:
		// no-op
	case iacargs.IsKnownSubCommand(parsed.Command):
		opts.TerraformCliArgs.SubCommand = []string{parsed.Command}
	default:
		opts.TerraformCliArgs.InsertArguments(0, parsed.Command)
	}

	// Explicit subcommands in parsed take precedence
	if len(parsed.SubCommand) > 0 {
		opts.TerraformCliArgs.SubCommand = parsed.SubCommand
	}
}

// AppendTerraformCliArgs appends the given argsToAppend after the current TerraformCliArgs.
// Uses IacArgs parsing to properly distinguish flags from arguments.
func (opts *TerragruntOptions) AppendTerraformCliArgs(argsToAppend ...string) {
	// Ensure TerraformCliArgs is initialized. This allows callers to use
	// Insert/AppendTerraformCliArgs without pre-initializing the struct,
	// which is common when building options incrementally or in tests.
	if opts.TerraformCliArgs == nil {
		opts.TerraformCliArgs = iacargs.New()
	}

	// Parse args using IacArgs to properly separate flags from arguments
	parsed := iacargs.New(argsToAppend...)

	opts.TerraformCliArgs.AppendFlag(parsed.Flags...)

	// Handle parsed.Command as an argument (extra_arguments don't have a command)
	if parsed.Command != "" {
		opts.TerraformCliArgs.AppendArgument(parsed.Command)
	}

	opts.TerraformCliArgs.AppendArgument(parsed.Arguments...)

	// Replace subcommand if provided
	if len(parsed.SubCommand) > 0 {
		opts.TerraformCliArgs.SubCommand = parsed.SubCommand
	}
}

// TerraformDataDir returns the Terraform data directory (.terraform by
// default, overridden by the TF_DATA_DIR entry in env).
func (opts *TerragruntOptions) TerraformDataDir(env map[string]string) string {
	if tfDataDir, ok := env["TF_DATA_DIR"]; ok {
		return tfDataDir
	}

	return DefaultTFDataDir
}

// DataDir returns the Terraform data directory prepended with the working
// directory path, or just the Terraform data directory if it is an absolute
// path.
func (opts *TerragruntOptions) DataDir(env map[string]string) string {
	tfDataDir := opts.TerraformDataDir(env)
	if filepath.IsAbs(tfDataDir) {
		return tfDataDir
	}

	return filepath.Join(opts.WorkingDir, tfDataDir)
}

// identifyDefaultWrappedExecutable returns default path used for wrapped executable.
func identifyDefaultWrappedExecutable(ctx context.Context) string {
	if util.IsCommandExecutable(vexec.NewOSExec(), ctx, TofuDefaultPath, "-version") {
		return TofuDefaultPath
	}
	// fallback to Terraform if tofu is not available
	return TerraformDefaultPath
}

// RunWithErrorHandling runs the given operation and handles any errors according to the configuration.
func (opts *TerragruntOptions) RunWithErrorHandling(
	ctx context.Context,
	l log.Logger,
	r *report.Report,
	operation func() error,
) error {
	if opts.Errors == nil {
		return operation()
	}

	currentAttempt := 1

	// Convert working dir to a clean, absolute path for reporting.
	// Use directory of original config path (pre-cache location) to ensure
	// report runs match those created by the runner pool.
	reportWorkingDir := opts.WorkingDir
	if opts.OriginalTerragruntConfigPath != "" {
		reportWorkingDir = filepath.Dir(opts.OriginalTerragruntConfigPath)
	}

	reportDir := filepath.Clean(reportWorkingDir)

	for {
		err := operation()
		if err == nil {
			return nil
		}

		// Process the error through our error handling configuration
		action, recoveryErr := opts.Errors.AttemptErrorRecovery(l, err, currentAttempt)
		if recoveryErr != nil {
			var maxAttemptsReachedError *errorconfig.MaxAttemptsReachedError
			if errors.As(recoveryErr, &maxAttemptsReachedError) {
				return maxAttemptsReachedError
			}

			return fmt.Errorf("encountered error while attempting error recovery: %w", recoveryErr)
		}

		if action == nil {
			return err
		}

		if action.ShouldIgnore {
			l.Warnf("Ignoring error, reason: %s", action.IgnoreMessage)

			// Handle ignore signals if any are configured
			if len(action.IgnoreSignals) > 0 {
				if err := opts.handleIgnoreSignals(l, action.IgnoreSignals); err != nil {
					return err
				}
			}

			run, err := r.EnsureRun(l, reportDir)
			if err != nil {
				return err
			}

			if err := r.EndRun(
				l,
				run.Path,
				report.WithResult(report.ResultSucceeded),
				report.WithReason(report.ReasonErrorIgnored),
				report.WithCauseIgnoreBlock(action.IgnoreBlockName),
			); err != nil {
				return err
			}

			return nil
		}

		if action.ShouldRetry {
			// Respect --no-auto-retry flag
			if !opts.AutoRetry {
				return err
			}

			l.Warnf(
				"Encountered retryable error: %s\nAttempt %d of %d. Waiting %d second(s) before retrying...",
				action.RetryBlockName,
				currentAttempt,
				action.RetryAttempts,
				action.RetrySleepSecs,
			)

			// Record that a retry will be attempted without prematurely marking success.
			run, err := r.EnsureRun(l, reportDir)
			if err != nil {
				return err
			}

			if err := r.EndRun(
				l,
				run.Path,
				report.WithResult(report.ResultSucceeded),
				report.WithReason(report.ReasonRetrySucceeded),
				report.WithCauseRetryBlock(action.RetryBlockName),
			); err != nil {
				return err
			}

			// Sleep before retry
			select {
			case <-time.After(time.Duration(action.RetrySleepSecs) * time.Second):
				// try again
			case <-ctx.Done():
				return ctx.Err()
			}

			currentAttempt++

			continue
		}

		return err
	}
}

func (opts *TerragruntOptions) handleIgnoreSignals(l log.Logger, signals map[string]any) error {
	workingDir := opts.WorkingDir
	signalsFile := filepath.Join(workingDir, DefaultSignalsFile)

	signalsJSON, err := json.MarshalIndent(signals, "", "  ")
	if err != nil {
		return err
	}

	const ownerPerms = 0644

	l.Warnf("Writing error signals to %s", signalsFile)

	if err := os.WriteFile(signalsFile, signalsJSON, ownerPerms); err != nil {
		return fmt.Errorf("failed to write signals file %s: %w", signalsFile, err)
	}

	return nil
}
