// Package run provides the main entry point for running orchestrated runs.
//
// These runs are typically OpenTofu/Terraform invocations, but they might be other commands as well.
package run

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"maps"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strings"
	"sync"

	"errors"

	"github.com/gruntwork-io/terragrunt/internal/codegen"
	"github.com/gruntwork-io/terragrunt/internal/iacargs"
	"github.com/gruntwork-io/terragrunt/internal/iam"
	"github.com/gruntwork-io/terragrunt/internal/multierror"
	"github.com/gruntwork-io/terragrunt/internal/remotestate"
	"github.com/gruntwork-io/terragrunt/internal/report"
	"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/telemetry"
	"github.com/gruntwork-io/terragrunt/internal/tf"
	"github.com/gruntwork-io/terragrunt/internal/util"
	"github.com/gruntwork-io/terragrunt/internal/venv"
	"github.com/gruntwork-io/terragrunt/internal/vfs"

	"github.com/gruntwork-io/terragrunt/pkg/log"
)

const (
	CommandNameTerragruntReadConfig = "terragrunt-read-config"
	NullTFVarsFile                  = ".terragrunt-null-vars.auto.tfvars.json"
)

var TerraformCommandsThatUseState = []string{
	"init",
	"apply",
	"destroy",
	"env",
	"import",
	"graph",
	"output",
	"plan",
	"push",
	"refresh",
	"show",
	"taint",
	"untaint",
	"validate",
	"force-unlock",
	"state",
}

// TerraformCommandsThatDoNotNeedInit is a list of Terraform commands that do not require 'terraform init' to be executed.
var TerraformCommandsThatDoNotNeedInit = []string{
	"version",

	// The engine command is a special command that engines can implement to provide additional functionality.
	// It's not part of the OpenTofu/Terraform CLI API, so we know that we don't consistently need to run 'init'.
	// Engines can decide to selectively perform inits based on the logic of their engine commands.
	"engine",
}

var ModuleRegex = regexp.MustCompile(`module[[:blank:]]+".+"`)

// sourceChangeLocks is a map of per-directory mutexes that serializes concurrent access to cache directories.
// It prevents generated code from being overwritten while a hook (e.g. `tflint`) is running, and prevents
// concurrent source downloads from racing on the same cache directory (see DownloadTerraformSource).
var sourceChangeLocks = sync.Map{}

// Run downloads terraform source if necessary, then runs terraform with the given options and CLI args.
// This will forward all the args and extra_arguments directly to Terraform.
func Run(
	ctx context.Context,
	l log.Logger,
	v venv.Venv,
	opts *Options,
	r *report.Report,
	cfg *runcfg.RunConfig,
	credsGetter *creds.Getter,
) error {
	engine, err := cfg.EngineOptions()
	if err != nil {
		return err
	}

	opts.EngineConfig = engine

	errConfig, err := cfg.ErrorsConfig()
	if err != nil {
		return err
	}

	// Only overwrite when the config actually defines error rules;
	// otherwise preserve the built-in default retryable errors.
	if errConfig != nil {
		opts.Errors = errConfig
	}

	l, terragruntOptionsClone, err := opts.CloneWithConfigPath(l, opts.TerragruntConfigPath)
	if err != nil {
		return err
	}

	terragruntOptionsClone.TerraformCommand = CommandNameTerragruntReadConfig

	if err = terragruntOptionsClone.RunWithErrorHandling(ctx, l, r, func() error {
		return ProcessHooks(ctx, l, v, ProcessHooksParams{
			Hooks:    cfg.Terraform.AfterHooks,
			Opts:     terragruntOptionsClone,
			Cfg:      cfg,
			HookType: HookTypeAfter,
		})
	}); err != nil {
		return err
	}

	// We merge the OriginalIAMRoleOptions into the one from the config, because the CLI passed IAMRoleOptions has
	// precedence.
	opts.IAMRoleOptions = iam.MergeRoleOptions(
		cfg.GetIAMRoleOptions(),
		opts.OriginalIAMRoleOptions,
	)

	if err = opts.RunWithErrorHandling(ctx, l, r, func() error {
		return credsGetter.ObtainAndUpdateEnvIfNecessary(
			ctx, l, v,
			amazonsts.NewProvider(l, opts.IAMRoleOptions, v.Env),
		)
	}); err != nil {
		return err
	}

	// get the default download dir
	_, defaultDownloadDir := util.DefaultWorkingAndDownloadDirs(opts.TerragruntConfigPath)

	// if the download dir hasn't been changed from default, and is set in the config,
	// then use it
	if opts.DownloadDir == defaultDownloadDir && cfg.DownloadDir != "" {
		opts.DownloadDir = cfg.DownloadDir
	}

	updatedOpts := opts

	sourceURL, err := runcfg.GetTerraformSourceURL(opts.Source, opts.SourceMap, opts.OriginalTerragruntConfigPath, cfg)
	if err != nil {
		return err
	}

	// Always download/copy source to cache directory for consistency.
	// When no source is specified, sourceURL will be "." (current directory).
	err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "download_terraform_source", map[string]any{
		"sourceUrl": sourceURL,
	}, func(ctx context.Context) error {
		updatedOpts, err = DownloadTerraformSource(ctx, l, v, sourceURL, opts, cfg, r)
		return err
	})
	if err != nil {
		return err
	}

	// Handle code generation configs, both generate blocks and generate attribute of remote_state.
	// Note that relative paths are relative to the terragrunt working dir (where terraform is called).
	if err = GenerateConfig(l, v.FS, updatedOpts, cfg); err != nil {
		return err
	}

	// We do the debug file generation here, after all the terragrunt generated terraform files are created so that we
	// can ensure the tfvars json file only includes the vars that are defined in the module.
	if updatedOpts.Debug {
		if err := WriteTerragruntDebugFile(l, v.Env, updatedOpts, cfg); err != nil {
			return err
		}
	}

	if err := CheckFolderContainsTerraformCode(updatedOpts); err != nil {
		return err
	}

	if err := opts.RunWithErrorHandling(ctx, l, r, func() error {
		return runTerragruntWithConfig(ctx, l, v, opts, updatedOpts, cfg, r)
	}); err != nil {
		return err
	}

	return nil
}

// GenerateConfig handles code generation using config types (for backwards compatibility).
func GenerateConfig(l log.Logger, fs vfs.FS, opts *Options, cfg *runcfg.RunConfig) error {
	rawActualLock, _ := sourceChangeLocks.LoadOrStore(opts.DownloadDir, &sync.Mutex{})

	actualLock := rawActualLock.(*sync.Mutex)
	actualLock.Lock()
	defer actualLock.Unlock()

	for _, genCfg := range cfg.GenerateConfigs {
		if err := codegen.WriteToFile(l, opts.CacheDir, &genCfg); err != nil {
			return err
		}
	}

	if cfg.RemoteState.Config != nil && cfg.RemoteState.Generate != nil {
		if err := cfg.RemoteState.GenerateOpenTofuCode(l, opts.CacheDir); err != nil {
			return err
		}
	} else if cfg.RemoteState.Config != nil {
		// We use else if here because we don't need to check the backend configuration is defined when the remote state
		// block has a `generate` attribute configured.
		if err := checkTerraformCodeDefinesBackend(fs, opts, cfg.RemoteState.BackendName); err != nil {
			return err
		}
	}

	return nil
}

// Runs tofu/terraform with the given options and CLI args.
// This will forward all the args and extra_arguments directly to Terraform.
//
// This function takes in the "original" options which has the unmodified 'WorkingDir' from before downloading the code from the source URL,
// and the "updated" options that will contain the updated 'WorkingDir' into which the code has been downloaded
func runTerragruntWithConfig(
	ctx context.Context,
	l log.Logger,
	v venv.Venv,
	originalOpts *Options,
	opts *Options,
	cfg *runcfg.RunConfig,
	r *report.Report,
) error {
	v.RequireEnv()

	if cfg.Exclude.ShouldPreventRun(opts.TerraformCommand) {
		l.Infof("Early exit in terragrunt unit %s due to exclude block with no_run = true", opts.CacheDir)

		return nil
	}

	if len(cfg.Terraform.ExtraArgs) > 0 {
		args := FilterTerraformExtraArgs(l, opts, cfg)

		opts.InsertTerraformCliArgs(args...)

		maps.Copy(v.Env, filterTerraformEnvVarsFromExtraArgsRunCfg(opts, cfg))
	}

	if err := SetTerragruntInputsAsEnvVars(l, v.Env, cfg); err != nil {
		return err
	}

	if opts.TerraformCliArgs.First() == tf.CommandNameInit {
		if err := prepareInitCommandRunCfg(ctx, l, v, opts, cfg); err != nil {
			return err
		}
	} else {
		if err := PrepareNonInitCommand(ctx, l, v, originalOpts, opts, cfg, r); err != nil {
			return err
		}
	}

	// Write null-valued inputs to a tfvars.json file that OpenTofu/Terraform will auto-load.
	nullVarsFile, err := setTerragruntNullValuesRunCfg(opts, cfg)
	if err != nil {
		return err
	}

	defer func() {
		if nullVarsFile != "" {
			if removeErr := os.Remove(nullVarsFile); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
				l.Debugf("Failed to remove null values file %s: %v", nullVarsFile, removeErr)
			}
		}
	}()

	// Now that we've run 'init' and have all the source code locally, we can finally run the patch command
	if err := checkProtectedModuleRunCfg(opts, cfg); err != nil {
		return err
	}

	return RunActionWithHooks(ctx, l, v, "terraform", opts, cfg, r, func(childCtx context.Context) error {
		// Execute the underlying command once; retries and ignores are handled by outer RunWithErrorHandling
		out, runTerraformError := tf.RunCommandWithOutput(
			childCtx, l, v,
			opts.tfRunOptions(), opts.TerraformCliArgs.Slice()...,
		)

		var lockFileError error
		if ShouldCopyLockFile(opts.TerraformCliArgs, &cfg.Terraform) {
			// Copy the lock file from the Terragrunt working dir (e.g., .terragrunt-cache/xxx/<some-module>) to the
			// user's working dir (e.g., /live/stage/vpc). That way, the lock file will end up right next to the user's
			// terragrunt.hcl and can be checked into version control. Note that in the past, Terragrunt allowed the
			// user's working dir to be different than the directory where the terragrunt.hcl file lived, so just in
			// case, we are using the user's working dir here, rather than just looking at the parent dir of the
			// terragrunt.hcl. However, the default value for the user's working dir, set in options.go, IS just the
			// parent dir of terragrunt.hcl, so these will likely always be the same.
			// Use directory from OriginalTerragruntConfigPath to copy locks since WorkingDir point to cache directory
			lockFileError = runcfg.CopyLockFile(l, opts.RootWorkingDir, opts.LogShowAbsPaths, opts.CacheDir, filepath.Dir(opts.OriginalTerragruntConfigPath))
		}

		// If command failed, log a helpful message
		if runTerraformError != nil {
			if out == nil {
				l.Errorf("%s invocation failed in %s", opts.TofuImplementation, opts.CacheDir)
			}
		}

		return multierror.Join(runTerraformError, lockFileError)
	})
}

// ShouldCopyLockFile verifies if the lock file should be copied to the user's working directory
// Terraform 0.14 now manages a lock file for providers. This can be updated
// in three ways:
// * `terraform init` in a module where no `.terraform.lock.hcl` exists
// * `terraform init -upgrade`
// * `terraform providers lock`
//
// In any of these cases, terragrunt should attempt to copy the generated
// `.terraform.lock.hcl`
//
// terraform init is not guaranteed to pull all checksums depending on platforms,
// if you already have the provider requested in a cache, or if you are using a mirror.
// There are lots of details at [hashicorp/terraform#27264](https://github.com/hashicorp/terraform/issues/27264#issuecomment-743389837)
// The `providers lock` sub command enables you to ensure that the lock file is
// fully populated.
func ShouldCopyLockFile(args *iacargs.IacArgs, terraformConfig *runcfg.TerraformConfig) bool {
	// If the user has explicitly set NoCopyTerraformLockFile to true, then we should not copy the lock file on any command
	// This is useful for users who want to manage the lock file themselves outside the working directory
	if terraformConfig != nil && terraformConfig.NoCopyTerraformLockFile {
		return false
	}

	if args.First() == tf.CommandNameInit {
		return true
	}

	if args.First() == tf.CommandNameProviders && args.Second() == tf.CommandNameLock {
		return true
	}

	return false
}

// RunActionWithHooks runs the given action function surrounded by hooks. That is, run the before hooks first, then, if there were no
// errors, run the action, and finally, run the after hooks. Return any errors hit from the hooks or action.
func RunActionWithHooks(
	ctx context.Context,
	l log.Logger,
	v venv.Venv,
	description string,
	opts *Options,
	cfg *runcfg.RunConfig,
	r *report.Report,
	action func(ctx context.Context) error,
) error {
	var allErrors []error

	beforeHookErrors := ProcessHooks(ctx, l, v, ProcessHooksParams{
		Hooks:              cfg.Terraform.BeforeHooks,
		Opts:               opts,
		Cfg:                cfg,
		HookType:           HookTypeBefore,
		PreviousExecErrors: allErrors,
	})
	if beforeHookErrors != nil {
		allErrors = append(allErrors, beforeHookErrors)
	}

	if beforeHookErrors == nil {
		if actionErrors := action(ctx); actionErrors != nil {
			allErrors = append(allErrors, actionErrors)
		}
	} else {
		l.Errorf("Errors encountered running before_hooks. Not running '%s'.", description)
	}

	if postHookErrors := ProcessHooks(ctx, l, v, ProcessHooksParams{
		Hooks:              cfg.Terraform.AfterHooks,
		Opts:               opts,
		Cfg:                cfg,
		HookType:           HookTypeAfter,
		PreviousExecErrors: allErrors,
	}); postHookErrors != nil {
		allErrors = append(allErrors, postHookErrors)
	}

	if errorHookErrors := ProcessErrorHooks(ctx, l, v, cfg.Terraform.ErrorHooks, cfg, opts, allErrors); errorHookErrors != nil {
		allErrors = append(allErrors, errorHookErrors)
	}

	return multierror.Join(allErrors...)
}

// SetTerragruntInputsAsEnvVars merges the inputs from Terragrunt
// configurations into env as TF_VAR_* entries, preserving any keys
// already present.
//
// Requires a non-nil env: it is the destination the entries are written into.
func SetTerragruntInputsAsEnvVars(l log.Logger, env map[string]string, cfg *runcfg.RunConfig) error {
	if env == nil {
		panic(venv.ErrVenvEnvUnset)
	}

	asEnvVars, err := ToTerraformEnvVars(l, cfg.Inputs)
	if err != nil {
		return err
	}

	for key, value := range asEnvVars {
		// Don't override any env vars the user has already set
		if _, envVarAlreadySet := env[key]; !envVarAlreadySet {
			env[key] = value
		}
	}

	return nil
}

// CheckFolderContainsTerraformCode checks if the folder contains Terraform/OpenTofu code
func CheckFolderContainsTerraformCode(opts *Options) error {
	found, err := util.DirContainsTFFiles(opts.CacheDir)
	if err != nil {
		return err
	}

	if !found {
		return NoTerraformFilesFound(opts.CacheDir)
	}

	return nil
}

// Check that the specified Terraform code defines a backend { ... } block and return an error if doesn't.
func checkTerraformCodeDefinesBackend(fs vfs.FS, opts *Options, backendType string) error {
	terraformBackendRegexp, err := regexp.Compile(fmt.Sprintf(`backend[[:blank:]]+"%s"`, backendType))
	if err != nil {
		return err
	}

	// Check for backend definitions in .tf and .tofu files using WalkDir
	definesBackend, err := util.RegexFoundInTFFiles(opts.CacheDir, terraformBackendRegexp)
	if err != nil {
		return err
	}

	if definesBackend {
		return nil
	}

	terraformJSONBackendRegexp, err := regexp.Compile(fmt.Sprintf(`(?m)"backend":[[:space:]]*{[[:space:]]*"%s"`, backendType))
	if err != nil {
		return err
	}

	definesJSONBackend, err := util.GrepFilesWithSuffix(fs, terraformJSONBackendRegexp, opts.CacheDir, ".tf.json")
	if err != nil {
		return err
	}

	if definesJSONBackend {
		return nil
	}

	return BackendNotDefined{ConfigPath: opts.TerragruntConfigPath, WorkingDir: opts.CacheDir, BackendType: backendType}
}

// Returns true if we need to run `terraform init` to download providers
func providersNeedInit(opts *Options, env map[string]string) bool {
	pluginsPath := filepath.Join(opts.DataDir(env), "plugins")
	providersPath := filepath.Join(opts.DataDir(env), "providers")
	terraformLockPath := filepath.Join(opts.CacheDir, tf.TerraformLockFile)

	return (!util.FileExists(pluginsPath) && !util.FileExists(providersPath)) || !util.FileExists(terraformLockPath)
}

// prepareInitOptions clones opts for a `terraform init` invocation. The
// caller is responsible for discarding stdout via the venv when running
// init with a non plan/apply parent command.
func prepareInitOptions(l log.Logger, opts *Options) (log.Logger, *Options, bool, error) {
	// Need to clone the options, so the TerraformCliArgs can be configured to run the init command
	l, initOptions, err := opts.CloneWithConfigPath(l, opts.TerragruntConfigPath)
	if err != nil {
		return l, nil, false, err
	}

	initOptions.TerraformCliArgs = iacargs.New().SetCommand(tf.CommandNameInit)
	initOptions.CacheDir = opts.CacheDir
	initOptions.TerraformCommand = tf.CommandNameInit
	initOptions.Headless = true

	initOutputForCommands := []string{tf.CommandNamePlan, tf.CommandNameApply}
	terraformCommand := opts.TerraformCliArgs.First()

	// Some commands return JSON, so init stdout must be suppressed.
	suppressInitStdout := !slices.Contains(initOutputForCommands, terraformCommand)

	if l.Formatter().DisabledColors() || opts.TerraformCliArgs.Contains(tf.FlagNameNoColor) {
		initOptions.TerraformCliArgs.AppendFlag(tf.FlagNameNoColor)
	}

	return l, initOptions, suppressInitStdout, nil
}

// Return true if modules aren't already downloaded and the Terraform templates in this project reference modules.
// Note that to keep the logic in this code very simple, this code ONLY detects the case where you haven't downloaded
// modules at all. Detecting if your downloaded modules are out of date (as opposed to missing entirely) is more
// complicated and not something we handle at the moment.
func modulesNeedInit(opts *Options, env map[string]string) (bool, error) {
	modulesPath := filepath.Join(opts.DataDir(env), "modules")
	if util.FileExists(modulesPath) {
		return false, nil
	}

	// Check for module definitions in .tf and .tofu files using WalkDir
	hasModuleDefinition, err := util.RegexFoundInTFFiles(opts.CacheDir, ModuleRegex)
	if err != nil {
		return false, err
	}

	return hasModuleDefinition, nil
}

// remoteStateNeedsInit determines whether remote state initialization is required before running a Terraform command.
// It returns true if:
//   - BackendBootstrap is enabled in options
//   - Remote state configuration is provided
//   - The Terraform command uses state (e.g., plan, apply, destroy, output, etc.)
//   - The remote state backend needs bootstrapping
func remoteStateNeedsInit(
	ctx context.Context,
	l log.Logger,
	v venv.Venv,
	remoteState *remotestate.RemoteState,
	opts *Options,
) (bool, error) {
	// If backend bootstrap is disabled, we don't need to initialize remote state
	if !opts.BackendBootstrap {
		return false, nil
	}
	// We only configure remote state for the commands that use the tfstate files. We do not configure it for
	// commands such as "get" or "version".
	if remoteState == nil || remoteState.Config == nil || !slices.Contains(
		TerraformCommandsThatUseState,
		opts.TerraformCliArgs.First(),
	) {
		return false, nil
	}

	if ok, err := remoteState.NeedsBootstrap(ctx, l, v, opts.remoteStateOpts()); err != nil || !ok {
		return false, err
	}

	return true, nil
}

// FilterTerraformExtraArgs extracts terraform extra arguments using runcfg types.
func FilterTerraformExtraArgs(l log.Logger, opts *Options, cfg *runcfg.RunConfig) []string {
	out := []string{}
	cmd := opts.TerraformCliArgs.First()

	for i := range cfg.Terraform.ExtraArgs {
		arg := &cfg.Terraform.ExtraArgs[i]
		for _, argCmd := range arg.Commands {
			if cmd == argCmd {
				lastArg := opts.TerraformCliArgs.Last()
				skipVars := (cmd == tf.CommandNameApply || cmd == tf.CommandNameDestroy) && util.IsFile(lastArg)

				if len(arg.Arguments) > 0 {
					if skipVars {
						for _, a := range arg.Arguments {
							if !strings.HasPrefix(a, "-var") {
								out = append(out, a)
							}
						}
					} else {
						out = append(out, arg.Arguments...)
					}
				}

				if !skipVars {
					for _, file := range arg.VarFiles {
						out = append(out, "-var-file="+file)
					}
				}
			}
		}
	}

	return out
}

// ToTerraformEnvVars converts the given variables to a map of environment variables that will expose those variables to Terraform. The
// keys will be of the format TF_VAR_xxx and the values will be converted to JSON, which Terraform knows how to read
// natively.
func ToTerraformEnvVars(l log.Logger, vars map[string]any) (map[string]string, error) {
	out := map[string]string{}

	for varName, varValue := range vars {
		if varValue == nil {
			continue
		}

		envVarName := fmt.Sprintf(tf.EnvNameTFVarFmt, varName)

		envVarValue, err := util.AsTerraformEnvVarJSONValue(varValue)
		if err != nil {
			return nil, err
		}

		out[envVarName] = envVarValue
	}

	return out, nil
}

// filterTerraformEnvVarsFromExtraArgsRunCfg extracts terraform env vars from extra args using runcfg types.
func filterTerraformEnvVarsFromExtraArgsRunCfg(opts *Options, cfg *runcfg.RunConfig) map[string]string {
	out := map[string]string{}
	cmd := opts.TerraformCliArgs.First()

	for i := range cfg.Terraform.ExtraArgs {
		arg := &cfg.Terraform.ExtraArgs[i]
		if len(arg.EnvVars) == 0 {
			continue
		}

		for _, argcmd := range arg.Commands {
			if cmd == argcmd {
				maps.Copy(out, arg.EnvVars)
			}
		}
	}

	return out
}

// prepareInitCommandRunCfg prepares for terraform init using runcfg types.
func prepareInitCommandRunCfg(ctx context.Context, l log.Logger, v venv.Venv, opts *Options, cfg *runcfg.RunConfig) error {
	if cfg.RemoteState.Config == nil {
		return nil
	}

	opts.InsertTerraformCliArgs(cfg.RemoteState.GetTFInitArgs()...)

	// Bootstrap is skipped when either BackendBootstrap is false (the default) or DisableInit is true.
	// DisableInit is also enforced in RemoteState.NeedsBootstrap (non-init auto-init path);
	// both must stay in sync to ensure consistent behavior across all command types.
	if !opts.BackendBootstrap || cfg.RemoteState.DisableInit {
		return nil
	}

	if err := cfg.RemoteState.Bootstrap(ctx, l, v, opts.remoteStateOpts()); err != nil {
		return err
	}

	return nil
}

// PrepareNonInitCommand prepares for non-init commands using runcfg types.
func PrepareNonInitCommand(
	ctx context.Context,
	l log.Logger,
	v venv.Venv,
	originalOpts *Options,
	opts *Options,
	cfg *runcfg.RunConfig,
	r *report.Report,
) error {
	needsInit, err := needsInitRunCfg(ctx, l, v, opts, cfg)
	if err != nil {
		return err
	}

	if needsInit {
		if err := runTerraformInitRunCfg(ctx, l, v, originalOpts, opts, cfg, r); err != nil {
			return err
		}
	}

	return nil
}

// needsInitRunCfg determines if terraform init is needed using runcfg types.
func needsInitRunCfg(ctx context.Context, l log.Logger, v venv.Venv, opts *Options, cfg *runcfg.RunConfig) (bool, error) {
	if slices.Contains(TerraformCommandsThatDoNotNeedInit, opts.TerraformCliArgs.First()) {
		return false, nil
	}

	// Marker check lives here, not in modulesNeedInit, so a populated .terraform/modules/
	// can't short-circuit past it. Refs: #1921, #6058.
	if util.FileExists(filepath.Join(opts.CacheDir, ModuleInitRequiredFile)) {
		return true, nil
	}

	if providersNeedInit(opts, v.Env) {
		return true, nil
	}

	modulesNeedsInit, err := modulesNeedInit(opts, v.Env)
	if err != nil {
		return false, err
	}

	if modulesNeedsInit {
		return true, nil
	}

	if cfg.RemoteState.Config == nil {
		return false, nil
	}

	return remoteStateNeedsInit(ctx, l, v, &cfg.RemoteState, opts)
}

// runTerraformInitRunCfg runs terraform init using runcfg types.
func runTerraformInitRunCfg(
	ctx context.Context,
	l log.Logger,
	v venv.Venv,
	originalOpts *Options,
	opts *Options,
	cfg *runcfg.RunConfig,
	r *report.Report,
) error {
	if opts.TerraformCliArgs.First() != tf.CommandNameInit && !opts.AutoInit {
		l.Warnf("Detected that init is needed, but Auto-Init is disabled. Continuing with further actions, but subsequent terraform commands may fail.")
		return nil
	}

	l, initOptions, suppressInitStdout, err := prepareInitOptions(l, opts)
	if err != nil {
		return err
	}

	initV := v
	if suppressInitStdout {
		initV = initV.WithWriter(io.Discard)
	}

	if err := runTerragruntWithConfig(ctx, l, initV, originalOpts, initOptions, cfg, r); err != nil {
		return err
	}

	moduleNeedInit := filepath.Join(opts.CacheDir, ModuleInitRequiredFile)
	if util.FileExists(moduleNeedInit) {
		return os.Remove(moduleNeedInit)
	}

	return nil
}

// checkProtectedModuleRunCfg checks if module is protected using runcfg types.
func checkProtectedModuleRunCfg(opts *Options, cfg *runcfg.RunConfig) error {
	var destroyFlag = false
	if opts.TerraformCliArgs.First() == tf.CommandNameDestroy {
		destroyFlag = true
	}

	if opts.TerraformCliArgs.Contains("-" + tf.CommandNameDestroy) {
		destroyFlag = true
	}

	if !destroyFlag {
		return nil
	}

	if cfg.PreventDestroy {
		return ModuleIsProtected{ConfigPath: opts.TerragruntConfigPath}
	}

	return nil
}

// setTerragruntNullValuesRunCfg writes null-valued inputs to a tfvars.json file
// that OpenTofu/Terraform will auto-load. This is necessary because OpenTofu/Terraform
// cannot accept null values via environment variables (TF_VAR_*), but it can read them
// from .auto.tfvars.json files.
func setTerragruntNullValuesRunCfg(opts *Options, cfg *runcfg.RunConfig) (string, error) {
	jsonEmptyVars := make(map[string]any)

	for varName, varValue := range cfg.Inputs {
		if varValue == nil {
			jsonEmptyVars[varName] = nil
		}
	}

	if len(jsonEmptyVars) == 0 {
		return "", nil
	}

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

	varFile := filepath.Join(opts.CacheDir, NullTFVarsFile)

	const ownerReadWritePermissions = 0600

	if err := os.WriteFile(varFile, jsonContents, os.FileMode(ownerReadWritePermissions)); err != nil {
		return "", err
	}

	return varFile, nil
}
