// Package runall implements the logic for running commands across all units in a stack.
package runall

import (
	"context"
	"fmt"
	"path/filepath"

	"github.com/gruntwork-io/terragrunt/internal/configbridge"
	"github.com/gruntwork-io/terragrunt/internal/runner"
	"github.com/gruntwork-io/terragrunt/internal/runner/common"
	"github.com/gruntwork-io/terragrunt/internal/runner/run"
	"github.com/gruntwork-io/terragrunt/internal/stacks/clean"
	"github.com/gruntwork-io/terragrunt/internal/stacks/generate"
	"github.com/gruntwork-io/terragrunt/internal/tips"
	"github.com/gruntwork-io/terragrunt/internal/worktrees"
	"github.com/gruntwork-io/terragrunt/pkg/config"

	"errors"

	"github.com/gruntwork-io/terragrunt/internal/iacargs"
	"github.com/gruntwork-io/terragrunt/internal/os/stdout"
	"github.com/gruntwork-io/terragrunt/internal/report"
	"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/pkg/log"
	"github.com/gruntwork-io/terragrunt/pkg/options"
)

// Known terraform commands that are explicitly not supported in run --all due to the nature of the command. This is
// tracked as a map that maps the terraform command to the reasoning behind disallowing the command in run --all.
var runAllDisabledCommands = map[string]string{
	tf.CommandNameImport: "terraform import should only be run against a single" +
		" state representation to avoid injecting the wrong object" +
		" in the wrong state representation.",
	tf.CommandNameTaint: "terraform taint should only be run against a single" +
		" state representation to avoid using the wrong state address.",
	tf.CommandNameUntaint: "terraform untaint should only be run against a single" +
		" state representation to avoid using the wrong state address.",
	tf.CommandNameConsole: "terraform console requires stdin, which is shared" +
		" across all instances of run --all when multiple modules" +
		" run concurrently.",
	tf.CommandNameForceUnlock: "lock IDs are unique per state representation" +
		" and thus should not be run with run --all.",
}

// Run executes the configured terraform command across every unit in the
// stack. v is the virtualized environment threaded through the runner pool
// into each unit's run pipeline.
func Run(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) (err error) {
	// --filter sets RunAll, so the CLI layer dispatches here without going
	// through the single-unit run path. Emit the tip here as well; the
	// underlying sync.Once dedupes if both paths fire.
	tips.GiveStackTargetTip(l, v.FS, opts.WorkingDir, opts.Filters, opts.Tips)

	if opts.TerraformCommand == "" {
		return MissingCommand{}
	}

	reason, isDisabled := runAllDisabledCommands[opts.TerraformCommand]
	if isDisabled {
		return RunAllDisabledErr{
			command: opts.TerraformCommand,
			reason:  reason,
		}
	}

	runnerOpts := []common.Option{}

	r := report.NewReport().WithWorkingDir(opts.WorkingDir)

	if l.Formatter().DisabledColors() || stdout.IsRedirected() {
		r.WithDisableColor()
	}

	if opts.ReportFormat != "" {
		r.WithFormat(opts.ReportFormat)
	}

	if opts.SummaryPerUnit {
		r.WithShowUnitLevelSummary()
	}

	if opts.ReportSchemaFile != "" {
		defer func() {
			if err := r.WriteSchemaToFile(v.FS, opts.ReportSchemaFile); err != nil {
				l.Warnf("Failed to write report schema to %s: %v", opts.ReportSchemaFile, err)
			}
		}()
	}

	if opts.ReportFile != "" {
		defer func() {
			if err := r.WriteToFile(v.FS, opts.ReportFile); err != nil {
				l.Warnf("Failed to write report to %s: %v", opts.ReportFile, err)
			}
		}()
	}

	// Skip summary for programmatic interactions:
	// - When JSON output is requested (--json or report format is JSON)
	// - When running 'output' command (typically for programmatic consumption)
	// - When the user cancelled the run-all confirmation prompt, since
	//   no units ran.
	if !opts.SummaryDisable && !shouldSkipSummary(opts) {
		defer func() {
			if errors.Is(err, ErrUserCancelled) {
				return
			}

			if writeErr := r.WriteSummary(opts.Writers.Writer); writeErr != nil {
				l.Warnf("Failed to write summary: %v", writeErr)
			}
		}()
	}

	gitFilters := opts.Filters.UniqueGitFilters()

	// Only create worktrees when git filter expressions are present.
	// `err` is the named return; the summary defer reads it to detect
	// ErrUserCancelled.
	var wts *worktrees.Worktrees
	if len(gitFilters) > 0 {
		wts, err = worktrees.NewWorktrees(ctx, l, worktrees.WorktreeOpts{
			WorkingDir:     opts.WorkingDir,
			GitExpressions: gitFilters,
			Experiments:    opts.Experiments,
		})
		if err != nil {
			return fmt.Errorf("failed to create worktrees: %w", err)
		}

		defer func() {
			cleanupErr := wts.Cleanup(ctx, l)
			if cleanupErr != nil {
				l.Errorf("failed to cleanup worktrees: %v", cleanupErr)
			}
		}()
	}

	if !opts.NoStackGenerate {
		// Set the stack config path to the default location in the working directory
		opts.TerragruntStackConfigPath = filepath.Join(opts.WorkingDir, config.DefaultStackFile)

		// Clean stack folders before calling `generate` when the `--source-update` flag is passed
		if opts.SourceUpdate {
			errClean := telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_clean", map[string]any{
				"stack_config_path": opts.TerragruntStackConfigPath,
				"working_dir":       opts.WorkingDir,
			}, func(ctx context.Context) error {
				l.Debugf("Running stack clean for %s, as part of generate command", opts.WorkingDir)
				return clean.CleanStacks(l, opts)
			})
			if errClean != nil {
				return fmt.Errorf("failed to clean stack directories under %q: %w", opts.WorkingDir, errClean)
			}
		}

		// Generate the stack configuration with telemetry tracking
		gen := generate.NewGenerator()
		err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_generate", map[string]any{
			"stack_config_path": opts.TerragruntStackConfigPath,
			"working_dir":       opts.WorkingDir,
		}, func(ctx context.Context) error {
			return gen.GenerateStacks(ctx, l, opts, wts)
		})

		// Handle any errors during stack generation
		if err != nil {
			return fmt.Errorf("failed to generate stack file: %w", err)
		}

		// After generation, hint when a literal stack filter left nested stacks ungenerated.
		funcsFor := configbridge.StackFuncFactory(ctx, l, opts)
		tips.GiveStackNestedGenerateTip(l, v.FS, funcsFor, opts.WorkingDir, opts.Filters, opts.Tips)
	} else {
		l.Debugf("Skipping stack generation in %s", opts.WorkingDir)
	}

	// Pass worktrees to runner for git filter expressions
	if wts != nil && len(wts.WorktreePairs) > 0 {
		runnerOpts = append(runnerOpts, common.WithWorktrees(wts))
	}

	rnr, err := runner.NewStackRunner(ctx, l, v, opts, runnerOpts...)
	if err != nil {
		return err
	}

	return RunAllOnStack(ctx, l, v, opts, rnr, r)
}

// RunAllOnStack drives the supplied [common.StackRunner] to completion.
func RunAllOnStack(
	ctx context.Context,
	l log.Logger,
	v run.Venv,
	opts *options.TerragruntOptions,
	rnr common.StackRunner,
	r *report.Report,
) error {
	l.Debugf("%s", rnr.GetStack().String())

	isDestroy := opts.TerraformCliArgs.IsDestroyCommand(opts.TerraformCommand)
	if err := rnr.LogUnitDeployOrder(l, isDestroy, opts.LogShowAbsPaths, opts.Experiments); err != nil {
		return err
	}

	var prompt string

	switch opts.TerraformCommand {
	case tf.CommandNameApply:
		prompt = "Are you sure you want to run 'terragrunt apply' in each unit of the run queue displayed above?"
	case tf.CommandNameDestroy:
		prompt = "WARNING: Are you sure you want to run `terragrunt destroy`" +
			" in each unit of the run queue displayed above? There is no undo!"
	case tf.CommandNameState:
		prompt = "Are you sure you want to manipulate the state with `terragrunt state`" +
			" in each unit of the run queue displayed above? Note that absolute paths are shared," +
			" while relative paths will be relative to each working directory."
	}

	if prompt != "" {
		shouldRunAll, err := shell.PromptUserForYesNo(ctx, l, prompt, opts.NonInteractive, opts.Writers.ErrWriter)
		if err != nil {
			return err
		}

		if !shouldRunAll {
			// Return a sentinel so the caller can map the cancellation
			// to a clean exit. os.Exit here would skip every defer in
			// the program, including worktree cleanup and telemetry
			// shutdown.
			return ErrUserCancelled
		}
	}

	var runErr error

	telemetryErr := telemetry.TelemeterFromContext(ctx).Collect(ctx, "run_all_on_stack", map[string]any{
		"terraform_command": opts.TerraformCommand,
		"working_dir":       opts.WorkingDir,
	}, func(ctx context.Context) error {
		err := rnr.Run(ctx, l, v, opts, r)
		if err != nil {
			// At this stage, we can't handle the error any further, so we just log it and return nil.
			// After this point, we'll need to report on what happened, and we want that to happen
			// after the error summary.
			l.Errorf("Run failed: %v", err)

			// Save error to potentially return after telemetry completes
			runErr = err

			// Return nil to allow telemetry and reporting to complete
			return nil
		}

		return nil
	})

	// log telemetry error and continue execution
	if telemetryErr != nil {
		l.Warnf("Telemetry collection failed: %v", telemetryErr)
	}

	return runErr
}

// shouldSkipSummary determines if summary output should be skipped for programmatic interactions.
// Summary is skipped when:
// - The command is 'output' (typically used for programmatic consumption)
// - JSON output is requested via terraform CLI args (-json flag)
// - JSON report format is specified (--report-format=json)
func shouldSkipSummary(opts *options.TerragruntOptions) bool {
	// Skip summary for 'output' command as it's typically used programmatically
	if opts.TerraformCommand == tf.CommandNameOutput {
		return true
	}

	// Skip summary when JSON output is requested via -json flag
	if opts.TerraformCliArgs.Normalize(iacargs.SingleDashFlag).Contains(tf.FlagNameJSON) {
		return true
	}

	return false
}
