package app

import (
	"bytes"
	goContext "context"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"text/tabwriter"

	"github.com/helmfile/vals"
	"go.uber.org/zap"

	"github.com/helmfile/helmfile/pkg/argparser"
	"github.com/helmfile/helmfile/pkg/cluster"
	"github.com/helmfile/helmfile/pkg/envvar"
	"github.com/helmfile/helmfile/pkg/filesystem"
	"github.com/helmfile/helmfile/pkg/helmexec"
	"github.com/helmfile/helmfile/pkg/plugins"
	"github.com/helmfile/helmfile/pkg/remote"
	"github.com/helmfile/helmfile/pkg/state"
)

var CleanWaitGroup sync.WaitGroup
var Cancel goContext.CancelFunc

// App is the main application object.
type App struct {
	OverrideKubeContext             string
	OverrideHelmBinary              string
	OverrideKustomizeBinary         string
	EnableLiveOutput                bool
	StripArgsValuesOnExitError      bool
	DisableForceUpdate              bool
	EnforcePluginVerification       bool
	HelmOCIPlainHTTP                bool
	RepoRetry                       int
	DisableKubeVersionAutoDetection bool
	SequentialHelmfiles             bool

	Logger      *zap.SugaredLogger
	Kubeconfig  string
	Env         string
	Namespace   string
	Chart       string
	Selectors   []string
	Args        string
	ValuesFiles []string
	Set         map[string]any

	FileOrDir string

	fs *filesystem.FileSystem

	remote *remote.Remote

	valsRuntime vals.Evaluator

	helms      map[helmKey]helmexec.Interface
	helmsMutex sync.Mutex

	ctx goContext.Context
}

type HelmRelease struct {
	Name      string `json:"name"`
	Namespace string `json:"namespace"`
	Enabled   bool   `json:"enabled"`
	Installed bool   `json:"installed"`
	Labels    string `json:"labels"`
	Chart     string `json:"chart"`
	Version   string `json:"version"`
}

func New(conf ConfigProvider) *App {
	ctx := goContext.Background()
	ctx, Cancel = goContext.WithCancel(ctx)

	return Init(&App{
		OverrideKubeContext:        conf.KubeContext(),
		OverrideHelmBinary:         conf.HelmBinary(),
		OverrideKustomizeBinary:    conf.KustomizeBinary(),
		EnableLiveOutput:           conf.EnableLiveOutput(),
		StripArgsValuesOnExitError: conf.StripArgsValuesOnExitError(),
		DisableForceUpdate:         conf.DisableForceUpdate(),
		EnforcePluginVerification:  conf.EnforcePluginVerification(),
		HelmOCIPlainHTTP:           conf.HelmOCIPlainHTTP(),
		RepoRetry:                  conf.RepoRetry(),
		SequentialHelmfiles:        conf.SequentialHelmfiles(),
		Logger:                     conf.Logger(),
		Kubeconfig:                 conf.Kubeconfig(),
		Env:                        conf.Env(),
		Namespace:                  conf.Namespace(),
		Chart:                      conf.Chart(),
		Selectors:                  conf.Selectors(),
		Args:                       conf.Args(),
		FileOrDir:                  conf.FileOrDir(),
		ValuesFiles:                conf.StateValuesFiles(),
		Set:                        conf.StateValuesSet(),
		fs:                         filesystem.DefaultFileSystem(),
		ctx:                        ctx,
	})
}

// WithFileSystem swaps the FileSystem this App reads through while loading state.
func (a *App) WithFileSystem(fs *filesystem.FileSystem) *App {
	a.fs = fs
	return a
}

func Init(app *App) *App {
	var err error
	app.valsRuntime, err = plugins.ValsInstance()
	if err != nil {
		panic(fmt.Sprintf("Failed to initialize vals runtime: %v", err))
	}

	if app.EnableLiveOutput {
		app.Logger.Info("Live output is enabled")
	}

	return app
}

func (a *App) Init(c InitConfigProvider) error {
	runner := &helmexec.ShellRunner{
		Logger:                     a.Logger,
		Ctx:                        a.ctx,
		StripArgsValuesOnExitError: a.StripArgsValuesOnExitError,
	}
	helmfileInit := NewHelmfileInit(a.OverrideHelmBinary, c, a.Logger, runner)
	return helmfileInit.Initialize()
}

func (a *App) Deps(c DepsConfigProvider) error {
	return a.ForEachState(func(run *Run) (_ bool, errs []error) {
		errs = run.Deps(c)
		return
	}, c.IncludeTransitiveNeeds(), SetFilter(true))
}

func (a *App) Repos(c ReposConfigProvider) error {
	return a.ForEachState(func(run *Run) (_ bool, errs []error) {
		reposErr := run.Repos(c)

		if reposErr != nil {
			errs = append(errs, reposErr)
		}

		return
	}, c.IncludeTransitiveNeeds(), SetFilter(true))
}

func (a *App) Diff(c DiffConfigProvider) error {
	var allDiffDetectedErrs []error

	var affectedAny bool

	err := a.ForEachState(func(run *Run) (bool, []error) {
		var criticalErrs []error

		var msg *string

		var matched, affected bool

		var errs []error

		includeCRDs := !c.SkipCRDs()

		prepErr := run.WithPreparedCharts("diff", state.ChartPrepareOptions{
			SkipRepos:                  c.SkipRefresh() || c.SkipDeps(),
			SkipRefresh:                c.SkipRefresh(),
			SkipDeps:                   c.SkipDeps(),
			SkipSchemaValidation:       c.SkipSchemaValidation(),
			IncludeCRDs:                &includeCRDs,
			Validate:                   c.Validate(),
			Concurrency:                c.Concurrency(),
			IncludeTransitiveNeeds:     c.IncludeNeeds(),
			PrefetchSharedRemoteCharts: true,
		}, func() []error {
			msg, matched, affected, errs = a.diff(run, c)
			return errs
		})

		if msg != nil {
			a.Logger.Info(*msg)
		}

		if prepErr != nil {
			errs = append(errs, prepErr)
		}

		affectedAny = affectedAny || affected

		for i := range errs {
			switch e := errs[i].(type) {
			case *state.ReleaseError:
				switch e.Code {
				case 2:
					// See https://github.com/roboll/helmfile/issues/874
					allDiffDetectedErrs = append(allDiffDetectedErrs, e)
				default:
					criticalErrs = append(criticalErrs, e)
				}
			default:
				criticalErrs = append(criticalErrs, e)
			}
		}

		return matched, criticalErrs
	}, c.IncludeNeeds())

	if err != nil {
		return err
	}

	if c.DetailedExitcode() && (len(allDiffDetectedErrs) > 0 || affectedAny) {
		// We take the first release error w/ exit status 2 (although all the deferred errs should have exit status 2)
		// to just let helmfile itself to exit with 2
		// See https://github.com/roboll/helmfile/issues/749
		code := 2
		e := &Error{
			msg:  "Identified at least one change",
			code: &code,
		}
		return e
	}

	return nil
}

func (a *App) Template(c TemplateConfigProvider) error {
	return a.ForEachState(func(run *Run) (ok bool, errs []error) {
		includeCRDs := c.IncludeCRDs()

		// Live output should never be enabled for the "template" subcommand to avoid breaking `helmfile template | kubectl apply -f -`
		run.helm.SetEnableLiveOutput(false)

		// Reset helm extra args to not pollute BuildDeps() and AddRepo() on subsequent helmfiles
		// https://github.com/helmfile/helmfile/issues/1749
		run.helm.SetExtraArgs()

		prepErr := run.WithPreparedCharts("template", state.ChartPrepareOptions{
			SkipRepos:              c.SkipRefresh() || c.SkipDeps(),
			SkipRefresh:            c.SkipRefresh(),
			SkipDeps:               c.SkipDeps(),
			SkipSchemaValidation:   c.SkipSchemaValidation(),
			IncludeCRDs:            &includeCRDs,
			SkipCleanup:            c.SkipCleanup(),
			Validate:               c.Validate(),
			Concurrency:            c.Concurrency(),
			IncludeTransitiveNeeds: c.IncludeNeeds(),
			Set:                    c.Set(),
			Values:                 c.Values(),
			KubeVersion:            c.KubeVersion(),
			HelmOCIPlainHTTP:       a.HelmOCIPlainHTTP,
			TemplateArgs:           c.TemplateArgs(),
		}, func() []error {
			ok, errs = a.template(run, c)
			return errs
		})

		if prepErr != nil {
			errs = append(errs, prepErr)
		}

		return
	}, c.IncludeNeeds())
}

func (a *App) WriteValues(c WriteValuesConfigProvider) error {
	return a.ForEachState(func(run *Run) (ok bool, errs []error) {
		prepErr := run.WithPreparedCharts("write-values", state.ChartPrepareOptions{
			SkipRepos:   c.SkipRefresh() || c.SkipDeps(),
			SkipRefresh: c.SkipRefresh(),
			SkipDeps:    c.SkipDeps(),
			SkipCleanup: c.SkipCleanup(),
			Concurrency: c.Concurrency(),
		}, func() []error {
			ok, errs = a.writeValues(run, c)
			return errs
		})

		if prepErr != nil {
			errs = append(errs, prepErr)
		}

		return
	}, c.IncludeTransitiveNeeds(), SetFilter(true))
}

type MultiError struct {
	Errors []error
}

func (e *MultiError) Error() string {
	indent := func(text string, indent string) string {
		lines := strings.Split(text, "\n")

		var buf bytes.Buffer
		for _, l := range lines {
			buf.WriteString(indent)
			buf.WriteString(l)
			buf.WriteString("\n")
		}

		return buf.String()
	}

	lines := []string{fmt.Sprintf("Failed with %d errors:", len(e.Errors))}
	for i, err := range e.Errors {
		lines = append(lines, fmt.Sprintf("Error %d:\n\n%v", i+1, indent(err.Error(), "  ")))
	}

	return strings.Join(lines, "\n\n")
}

func (a *App) Lint(c LintConfigProvider) error {
	var deferredLintErrors []error

	err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
		var lintErrs []error

		// `helm lint` on helm v2 and v3 does not support remote charts, that we need to set `forceDownload=true` here
		prepErr := run.WithPreparedCharts("lint", state.ChartPrepareOptions{
			ForceDownload:          true,
			SkipRepos:              c.SkipRefresh() || c.SkipDeps(),
			SkipRefresh:            c.SkipRefresh(),
			SkipDeps:               c.SkipDeps(),
			SkipCleanup:            c.SkipCleanup(),
			Concurrency:            c.Concurrency(),
			IncludeTransitiveNeeds: c.IncludeNeeds(),
		}, func() []error {
			ok, lintErrs, errs = a.lint(run, c)
			return append(errs, lintErrs...)
		})

		if prepErr != nil {
			errs = append(errs, prepErr)
		}

		if len(lintErrs) > 0 {
			deferredLintErrors = append(deferredLintErrors, lintErrs...)
		}

		return
	}, c.IncludeNeeds())

	if err != nil {
		return err
	}

	if len(deferredLintErrors) > 0 {
		return &MultiError{Errors: deferredLintErrors}
	}

	return nil
}

func (a *App) Unittest(c UnittestConfigProvider) error {
	var deferredUnittestErrors []error

	err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
		var unittestErrs []error

		// helm unittest needs local charts, so force download
		prepErr := run.WithPreparedCharts("unittest", state.ChartPrepareOptions{
			ForceDownload:          true,
			SkipRepos:              c.SkipRefresh() || c.SkipDeps(),
			SkipRefresh:            c.SkipRefresh(),
			SkipDeps:               c.SkipDeps(),
			SkipCleanup:            c.SkipCleanup(),
			Concurrency:            c.Concurrency(),
			IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(),
		}, func() []error {
			ok, unittestErrs, errs = a.unittest(run, c)
			return append(errs, unittestErrs...)
		})

		if prepErr != nil {
			errs = append(errs, prepErr)
		}

		if len(unittestErrs) > 0 {
			deferredUnittestErrors = append(deferredUnittestErrors, unittestErrs...)
		}

		return
	}, c.IncludeNeeds())

	if err != nil {
		return err
	}

	if len(deferredUnittestErrors) > 0 {
		return &MultiError{Errors: deferredUnittestErrors}
	}

	return nil
}

func (a *App) Fetch(c FetchConfigProvider) error {
	if c.WriteOutput() && c.OutputDir() == "" {
		return fmt.Errorf("--output-dir is required when --write-output is set")
	}

	if c.WriteOutput() {
		// Force sequential processing to ensure YAML documents are emitted in order
		// without interleaving when multiple helmfile state files are processed.
		// Restore the original value when Fetch returns so the App instance is not
		// permanently mutated (important for tests and library usage).
		prev := a.SequentialHelmfiles
		a.SequentialHelmfiles = true
		defer func() { a.SequentialHelmfiles = prev }()
	}

	// processedStateFileCount tracks how many state files have been processed when
	// --write-output is set; used to detect multi-file inputs early and return
	// a clear error instead of silently producing semantically incorrect YAML.
	var processedStateFileCount int

	// yamlOutput buffers the generated YAML document so that nothing is written to
	// stdout until ForEachState completes successfully. This prevents partial/corrupted
	// output reaching stdout when a later state file (or chart download error) causes
	// the operation to fail.
	var yamlOutput strings.Builder

	err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
		if c.WriteOutput() {
			processedStateFileCount++
			if processedStateFileCount > 1 {
				return false, []error{fmt.Errorf(
					"--write-output requires a single helmfile state file, but multiple were found; " +
						"use -f to specify a single helmfile instead of a directory or a helmfile with nested helmfiles: entries",
				)}
			}

			// Disable live output to avoid Helm progress/status lines being streamed
			// to stdout and corrupting the YAML document emitted by --write-output.
			// Restore the original value when this callback returns so the cached helm
			// exec instance is not permanently mutated (important for tests and library usage).
			run.helm.SetEnableLiveOutput(false)
			defer run.helm.SetEnableLiveOutput(a.EnableLiveOutput)
		}

		prepErr := run.WithPreparedCharts("pull", state.ChartPrepareOptions{
			ForceDownload:     true,
			SkipRefresh:       c.SkipRefresh(),
			SkipRepos:         c.SkipRefresh() || c.SkipDeps(),
			SkipDeps:          c.SkipDeps(),
			OutputDir:         c.OutputDir(),
			OutputDirTemplate: c.OutputDirTemplate(),
			Concurrency:       c.Concurrency(),
		}, func() []error {
			if c.WriteOutput() {
				for i := range run.state.Releases {
					rel := &run.state.Releases[i]
					if rel.ChartPath != "" {
						rel.Chart = rel.ChartPath
						rel.ChartPath = ""
					}
				}

				stateYaml, yamlErr := run.state.ToYaml()
				if yamlErr != nil {
					return []error{yamlErr}
				}

				sourceFile, pathErr := run.state.FullFilePath()
				if pathErr != nil {
					return []error{pathErr}
				}
				fmt.Fprintf(&yamlOutput, "---\n#  Source: %s\n\n%s", sourceFile, stateYaml)
			}

			return nil
		})

		if prepErr != nil {
			errs = append(errs, prepErr)
		}

		return ok, errs
	}, false, SetFilter(true))

	if err == nil && c.WriteOutput() {
		fmt.Print(yamlOutput.String())
	}

	return err
}

func (a *App) Sync(c SyncConfigProvider) error {
	var any bool

	mut := &sync.Mutex{}

	err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
		includeCRDs := !c.SkipCRDs()

		prepErr := run.WithPreparedCharts("sync", state.ChartPrepareOptions{
			SkipRepos:                  c.SkipRefresh() || c.SkipDeps(),
			SkipRefresh:                c.SkipRefresh(),
			SkipDeps:                   c.SkipDeps(),
			SkipSchemaValidation:       c.SkipSchemaValidation(),
			Wait:                       c.Wait(),
			WaitRetries:                c.WaitRetries(),
			WaitForJobs:                c.WaitForJobs(),
			IncludeCRDs:                &includeCRDs,
			IncludeTransitiveNeeds:     c.IncludeNeeds(),
			Validate:                   c.Validate(),
			Concurrency:                c.Concurrency(),
			TemplateArgs:               c.TemplateArgs(),
			PrefetchSharedRemoteCharts: true,
		}, func() []error {
			matched, updated, es := a.SyncState(run, c)

			mut.Lock()
			any = any || updated
			mut.Unlock()

			ok = matched
			errs = es
			return errs
		})

		if prepErr != nil {
			errs = append(errs, prepErr)
		}

		return
	}, c.IncludeNeeds())

	if err != nil {
		return err
	}

	if ec, ok := c.(interface{ DetailedExitcode() bool }); ok && ec.DetailedExitcode() && any {
		code := 2

		return &Error{msg: "", code: &code}
	}

	return nil
}

func (a *App) Apply(c ApplyConfigProvider) error {
	var any bool

	mut := &sync.Mutex{}

	var opts []LoadOption

	opts = append(opts, SetRetainValuesFiles(c.SkipCleanup()))

	err := a.ForEachState(func(run *Run) (ok bool, errs []error) {
		includeCRDs := !c.SkipCRDs()

		prepErr := run.WithPreparedCharts("apply", state.ChartPrepareOptions{
			SkipRepos:                  c.SkipRefresh() || c.SkipDeps(),
			SkipRefresh:                c.SkipRefresh(),
			SkipDeps:                   c.SkipDeps(),
			SkipSchemaValidation:       c.SkipSchemaValidation(),
			Wait:                       c.Wait(),
			WaitRetries:                c.WaitRetries(),
			WaitForJobs:                c.WaitForJobs(),
			IncludeCRDs:                &includeCRDs,
			SkipCleanup:                c.SkipCleanup(),
			Validate:                   c.Validate(),
			Concurrency:                c.Concurrency(),
			IncludeTransitiveNeeds:     c.IncludeNeeds(),
			TemplateArgs:               c.TemplateArgs(),
			PrefetchSharedRemoteCharts: true,
		}, func() []error {
			matched, updated, es := a.apply(run, c)

			mut.Lock()
			any = any || updated
			mut.Unlock()

			ok, errs = matched, es
			return errs
		})

		if prepErr != nil {
			errs = append(errs, prepErr)
		}

		return
	}, c.IncludeNeeds(), opts...)

	if err != nil {
		return err
	}

	if c.DetailedExitcode() && any {
		code := 2

		return &Error{msg: "", Errors: nil, code: &code}
	}

	return nil
}

func (a *App) Status(c StatusesConfigProvider) error {
	return a.ForEachState(func(run *Run) (ok bool, errs []error) {
		err := run.WithPreparedCharts("status", state.ChartPrepareOptions{
			SkipRepos:   true,
			SkipDeps:    true,
			Concurrency: c.Concurrency(),
		}, func() []error {
			ok, errs = a.status(run, c)
			return errs
		})

		if err != nil {
			errs = append(errs, err)
		}

		return
	}, false, SetFilter(true))
}

func (a *App) Destroy(c DestroyConfigProvider) error {
	return a.ForEachState(func(run *Run) (ok bool, errs []error) {
		if !c.SkipCharts() {
			err := run.WithPreparedCharts("destroy", state.ChartPrepareOptions{
				SkipRepos:     c.SkipRefresh() || c.SkipDeps(),
				SkipRefresh:   c.SkipRefresh(),
				SkipDeps:      c.SkipDeps(),
				Concurrency:   c.Concurrency(),
				DeleteWait:    c.DeleteWait(),
				DeleteTimeout: c.DeleteTimeout(),
			}, func() []error {
				ok, errs = a.delete(run, true, c)
				return errs
			})
			if err != nil {
				errs = append(errs, err)
			}
		} else {
			ok, errs = a.delete(run, true, c)
		}
		return
	}, false, SetReverse(true))
}

func (a *App) Test(c TestConfigProvider) error {
	return a.ForEachState(func(run *Run) (_ bool, errs []error) {
		if c.Cleanup() {
			a.Logger.Warnf("warn: requested cleanup will not be applied. " +
				"To clean up test resources with Helm 3, you have to remove them manually " +
				"or set helm.sh/hook-delete-policy\n")
		}

		err := run.WithPreparedCharts("test", state.ChartPrepareOptions{
			SkipRepos:   c.SkipRefresh() || c.SkipDeps(),
			SkipRefresh: c.SkipRefresh(),
			SkipDeps:    c.SkipDeps(),
			Concurrency: c.Concurrency(),
		}, func() []error {
			errs = a.test(run, c)
			return errs
		})

		if err != nil {
			errs = append(errs, err)
		}

		return
	}, false, SetFilter(true))
}

func (a *App) PrintDAGState(c DAGConfigProvider) error {
	var err error
	return a.ForEachState(func(run *Run) (ok bool, errs []error) {
		err = run.WithPreparedCharts("show-dag", state.ChartPrepareOptions{
			SkipRepos:   true,
			SkipDeps:    true,
			Concurrency: 2,
		}, func() []error {
			err = a.dag(run)
			if err != nil {
				errs = append(errs, err)
			}
			return errs
		})
		return ok, errs
	}, false, SetFilter(true))
}

func (a *App) PrintState(c StateConfigProvider) error {
	return a.ForEachState(func(run *Run) (_ bool, errs []error) {
		err := run.WithPreparedCharts("build", state.ChartPrepareOptions{
			SkipRepos:   true,
			SkipDeps:    true,
			Concurrency: 2,
		}, func() []error {
			if c.EmbedValues() {
				for i := range run.state.Releases {
					r := run.state.Releases[i]

					values, err := run.state.LoadYAMLForEmbedding(&r, r.Values, r.MissingFileHandler, r.ValuesPathPrefix)
					if err != nil {
						errs = []error{err}
						return errs
					}

					run.state.Releases[i].Values = values

					secrets, err := run.state.LoadYAMLForEmbedding(&r, r.Secrets, r.MissingFileHandler, r.ValuesPathPrefix)
					if err != nil {
						errs = []error{err}
						return errs
					}

					run.state.Releases[i].Secrets = secrets
				}
			}

			stateYaml, err := run.state.ToYaml()
			if err != nil {
				errs = []error{err}
				return errs
			}

			sourceFile, err := run.state.FullFilePath()
			if err != nil {
				errs = []error{err}
				return errs
			}
			fmt.Printf("---\n#  Source: %s\n\n%+v", sourceFile, stateYaml)

			errs = []error{}
			return errs
		})

		if err != nil {
			errs = append(errs, err)
		}

		return false, errs
	}, false, SetFilter(true))
}

func (a *App) dag(r *Run) error {
	st := r.state

	batches, err := st.PlanReleases(state.PlanOptions{SelectedReleases: st.Releases, Reverse: false, SkipNeeds: false, IncludeNeeds: true, IncludeTransitiveNeeds: true})
	if err != nil {
		return err
	}

	fmt.Print(printDAG(batches))

	return nil
}

func (a *App) ListReleases(c ListConfigProvider) error {
	releasesChan := make(chan []*HelmRelease, 100)

	err := a.ForEachState(func(run *Run) (_ bool, errs []error) {
		var stateReleases []*HelmRelease
		var listErr error

		if !c.SkipCharts() {
			prepErr := run.WithPreparedCharts("list", state.ChartPrepareOptions{
				SkipRepos:   true,
				SkipDeps:    true,
				Concurrency: 2,
			}, func() []error {
				rel, err := a.list(run)
				if err != nil {
					errs = append(errs, err)
					return []error{err}
				}
				stateReleases = rel
				return nil
			})
			if prepErr != nil {
				errs = append(errs, prepErr)
			}
		} else {
			stateReleases, listErr = a.list(run)
			if listErr != nil {
				errs = append(errs, listErr)
			}
		}

		if len(stateReleases) > 0 {
			releasesChan <- stateReleases
		}

		return
	}, false, SetFilter(true))

	close(releasesChan)

	// Collect all releases from channel
	var releases []*HelmRelease
	for rels := range releasesChan {
		releases = append(releases, rels...)
	}

	if err != nil {
		return err
	}

	// Sort releases to ensure deterministic output order regardless of parallel execution
	sort.Slice(releases, func(i, j int) bool {
		if releases[i].Namespace != releases[j].Namespace {
			return releases[i].Namespace < releases[j].Namespace
		}
		return releases[i].Name < releases[j].Name
	})

	if c.Output() == "json" {
		err = FormatAsJson(releases)
	} else {
		err = FormatAsTable(releases)
	}

	return err
}

func (a *App) list(run *Run) ([]*HelmRelease, error) {
	var releases []*HelmRelease

	resolvedState, err := run.state.ResolveDeps()
	if err != nil {
		return nil, fmt.Errorf("unable to resolve dependencies for %s: %w", run.state.FilePath, err)
	}

	for _, r := range resolvedState.Releases {
		labels := ""
		if r.Labels == nil {
			r.Labels = map[string]string{}
		}

		var keys []string
		for k := range r.Labels {
			keys = append(keys, k)
		}
		sort.Strings(keys)

		for _, k := range keys {
			v := r.Labels[k]
			labels = fmt.Sprintf("%s,%s:%s", labels, k, v)
		}
		labels = strings.Trim(labels, ",")

		enabled, err := state.ConditionEnabled(r, resolvedState.Values())
		if err != nil {
			return nil, err
		}

		releases = append(releases, &HelmRelease{
			Name:      r.Name,
			Namespace: r.Namespace,
			Installed: r.Desired(),
			Enabled:   enabled,
			Labels:    labels,
			Chart:     r.Chart,
			Version:   r.Version,
		})
	}

	return releases, nil
}

func (a *App) within(dir string, do func() error) error {
	if dir == "." {
		return do()
	}

	prev, err := a.fs.Getwd()
	if err != nil {
		return fmt.Errorf("failed getting current working directory: %v", err)
	}

	absDir, err := a.fs.Abs(dir)
	if err != nil {
		return err
	}

	// Skip chdir if we're already in the target directory
	if absDir == prev {
		return do()
	}

	a.Logger.Debugf("changing working directory to \"%s\"", absDir)

	if err := a.fs.Chdir(absDir); err != nil {
		return fmt.Errorf("failed changing working directory to \"%s\": %v", absDir, err)
	}

	appErr := do()

	a.Logger.Debugf("changing working directory back to \"%s\"", prev)

	if chdirBackErr := a.fs.Chdir(prev); chdirBackErr != nil {
		if appErr != nil {
			a.Logger.Warnf("%v", appErr)
		}
		return fmt.Errorf("failed chaging working directory back to \"%s\": %v", prev, chdirBackErr)
	}

	return appErr
}

func (a *App) loadDesiredStateFromYaml(file string, opts ...LoadOpts) (*state.HelmState, error) {
	return a.loadDesiredStateFromYamlWithBaseDir(file, "", opts...)
}

func (a *App) loadDesiredStateFromYamlWithBaseDir(file string, baseDir string, opts ...LoadOpts) (*state.HelmState, error) {
	var op LoadOpts
	if len(opts) > 0 {
		op = opts[0]
	}

	ld := &desiredStateLoader{
		fs:        a.fs,
		env:       a.Env,
		namespace: a.Namespace,
		chart:     a.Chart,
		logger:    a.Logger,
		remote:    a.remote,
		baseDir:   baseDir,

		overrideKubeContext:     a.OverrideKubeContext,
		overrideHelmBinary:      a.OverrideHelmBinary,
		overrideKustomizeBinary: a.OverrideKustomizeBinary,
		enableLiveOutput:        a.EnableLiveOutput,
		getHelm:                 a.getHelm,
		valsRuntime:             a.valsRuntime,
	}

	st, err := ld.Load(file, op)
	if err != nil {
		return nil, err
	}

	st.SetKubeconfig(a.Kubeconfig)

	return st, nil
}

type helmKey struct {
	Binary  string
	Context string
}

func createHelmKey(bin, kubectx string) helmKey {
	return helmKey{
		Binary:  bin,
		Context: kubectx,
	}
}

// GetHelm returns the global helm exec instance for the specified state that is used for helmfile-wise operation
// like decrypting environment secrets.
//
// This is currently used for running all the helm commands for reconciling releases. But this may change in the future
// once we enable each release to have its own helm binary/version.
func (a *App) getHelm(st *state.HelmState) (helmexec.Interface, error) {
	a.helmsMutex.Lock()
	defer a.helmsMutex.Unlock()

	if a.helms == nil {
		a.helms = map[helmKey]helmexec.Interface{}
	}

	bin := st.DefaultHelmBinary
	if bin == "" {
		bin = state.DefaultHelmBinary
	}
	kubeconfig := a.Kubeconfig
	kubectx := st.HelmDefaults.KubeContext

	key := createHelmKey(bin, kubectx)

	if _, ok := a.helms[key]; !ok {
		exec, err := helmexec.New(bin, helmexec.HelmExecOptions{
			EnableLiveOutput:          a.EnableLiveOutput,
			DisableForceUpdate:        a.DisableForceUpdate,
			EnforcePluginVerification: a.EnforcePluginVerification,
			HelmOCIPlainHTTP:          a.HelmOCIPlainHTTP,
			RepoRetry:                 a.RepoRetry,
		}, a.Logger, kubeconfig, kubectx, &helmexec.ShellRunner{
			Logger:                     a.Logger,
			Ctx:                        a.ctx,
			StripArgsValuesOnExitError: a.StripArgsValuesOnExitError,
		})
		if err != nil {
			return nil, err
		}
		a.helms[key] = exec
	}

	return a.helms[key], nil
}

// processStateFileParallel processes a single helmfile state file in a goroutine.
// It is used for parallel processing of multiple helmfile.d files.
// Results are communicated via errChan (errors) and matchChan (whether file had matching releases).
func (a *App) processStateFileParallel(relPath string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context, errChan chan error, matchChan chan bool) {
	var file string
	var dir string
	if a.fs.DirectoryExistsAt(relPath) {
		file = relPath
		dir = relPath
	} else {
		file = filepath.Base(relPath)
		dir = filepath.Dir(relPath)
	}

	absd, errAbsDir := a.fs.Abs(dir)
	if errAbsDir != nil {
		errChan <- errAbsDir
		return
	}

	opts := defOpts.DeepCopy()
	if opts.CalleePath == "" {
		opts.CalleePath = file
	}

	st, err := a.loadDesiredStateFromYamlWithBaseDir(file, absd, opts)
	if err != nil {
		switch stateLoadErr := err.(type) {
		case *state.StateLoadError:
			switch stateLoadErr.Cause.(type) {
			case *state.UndefinedEnvError:
				return
			default:
				errChan <- appError(fmt.Sprintf("in %s/%s", dir, file), err)
				return
			}
		default:
			errChan <- appError(fmt.Sprintf("in %s/%s", dir, file), err)
			return
		}
	}

	if st == nil {
		return
	}

	st.Selectors = opts.Selectors

	// Track whether any releases matched across nested helmfiles and converge.
	// Aggregate into a single send to matchChan to avoid overfilling the buffer
	// (which is sized to len(desiredStateFiles), i.e. one send per goroutine).
	anyMatched := false

	if len(st.Helmfiles) > 0 && !opts.Reverse {
		matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
		if err != nil {
			errChan <- err
			return
		}
		if matched {
			anyMatched = true
		}
	}

	templated, err := st.ExecuteTemplates()
	if err != nil {
		errChan <- appError(fmt.Sprintf("in %s/%s: failed executing release templates in \"%s\"", dir, file, file), err)
		return
	}

	var errs []error
	CleanWaitGroup.Add(1)
	var cleanErr error
	defer func() {
		defer CleanWaitGroup.Done()
		cleanErr = context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs)
	}()

	processed, errs := converge(templated)

	if len(errs) > 0 {
		errChan <- errs[0]
		return
	}
	if cleanErr != nil {
		errChan <- cleanErr
		return
	}

	if processed {
		anyMatched = true
	}

	if opts.Reverse && len(st.Helmfiles) > 0 {
		matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
		if err != nil {
			errChan <- err
			return
		}
		if matched {
			anyMatched = true
		}
	}

	if anyMatched {
		matchChan <- true
	}
}

// processNestedHelmfiles processes sub-helmfiles referenced from a parent state.
// It returns true if any nested helmfile successfully found matching releases,
// which is used to update the caller's noMatchInHelmfiles tracking.
func (a *App) processNestedHelmfiles(st *state.HelmState, absd, file string, defOpts, opts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) (bool, error) {
	anyMatched := false
	// Parent repo names are constant across sub-helmfiles; compute once for the
	// "did you mean inherits: [repositories]?" footgun warning. These come from
	// st.Repositories — the parent's *effective* set, which includes repositories
	// brought in via bases: or inherits:, not just those declared inline here.
	parentRepoNames := make([]string, 0, len(st.Repositories))
	for _, r := range st.Repositories {
		parentRepoNames = append(parentRepoNames, r.Name)
	}
	for i, m := range st.Helmfiles {
		if subhelmfileSelectorsConflict(a.Selectors, m, a.Logger) {
			a.Logger.Debugf("skipping subhelmfile %q: CLI selectors %v conflict with subhelmfile selectors %v", m.Path, a.Selectors, m.Selectors)
			continue
		}

		optsForNestedState := LoadOpts{
			CalleePath:        filepath.Join(absd, file),
			Environment:       m.Environment,
			Reverse:           defOpts.Reverse,
			RetainValuesFiles: defOpts.RetainValuesFiles,
		}
		if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited {
			optsForNestedState.Selectors = opts.Selectors
		} else {
			optsForNestedState.Selectors = m.Selectors
		}

		// Carry parent config requested via `inherits:`, and parent repo names
		// for the warning, down to the sub-helmfile load path.
		optsForNestedState.ParentRepoNames = parentRepoNames
		if len(m.Inherits) > 0 {
			inherited, err := st.BuildInheritedConfig(m.Inherits)
			if err != nil {
				return anyMatched, appError(fmt.Sprintf("in .helmfiles[%d]", i), err)
			}
			optsForNestedState.Inherited = inherited
		}

		if err := a.visitStatesWithContext(m.Path, optsForNestedState, converge, sharedCtx); err != nil {
			switch err.(type) {
			case *NoMatchingHelmfileError:
			default:
				return anyMatched, appError(fmt.Sprintf("in .helmfiles[%d]", i), err)
			}
		} else {
			anyMatched = true
		}
	}
	return anyMatched, nil
}

// subhelmfileSelectorsConflict returns true when the subhelmfile has explicit
// selectors that are provably incompatible with the CLI selectors,
// meaning no release could satisfy both. In that case the subhelmfile can be
// safely skipped without loading or evaluating it.
// Only CLI selectors (not inherited parent selectors) are used for comparison,
// so this optimization is restricted to cases where the user explicitly
// provided selectors via the command line (e.g. -l name=b).
func subhelmfileSelectorsConflict(cliSelectors []string, m state.SubHelmfileSpec, logger *zap.SugaredLogger) bool {
	if len(cliSelectors) == 0 || len(m.Selectors) == 0 || m.SelectorsInherited {
		return false
	}
	compatible, err := state.SelectorsAreCompatible(cliSelectors, m.Selectors)
	if err != nil {
		logger.Debugf("selector compatibility check failed for subhelmfile %q: %v", m.Path, err)
	}
	return !compatible
}

func (a *App) visitStatesWithContext(fileOrDir string, defOpts LoadOpts, converge func(*state.HelmState) (bool, []error), sharedCtx *Context) error {
	noMatchInHelmfiles := true

	desiredStateFiles, findErr := a.findDesiredStateFiles(fileOrDir, defOpts)
	if findErr != nil {
		return appError("", findErr)
	}

	// Process files in parallel if we have multiple files and parallel mode is enabled
	shouldProcessInParallel := len(desiredStateFiles) > 1 && !a.SequentialHelmfiles

	if shouldProcessInParallel {
		// Parallel processing for multiple files (default behavior)
		var wg sync.WaitGroup
		errChan := make(chan error, len(desiredStateFiles))
		matchChan := make(chan bool, len(desiredStateFiles))

		for _, relPath := range desiredStateFiles {
			wg.Add(1)
			go func(relPath string) {
				defer wg.Done()
				a.processStateFileParallel(relPath, defOpts, converge, sharedCtx, errChan, matchChan)
			}(relPath)
		}

		wg.Wait()
		close(errChan)
		close(matchChan)

		for err := range errChan {
			if err != nil {
				return err
			}
		}

		// Check if any files had matching releases
		for range matchChan {
			noMatchInHelmfiles = false
		}
	} else {
		// Sequential processing for single file or when --sequential-helmfiles is set.
		//
		// Two strategies for path resolution:
		// - Single file: use os.Chdir (via within()) to preserve backward-compatible
		//   chart path format in output (relative to the helmfile directory).
		// - Multiple files with --sequential-helmfiles: use baseDir parameter to avoid
		//   os.Chdir, which fixes relative env var paths like KUBECONFIG (#2409).
		useBaseDir := len(desiredStateFiles) > 1

		for _, relPath := range desiredStateFiles {
			var file string
			var dir string
			if a.fs.DirectoryExistsAt(relPath) {
				file = relPath
				dir = relPath
			} else {
				file = filepath.Base(relPath)
				dir = filepath.Dir(relPath)
			}

			absd, errAbsDir := a.fs.Abs(dir)
			if errAbsDir != nil {
				return errAbsDir
			}

			opts := defOpts.DeepCopy()
			if opts.CalleePath == "" {
				opts.CalleePath = file
			}

			processFile := func() (retErr error) {
				var st *state.HelmState
				var loadErr error

				if useBaseDir {
					// Multi-file sequential: use absolute baseDir for path resolution
					// instead of os.Chdir to avoid breaking relative env var paths.
					// Must use absd (absolute dir) to correctly resolve relative values/secrets paths.
					st, loadErr = a.loadDesiredStateFromYamlWithBaseDir(file, absd, opts)
				} else {
					// Single file: CWD is set by within(), load without baseDir
					st, loadErr = a.loadDesiredStateFromYaml(file, opts)
				}

				ctx := context{app: a, st: st, retainValues: defOpts.RetainValuesFiles}

				if loadErr != nil {
					switch stateLoadErr := loadErr.(type) {
					case *state.StateLoadError:
						switch stateLoadErr.Cause.(type) {
						case *state.UndefinedEnvError:
							return nil
						default:
							return ctx.wrapErrs(loadErr)
						}
					default:
						return ctx.wrapErrs(loadErr)
					}
				}

				if st == nil {
					return nil
				}

				st.Selectors = opts.Selectors

				if !opts.Reverse && len(st.Helmfiles) > 0 {
					matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
					if err != nil {
						return err
					}
					if matched {
						noMatchInHelmfiles = false
					}
				}

				templated, tmplErr := st.ExecuteTemplates()
				if tmplErr != nil {
					return appError(fmt.Sprintf("failed executing release templates in \"%s\"", file), tmplErr)
				}

				var (
					processed bool
					errs      []error
				)

				CleanWaitGroup.Add(1)
				defer func() {
					defer CleanWaitGroup.Done()
					cleanErr := context{app: a, st: templated, retainValues: defOpts.RetainValuesFiles}.clean(errs)
					if retErr == nil {
						retErr = cleanErr
					} else if cleanErr != nil {
						a.Logger.Debugf("Failed to clean up temporary files generated while processing %q: %v", templated.FilePath, cleanErr)
					}
				}()

				processed, errs = converge(templated)

				if len(errs) > 0 {
					return errs[0]
				}

				noMatchInHelmfiles = noMatchInHelmfiles && !processed

				if opts.Reverse && len(st.Helmfiles) > 0 {
					matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
					if err != nil {
						return err
					}
					if matched {
						noMatchInHelmfiles = false
					}
				}

				return nil
			}

			var fileErr error
			if useBaseDir {
				fileErr = processFile()
			} else {
				fileErr = a.within(absd, processFile)
			}
			if fileErr != nil {
				return appError(fmt.Sprintf("in %s/%s", dir, file), fileErr)
			}
		}
	}

	if noMatchInHelmfiles {
		return &NoMatchingHelmfileError{selectors: a.Selectors, env: a.Env}
	}

	return nil
}

type LoadOption func(o *LoadOpts)

var (
	SetReverse = func(r bool) func(o *LoadOpts) {
		return func(o *LoadOpts) {
			o.Reverse = r
		}
	}

	SetRetainValuesFiles = func(r bool) func(o *LoadOpts) {
		return func(o *LoadOpts) {
			o.RetainValuesFiles = r
		}
	}

	SetFilter = func(f bool) func(o *LoadOpts) {
		return func(o *LoadOpts) {
			o.Filter = f
		}
	}
)

// ForEachState iterates over each loaded state file and invokes do.
//
// includeTransitiveNeeds controls whether releases reachable via "needs" are
// un-filtered when SetFilter(true) is used. Despite the name, passing true
// unmarks ALL needs (both direct and transitive) via collectNeedsWithTransitives.
// Callers that support --include-needs should pass c.IncludeNeeds() (which is
// true for both --include-needs and --include-transitive-needs) so that chart
// preparation and early filtering stay consistent. See issue #923.
func (a *App) ForEachState(do func(*Run) (bool, []error), includeTransitiveNeeds bool, o ...LoadOption) error {
	ctx := NewContext()
	err := a.visitStatesWithSelectorsAndRemoteSupportWithContext(a.FileOrDir, func(st *state.HelmState) (bool, []error) {
		helm, err := a.getHelm(st)
		if err != nil {
			return false, []error{err}
		}

		run, err := NewRun(st, helm, &ctx)
		if err != nil {
			return false, []error{err}
		}
		return do(run)
	}, includeTransitiveNeeds, &ctx, o...)

	return err
}

func printBatches(batches [][]state.Release) string {
	buf := &bytes.Buffer{}

	w := new(tabwriter.Writer)

	w.Init(buf, 0, 1, 1, ' ', 0)

	_, _ = fmt.Fprintln(w, "GROUP\tRELEASES")

	for i, batch := range batches {
		ids := []string{}
		for _, r := range batch {
			ids = append(ids, state.ReleaseToID(&r.ReleaseSpec))
		}
		_, _ = fmt.Fprintf(w, "%d\t%s\n", i+1, strings.Join(ids, ", "))
	}

	_ = w.Flush()

	return buf.String()
}

func printDAG(batches [][]state.Release) string {
	buf := &bytes.Buffer{}

	w := new(tabwriter.Writer)

	w.Init(buf, 0, 1, 1, ' ', 0)

	_, _ = fmt.Fprintln(w, "GROUP\tRELEASE\tDEPENDENCIES")

	for i, batch := range batches {
		for _, r := range batch {
			id := state.ReleaseToID(&r.ReleaseSpec)
			needs := r.Needs
			_, _ = fmt.Fprintf(w, "%d\t%s\t%s\n", i+1, id, strings.Join(needs, ", "))
		}
	}

	_ = w.Flush()

	return trimTrailingWhitespace(buf.String())
}

// nolint: unparam
func withDAG(templated *state.HelmState, helm helmexec.Interface, logger *zap.SugaredLogger, opts state.PlanOptions, converge func(*state.HelmState, helmexec.Interface) (bool, []error)) (bool, []error) {
	batches, err := templated.PlanReleases(opts)
	if err != nil {
		return false, []error{err}
	}

	return withBatches(opts.Purpose, templated, batches, helm, logger, converge)
}

func withBatches(purpose string, templated *state.HelmState, batches [][]state.Release, helm helmexec.Interface, logger *zap.SugaredLogger, converge func(*state.HelmState, helmexec.Interface) (bool, []error)) (bool, []error) {
	numBatches := len(batches)

	if purpose == "" {
		purpose = "processing"
	}

	logger.Debugf("%s %d groups of releases in this order:\n%s", purpose, numBatches, printBatches(batches))

	any := false

	for i, batch := range batches {
		var targets []state.ReleaseSpec

		for _, marked := range batch {
			targets = append(targets, marked.ReleaseSpec)
		}

		var releaseIds []string
		for _, r := range targets {
			release := r
			releaseIds = append(releaseIds, state.ReleaseToID(&release))
		}

		logger.Debugf("%s releases in group %d/%d: %s", purpose, i+1, numBatches, strings.Join(releaseIds, ", "))

		batchSt := *templated
		batchSt.Releases = targets

		processed, errs := converge(&batchSt, helm)

		if len(errs) > 0 {
			return false, errs
		}

		any = any || processed
	}

	return any, nil
}

type Opts struct {
	DAGEnabled bool
}

func (a *App) visitStatesWithSelectorsAndRemoteSupportWithContext(fileOrDir string, converge func(*state.HelmState) (bool, []error), includeTransitiveNeeds bool, sharedCtx *Context, opt ...LoadOption) error {
	opts := LoadOpts{
		Selectors: a.Selectors,
	}

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

	envvals := []any{}

	for _, v := range a.ValuesFiles {
		envvals = append(envvals, v)
	}
	if len(envvals) > 0 {
		opts.Environment.OverrideValues = envvals
	}

	if len(a.Set) > 0 {
		opts.Environment.OverrideCLISetValues = []any{a.Set}
	}

	a.remote = remote.NewRemote(a.Logger, "", a.fs)

	f := converge
	if opts.Filter {
		f = func(st *state.HelmState) (bool, []error) {
			return processFilteredReleases(st, func(st *state.HelmState) []error {
				_, err := converge(st)
				return err
			},
				includeTransitiveNeeds)
		}
	}

	// pre-handles HelmState
	fHelmStatsWithOverrides := func(st *state.HelmState) (bool, []error) {
		var err error
		// override release settings
		st.Releases, err = st.GetReleasesWithOverrides()
		if err != nil {
			return false, []error{err}
		}

		// override release labels
		st.Releases = st.GetReleasesWithLabels()
		return f(st)
	}

	return a.visitStatesWithContext(fileOrDir, opts, fHelmStatsWithOverrides, sharedCtx)
}

func processFilteredReleases(st *state.HelmState, converge func(st *state.HelmState) []error, includeTransitiveNeeds bool) (bool, []error) {
	if len(st.Selectors) > 0 {
		err := st.FilterReleases(includeTransitiveNeeds)
		if err != nil {
			return false, []error{err}
		}
	}

	if err := checkDuplicates(st.Releases); err != nil {
		return false, []error{err}
	}

	errs := converge(st)

	processed := len(st.Releases) != 0 && len(errs) == 0

	return processed, errs
}

func checkDuplicates(releases []state.ReleaseSpec) error {
	type Key struct {
		Namespace, Name, KubeContext string
	}

	releaseNameCounts := map[Key]int{}
	for _, r := range releases {
		namespace := r.Namespace
		releaseNameCounts[Key{namespace, r.Name, r.KubeContext}]++
	}
	for name, c := range releaseNameCounts {
		if c > 1 {
			var msg string

			if name.Namespace != "" {
				msg += fmt.Sprintf(" in namespace %q", name.Namespace)
			}

			if name.KubeContext != "" {
				msg += fmt.Sprintf(" in kubecontext %q", name.KubeContext)
			}

			return fmt.Errorf("duplicate release %q found%s: there were %d releases named \"%s\" matching specified selector", name.Name, msg, c, name.Name)
		}
	}

	return nil
}

func (a *App) Wrap(converge func(*state.HelmState, helmexec.Interface) []error) func(st *state.HelmState, helm helmexec.Interface, includeTransitiveNeeds bool) (bool, []error) {
	return func(st *state.HelmState, helm helmexec.Interface, includeTransitiveNeeds bool) (bool, []error) {
		return processFilteredReleases(st, func(st *state.HelmState) []error {
			return converge(st, helm)
		}, includeTransitiveNeeds)
	}
}

func (a *App) WrapWithoutSelector(converge func(*state.HelmState, helmexec.Interface) []error) func(st *state.HelmState, helm helmexec.Interface) (bool, []error) {
	return func(st *state.HelmState, helm helmexec.Interface) (bool, []error) {
		errs := converge(st, helm)
		processed := len(st.Releases) != 0 && len(errs) == 0
		return processed, errs
	}
}

func (a *App) findDesiredStateFiles(specifiedPath string, opts LoadOpts) ([]string, error) {
	path, err := a.remote.Locate(specifiedPath, "states")
	if err != nil {
		return nil, fmt.Errorf("locate: %v", err)
	}
	if specifiedPath != path {
		a.Logger.Debugf("fetched remote \"%s\" to local cache \"%s\" and loading the latter...", specifiedPath, path)
	}
	specifiedPath = path

	var helmfileDir string
	if specifiedPath != "" {
		switch {
		case a.fs.FileExistsAt(specifiedPath):
			return []string{specifiedPath}, nil
		case a.fs.DirectoryExistsAt(specifiedPath):
			helmfileDir = specifiedPath
		default:
			return []string{}, fmt.Errorf("specified state file %s is not found", specifiedPath)
		}
	} else {
		var defaultFile string
		DefaultGotmplHelmfile := DefaultHelmfile + ".gotmpl"
		if a.fs.FileExistsAt(DefaultHelmfile) && a.fs.FileExistsAt(DefaultGotmplHelmfile) {
			return []string{}, fmt.Errorf("both %s and %s.gotmpl exist. Please remove one of them", DefaultHelmfile, DefaultHelmfile)
		}
		switch {
		case a.fs.FileExistsAt(DefaultHelmfile):
			defaultFile = DefaultHelmfile

		case a.fs.FileExistsAt(DefaultGotmplHelmfile):
			defaultFile = DefaultGotmplHelmfile
		}

		switch {
		case a.fs.DirectoryExistsAt(DefaultHelmfileDirectory):
			if defaultFile != "" {
				return []string{}, fmt.Errorf("configuration conlict error: you can have either %s or %s, but not both", defaultFile, DefaultHelmfileDirectory)
			}

			helmfileDir = DefaultHelmfileDirectory
		case defaultFile != "":
			return []string{defaultFile}, nil
		default:
			return []string{}, fmt.Errorf("no state file found. It must be named %s/*.{yaml,yml,yaml.gotmpl,yml.gotmpl}, %s, or %s, otherwise specified with the --file flag or %s environment variable", DefaultHelmfileDirectory, DefaultHelmfile, DefaultGotmplHelmfile, envvar.FilePath)
		}
	}

	files := []string{}

	ymlFiles, err := a.fs.Glob(filepath.Join(helmfileDir, "*.y*ml"))
	if err != nil {
		return []string{}, err
	}
	gotmplFiles, err := a.fs.Glob(filepath.Join(helmfileDir, "*.y*ml.gotmpl"))
	if err != nil {
		return []string{}, err
	}

	files = append(files, ymlFiles...)
	files = append(files, gotmplFiles...)

	if opts.Reverse {
		sort.Slice(files, func(i, j int) bool {
			return files[j] < files[i]
		})
	} else {
		sort.Slice(files, func(i, j int) bool {
			return files[i] < files[j]
		})
	}

	a.Logger.Debugf("found %d helmfile state files in %s: %s", len(ymlFiles)+len(gotmplFiles), helmfileDir, strings.Join(files, ", "))

	return files, nil
}

func (a *App) getSelectedReleases(r *Run, includeTransitiveNeeds bool) ([]state.ReleaseSpec, []state.ReleaseSpec, error) {
	selected, err := r.state.GetSelectedReleases(includeTransitiveNeeds)
	if err != nil {
		return nil, nil, err
	}

	selectedIds := map[string]state.ReleaseSpec{}
	selectedCounts := map[string]int{}
	for _, r := range selected {
		id := state.ReleaseToID(&r)
		selectedIds[id] = r
		selectedCounts[id]++

		if dupCount := selectedCounts[id]; dupCount > 1 {
			return nil, nil, fmt.Errorf("found %d duplicate releases with ID %q", dupCount, id)
		}
	}

	allReleases := r.state.Releases

	groupsByID := map[string][]*state.ReleaseSpec{}
	for _, r := range allReleases {
		groupsByID[state.ReleaseToID(&r)] = append(groupsByID[state.ReleaseToID(&r)], &r)
	}

	var deduplicated []state.ReleaseSpec

	dedupedBefore := map[string]struct{}{}

	// We iterate over allReleases rather than groupsByID
	// to preserve the order of releases
	for _, seq := range allReleases {
		release := seq
		id := state.ReleaseToID(&release)

		rs := groupsByID[id]

		if len(rs) == 1 {
			deduplicated = append(deduplicated, *rs[0])
			continue
		}

		if _, ok := dedupedBefore[id]; ok {
			continue
		}

		// We keep the selected one only when there were two or more duplicate
		// releases in the helmfile config.
		// Otherwise we can't compute the DAG of releases correctly.
		r, deduped := selectedIds[id]
		if deduped {
			deduplicated = append(deduplicated, r)
			dedupedBefore[id] = struct{}{}
		}
	}

	if err := checkDuplicates(deduplicated); err != nil {
		return nil, nil, err
	}

	var extra string

	if len(r.state.Selectors) > 0 {
		extra = " matching " + strings.Join(r.state.Selectors, ",")
	}

	a.Logger.Debugf("%d release(s)%s found in %s\n", len(selected), extra, r.state.FilePath)

	return selected, deduplicated, nil
}

// GetPlannedAndSelectedReleasesWithNeeds returns the planned releases and the selected releases used for planning.
// The planned releases include dependency releases only when includeNeeds is true and skipNeeds is false.
func (a *App) GetPlannedAndSelectedReleasesWithNeeds(r *Run, skipNeeds bool, includeNeeds bool, includeTransitiveNeeds bool) ([]state.ReleaseSpec, []state.ReleaseSpec, error) {
	st := r.state

	selectedReleases, selectedAndNeededReleases, err := a.getSelectedReleases(r, includeTransitiveNeeds)
	if err != nil {
		return nil, nil, err
	}
	if len(selectedReleases) == 0 {
		return nil, nil, nil
	}

	// This is required when you're trying to deduplicate releases by the selector.
	// Without this, `PlanReleases` conflates duplicates and return both in `batches`,
	// even if we provided `SelectedReleases: selectedReleases`.
	// See https://github.com/roboll/helmfile/issues/1818 for more context.
	originalReleases := st.Releases
	st.Releases = selectedAndNeededReleases
	defer func() {
		st.Releases = originalReleases
	}()

	batches, err := st.PlanReleases(state.PlanOptions{Reverse: false, SelectedReleases: selectedReleases, SkipNeeds: skipNeeds, IncludeNeeds: includeNeeds, IncludeTransitiveNeeds: includeTransitiveNeeds})
	if err != nil {
		return nil, nil, err
	}

	var releasesWithNeeds []state.ReleaseSpec

	for _, rs := range batches {
		for _, r := range rs {
			releasesWithNeeds = append(releasesWithNeeds, r.ReleaseSpec)
		}
	}

	return releasesWithNeeds, selectedAndNeededReleases, nil
}

func (a *App) apply(r *Run, c ApplyConfigProvider) (bool, bool, []error) {
	st := r.state
	helm := r.helm

	helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)

	releasesWithNeeds, selectedAndNeededReleases, err := a.GetPlannedAndSelectedReleasesWithNeeds(r, c.SkipNeeds(), c.IncludeNeeds(), c.IncludeTransitiveNeeds())
	if err != nil {
		return false, false, []error{err}
	}

	if len(releasesWithNeeds) == 0 {
		return false, false, nil
	}

	// Do build deps and prepare only on selected releases so that we won't waste time
	// on running various helm commands on unnecessary releases
	st.Releases = releasesWithNeeds

	// helm must be 2.11+ and helm-diff should be provided `--detailed-exitcode` in order for `helmfile apply` to work properly
	detailedExitCode := true

	detectedKubeVersion := a.detectKubeVersion(st)

	diffOpts := &state.DiffOpts{
		Color:                       c.Color(),
		NoColor:                     c.NoColor(),
		Context:                     c.Context(),
		Output:                      c.DiffOutput(),
		Set:                         c.Set(),
		SkipCleanup:                 c.SkipCleanup(),
		SkipDiffOnInstall:           c.SkipDiffOnInstall(),
		SkipDiffValidationOnInstall: c.SkipDiffValidationOnInstall(),
		ReuseValues:                 c.ReuseValues(),
		ResetValues:                 c.ResetValues(),
		DiffArgs:                    c.DiffArgs(),
		TemplateArgs:                c.TemplateArgs(),
		PostRenderer:                c.PostRenderer(),
		PostRendererArgs:            c.PostRendererArgs(),
		SkipSchemaValidation:        c.SkipSchemaValidation(),
		SuppressOutputLineRegex:     c.SuppressOutputLineRegex(),
		TakeOwnership:               c.TakeOwnership(),
		ServerSide:                  c.ServerSide(),
		DetectedKubeVersion:         detectedKubeVersion,
	}

	infoMsg, releasesToUpdate, releasesToDelete, diffErrs := r.diff(false, detailedExitCode, c, diffOpts)
	if len(diffErrs) > 0 {
		return false, false, diffErrs
	}

	var toDelete []state.ReleaseSpec
	for _, r := range releasesToDelete {
		toDelete = append(toDelete, r)
	}

	var toUpdate []state.ReleaseSpec
	for _, r := range releasesToUpdate {
		toUpdate = append(toUpdate, r)
	}

	releasesWithNoChange := map[string]state.ReleaseSpec{}
	for _, r := range releasesWithNeeds {
		release := r
		id := state.ReleaseToID(&release)
		_, uninstalled := releasesToDelete[id]
		_, updated := releasesToUpdate[id]
		if !uninstalled && !updated {
			releasesWithNoChange[id] = release
		}
	}

	infoMsgStr := ""
	if infoMsg != nil {
		infoMsgStr = *infoMsg
	}

	confMsg := fmt.Sprintf(`%s
Do you really want to apply?
  Helmfile will apply all your changes, as shown above.

`, infoMsgStr)

	interactive := c.Interactive()
	if !interactive && infoMsgStr != "" {
		a.Logger.Debug(infoMsgStr)
	}

	var errs []error

	// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
	st.Releases = selectedAndNeededReleases

	if len(releasesToUpdate) == 0 && len(releasesToDelete) == 0 {
		return true, false, nil
	}
	affectedReleases := state.AffectedReleases{}

	if !interactive || interactive && r.askForConfirmation(confMsg) {
		if _, preapplyErrors := withDAG(st, helm, a.Logger, state.PlanOptions{Purpose: "invoking preapply hooks for", Reverse: true, SelectedReleases: releasesWithNeeds, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
			for _, r := range subst.Releases {
				release := r
				if _, err := st.TriggerPreapplyEvent(&release, "apply"); err != nil {
					return []error{err}
				}
			}

			return nil
		})); len(preapplyErrors) > 0 {
			return true, false, preapplyErrors
		}

		// We deleted releases by traversing the DAG in reverse order
		if len(releasesToDelete) > 0 {
			_, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{Reverse: true, SelectedReleases: toDelete, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
				var rs []state.ReleaseSpec

				for _, r := range subst.Releases {
					release := r
					if r2, ok := releasesToDelete[state.ReleaseToID(&release)]; ok {
						rs = append(rs, r2)
					}
				}

				subst.Releases = rs

				return subst.DeleteReleasesForSync(&affectedReleases, helm, c.Concurrency(), c.Cascade())
			}))

			if len(deletionErrs) > 0 {
				errs = append(errs, deletionErrs...)
			}
		}

		// We upgrade releases by traversing the DAG
		if len(releasesToUpdate) > 0 {
			_, updateErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toUpdate, SkipNeeds: true, IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
				var rs []state.ReleaseSpec

				for _, r := range subst.Releases {
					release := r
					if r2, ok := releasesToUpdate[state.ReleaseToID(&release)]; ok {
						rs = append(rs, r2)
					}
				}

				subst.Releases = rs

				syncOpts := &state.SyncOpts{
					Set:                  c.Set(),
					SkipCleanup:          c.SkipCleanup(),
					SkipCRDs:             c.SkipCRDs(),
					Wait:                 c.Wait(),
					WaitRetries:          c.WaitRetries(),
					WaitForJobs:          c.WaitForJobs(),
					Timeout:              c.Timeout(),
					ReuseValues:          c.ReuseValues(),
					ResetValues:          c.ResetValues(),
					PostRenderer:         c.PostRenderer(),
					PostRendererArgs:     c.PostRendererArgs(),
					SkipSchemaValidation: c.SkipSchemaValidation(),
					SyncArgs:             c.SyncArgs(),
					HideNotes:            c.HideNotes(),
					TakeOwnership:        c.TakeOwnership(),
					ServerSide:           c.ServerSide(),
					SyncReleaseLabels:    c.SyncReleaseLabels(),
					TrackMode:            c.TrackMode(),
					TrackTimeout:         c.TrackTimeout(),
					TrackLogs:            c.TrackLogs(),
					TrackFailedLogs:      c.TrackFailedLogs(),
					HelmStuckGrace:       c.HelmStuckGrace(),
					TrackFailOnError:     c.TrackFailOnError(),
					Description:          c.Description(),
					Color:                c.Color(),
					NoColor:              c.NoColor(),
				}
				return subst.SyncReleases(&affectedReleases, helm, c.Values(), c.Concurrency(), syncOpts)
			}))

			if len(updateErrs) > 0 {
				errs = append(errs, updateErrs...)
			}
		}
	}

	affectedReleases.DisplayAffectedReleases(c.Logger(), !c.NoColor())

	for id := range releasesWithNoChange {
		r := releasesWithNoChange[id]
		if _, err := st.TriggerCleanupEvent(&r, "apply"); err != nil {
			a.Logger.Warnf("warn: %v\n", err)
		}
	}
	if releasesToDelete == nil && releasesToUpdate == nil {
		return true, false, nil
	}

	return true, true, errs
}

func (a *App) delete(r *Run, purge bool, c DestroyConfigProvider) (bool, []error) {
	st := r.state
	helm := r.helm

	affectedReleases := state.AffectedReleases{}

	toSync, _, err := a.getSelectedReleases(r, false)
	if err != nil {
		return false, []error{err}
	}
	if len(toSync) == 0 {
		return false, nil
	}

	toDelete, err := st.DetectReleasesToBeDeleted(helm, toSync)
	if err != nil {
		return false, []error{err}
	}

	releasesToDelete := map[string]state.ReleaseSpec{}
	for _, r := range toDelete {
		release := r
		id := state.ReleaseToID(&release)
		releasesToDelete[id] = release
	}

	releasesWithNoChange := map[string]state.ReleaseSpec{}
	for _, r := range toSync {
		release := r
		id := state.ReleaseToID(&release)
		_, uninstalled := releasesToDelete[id]
		if !uninstalled {
			releasesWithNoChange[id] = release
		}
	}

	for id := range releasesWithNoChange {
		r := releasesWithNoChange[id]
		if _, err := st.TriggerCleanupEvent(&r, "delete"); err != nil {
			a.Logger.Warnf("warn: %v\n", err)
		}
	}

	names := make([]string, len(toSync))
	for i, r := range toSync {
		names[i] = fmt.Sprintf("  %s (%s)", r.Name, r.Chart)
	}

	var errs []error

	msg := fmt.Sprintf(`Affected releases are:
%s

Do you really want to delete?
  Helmfile will delete all your releases, as shown above.

`, strings.Join(names, "\n"))
	interactive := c.Interactive()
	if !interactive || interactive && r.askForConfirmation(msg) {
		r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)

		if len(releasesToDelete) > 0 {
			_, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toDelete, Reverse: true, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
				return subst.DeleteReleases(&affectedReleases, helm, c.Concurrency(), purge, c.Cascade())
			}))

			if len(deletionErrs) > 0 {
				errs = append(errs, deletionErrs...)
			}
		}
	}
	affectedReleases.DisplayAffectedReleases(c.Logger(), !c.NoColor())
	return true, errs
}

// detectKubeVersion auto-detects the Kubernetes cluster version if not specified in helmfile.yaml.
// This prevents helm-diff from falling back to v1.20.0 (issue #2275).
// Returns empty string when kubeVersion is already set in helmfile.yaml (not needed),
// when auto-detection is disabled, or if detection fails.
func (a *App) detectKubeVersion(st *state.HelmState) string {
	if st.KubeVersion != "" {
		return ""
	}

	// Allow tests to disable auto-detection to avoid connecting to real clusters
	if a.DisableKubeVersionAutoDetection {
		return ""
	}

	version, err := cluster.DetectServerVersion(a.Kubeconfig, a.OverrideKubeContext)
	if err != nil {
		// If detection fails, we silently continue - helm-diff will handle it
		return ""
	}

	return version
}

func (a *App) diff(r *Run, c DiffConfigProvider) (*string, bool, bool, []error) {
	var (
		infoMsg          *string
		updated, deleted map[string]state.ReleaseSpec
	)

	ok, errs := a.withNeeds(r, c, true, func(st *state.HelmState) []error {
		helm := r.helm

		var errs []error

		helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)

		detectedKubeVersion := a.detectKubeVersion(st)

		opts := &state.DiffOpts{
			Context:                     c.Context(),
			Output:                      c.DiffOutput(),
			Color:                       c.Color(),
			NoColor:                     c.NoColor(),
			Set:                         c.Set(),
			DiffArgs:                    c.DiffArgs(),
			TemplateArgs:                c.TemplateArgs(),
			SkipDiffOnInstall:           c.SkipDiffOnInstall(),
			SkipDiffValidationOnInstall: c.SkipDiffValidationOnInstall(),
			ReuseValues:                 c.ReuseValues(),
			ResetValues:                 c.ResetValues(),
			PostRenderer:                c.PostRenderer(),
			PostRendererArgs:            c.PostRendererArgs(),
			SkipSchemaValidation:        c.SkipSchemaValidation(),
			SuppressOutputLineRegex:     c.SuppressOutputLineRegex(),
			TakeOwnership:               c.TakeOwnership(),
			ServerSide:                  c.ServerSide(),
			DetectedKubeVersion:         detectedKubeVersion,
		}

		filtered := &Run{
			state: st,
			helm:  helm,
			ctx:   r.ctx,
			Ask:   r.Ask,
		}
		infoMsg, updated, deleted, errs = filtered.diff(true, c.DetailedExitcode(), c, opts)

		return errs
	})

	return infoMsg, ok, len(deleted) > 0 || len(updated) > 0, errs
}

func (a *App) lint(r *Run, c LintConfigProvider) (bool, []error, []error) {
	var deferredLintErrs []error

	ok, errs := a.withNeeds(r, c, false, func(st *state.HelmState) []error {
		helm := r.helm

		args := GetArgs(c.Args(), st)

		// Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `lint`
		helm.SetExtraArgs()

		if len(args) > 0 {
			helm.SetExtraArgs(args...)
		}

		opts := &state.LintOpts{
			Set:         c.Set(),
			SkipCleanup: c.SkipCleanup(),
		}
		lintErrs := st.LintReleases(helm, c.Values(), args, c.Concurrency(), opts)
		if len(lintErrs) == 1 {
			if err, ok := lintErrs[0].(helmexec.ExitError); ok {
				if err.Code > 0 {
					deferredLintErrs = append(deferredLintErrs, err)

					return nil
				}
			}
		}

		return lintErrs
	})

	return ok, deferredLintErrs, errs
}

func (a *App) unittest(r *Run, c UnittestConfigProvider) (bool, []error, []error) {
	var deferredUnittestErrs []error

	ok, errs := a.withNeeds(r, c, false, func(st *state.HelmState) []error {
		helm := r.helm

		args := GetArgs(c.Args(), st)

		// Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `unittest`
		helm.SetExtraArgs()

		if len(args) > 0 {
			helm.SetExtraArgs(args...)
		}

		opts := &state.UnittestOpts{
			Set:         c.Set(),
			SkipCleanup: c.SkipCleanup(),
			FailFast:    c.FailFast(),
			Color:       c.Color(),
			DebugPlugin: c.DebugPlugin(),
		}
		unittestErrs := st.UnittestReleases(helm, c.Values(), args, c.Concurrency(), opts)
		if len(unittestErrs) == 1 {
			if err, ok := unittestErrs[0].(helmexec.ExitError); ok {
				if err.Code > 0 {
					deferredUnittestErrs = append(deferredUnittestErrs, err)

					return nil
				}
			}
		}

		return unittestErrs
	})

	return ok, deferredUnittestErrs, errs
}

func (a *App) status(r *Run, c StatusesConfigProvider) (bool, []error) {
	st := r.state
	helm := r.helm

	allReleases := st.Releases

	selectedReleases, selectedAndNeededReleases, err := a.getSelectedReleases(r, false)
	if err != nil {
		return false, []error{err}
	}
	if len(selectedReleases) == 0 {
		return false, nil
	}

	// Do build deps and prepare only on selected releases so that we won't waste time
	// on running various helm commands on unnecessary releases
	st.Releases = selectedAndNeededReleases

	var toStatus []state.ReleaseSpec
	for _, r := range selectedReleases {
		if r.Desired() {
			toStatus = append(toStatus, r)
		}
	}

	var errs []error

	// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
	st.Releases = allReleases

	args := GetArgs(c.Args(), st)

	// Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `lint`
	helm.SetExtraArgs()

	if len(args) > 0 {
		helm.SetExtraArgs(args...)
	}

	if len(toStatus) > 0 {
		_, templateErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toStatus, Reverse: false, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
			return subst.ReleaseStatuses(helm, c.Concurrency())
		}))

		if len(templateErrs) > 0 {
			errs = append(errs, templateErrs...)
		}
	}
	return true, errs
}

func (a *App) SyncState(r *Run, c SyncConfigProvider) (bool, bool, []error) {
	st := r.state
	helm := r.helm

	releasesWithNeeds, selectedAndNeededReleases, err := a.GetPlannedAndSelectedReleasesWithNeeds(r, c.SkipNeeds(), c.IncludeNeeds(), c.IncludeTransitiveNeeds())
	if err != nil {
		return false, false, []error{err}
	}
	if len(releasesWithNeeds) == 0 {
		return false, false, nil
	}

	// Do build deps and prepare only on selected releases so that we won't waste time
	// on running various helm commands on unnecessary releases
	st.Releases = releasesWithNeeds

	toDelete, err := st.DetectReleasesToBeDeletedForSync(helm, releasesWithNeeds)
	if err != nil {
		return false, false, []error{err}
	}

	releasesToDelete := map[string]state.ReleaseSpec{}
	for _, r := range toDelete {
		release := r
		id := state.ReleaseToID(&release)
		releasesToDelete[id] = release
	}

	var toUpdate []state.ReleaseSpec
	for _, r := range releasesWithNeeds {
		release := r
		if _, deleted := releasesToDelete[state.ReleaseToID(&release)]; !deleted {
			if r.Desired() {
				toUpdate = append(toUpdate, release)
			}
			// TODO Emit error when the user opted to fail when the needed release is disabled,
			// instead of silently ignoring it.
			// See https://github.com/roboll/helmfile/issues/1018
		}
	}

	releasesToUpdate := map[string]state.ReleaseSpec{}
	for _, r := range toUpdate {
		release := r
		id := state.ReleaseToID(&release)
		releasesToUpdate[id] = release
	}

	releasesWithNoChange := map[string]state.ReleaseSpec{}
	for _, r := range releasesWithNeeds {
		release := r
		id := state.ReleaseToID(&release)
		_, uninstalled := releasesToDelete[id]
		_, updated := releasesToUpdate[id]
		if !uninstalled && !updated {
			releasesWithNoChange[id] = release
		}
	}

	names := []string{}
	for _, r := range releasesToUpdate {
		names = append(names, fmt.Sprintf("  %s (%s) UPDATED", r.Name, r.Chart))
	}
	for _, r := range releasesToDelete {
		names = append(names, fmt.Sprintf("  %s (%s) DELETED", r.Name, r.Chart))
	}
	// Make the output deterministic for testing purpose
	sort.Strings(names)

	interactive := c.Interactive()

	var infoMsg string
	var errs []error

	r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)

	operationsAttempted := false

	if interactive {
		if diffC, ok := c.(DiffConfigProvider); ok {
			detectedKubeVersion := a.detectKubeVersion(st)
			diffOpts := &state.DiffOpts{
				Context:                     diffC.Context(),
				Output:                      diffC.DiffOutput(),
				Color:                       diffC.Color(),
				NoColor:                     diffC.NoColor(),
				Set:                         diffC.Set(),
				DiffArgs:                    diffC.DiffArgs(),
				TemplateArgs:                diffC.TemplateArgs(),
				SkipDiffOnInstall:           diffC.SkipDiffOnInstall(),
				SkipDiffValidationOnInstall: diffC.SkipDiffValidationOnInstall(),
				ReuseValues:                 diffC.ReuseValues(),
				ResetValues:                 diffC.ResetValues(),
				PostRenderer:                diffC.PostRenderer(),
				PostRendererArgs:            diffC.PostRendererArgs(),
				SkipSchemaValidation:        diffC.SkipSchemaValidation(),
				SuppressOutputLineRegex:     diffC.SuppressOutputLineRegex(),
				TakeOwnership:               diffC.TakeOwnership(),
				ServerSide:                  diffC.ServerSide(),
				DetectedKubeVersion:         detectedKubeVersion,
			}
			infoMsgPtr, _, _, diffErrs := r.diff(false, diffC.DetailedExitcode(), diffC, diffOpts)
			if len(diffErrs) > 0 {
				return false, false, diffErrs
			}
			if infoMsgPtr != nil {
				infoMsg = *infoMsgPtr
			} else {
				infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
			}
		} else {
			infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
		}
	} else {
		infoMsg = fmt.Sprintf(`Affected releases are:
%s
`, strings.Join(names, "\n"))
		a.Logger.Debug(infoMsg)
	}

	confMsg := fmt.Sprintf(`%s
Do you really want to sync?
  Helmfile will sync all your releases, as shown above.

`, infoMsg)

	// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
	st.Releases = selectedAndNeededReleases

	affectedReleases := state.AffectedReleases{}

	if !interactive || interactive && r.askForConfirmation(confMsg) {
		if len(releasesToDelete) > 0 {
			operationsAttempted = true
			_, deletionErrs := withDAG(st, helm, a.Logger, state.PlanOptions{Reverse: true, SelectedReleases: toDelete, SkipNeeds: true}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
				var rs []state.ReleaseSpec

				for _, r := range subst.Releases {
					release := r
					if r2, ok := releasesToDelete[state.ReleaseToID(&release)]; ok {
						rs = append(rs, r2)
					}
				}

				subst.Releases = rs

				return subst.DeleteReleasesForSync(&affectedReleases, helm, c.Concurrency(), c.Cascade())
			}))

			if len(deletionErrs) > 0 {
				errs = append(errs, deletionErrs...)
			}
		}

		if len(releasesToUpdate) > 0 {
			operationsAttempted = true
			_, syncErrs := withDAG(st, helm, a.Logger, state.PlanOptions{SelectedReleases: toUpdate, SkipNeeds: true, IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
				var rs []state.ReleaseSpec

				for _, r := range subst.Releases {
					release := r
					if _, ok := releasesToDelete[state.ReleaseToID(&release)]; !ok {
						rs = append(rs, release)
					}
				}

				subst.Releases = rs

				syncOpts := &state.SyncOpts{
					Set:                  c.Set(),
					SkipCRDs:             c.SkipCRDs(),
					Wait:                 c.Wait(),
					WaitRetries:          c.WaitRetries(),
					WaitForJobs:          c.WaitForJobs(),
					Timeout:              c.Timeout(),
					ReuseValues:          c.ReuseValues(),
					ResetValues:          c.ResetValues(),
					PostRenderer:         c.PostRenderer(),
					PostRendererArgs:     c.PostRendererArgs(),
					SyncArgs:             c.SyncArgs(),
					HideNotes:            c.HideNotes(),
					TakeOwnership:        c.TakeOwnership(),
					ServerSide:           c.ServerSide(),
					SkipSchemaValidation: c.SkipSchemaValidation(),
					SyncReleaseLabels:    c.SyncReleaseLabels(),
					TrackMode:            c.TrackMode(),
					TrackTimeout:         c.TrackTimeout(),
					TrackLogs:            c.TrackLogs(),
					TrackFailedLogs:      c.TrackFailedLogs(),
					HelmStuckGrace:       c.HelmStuckGrace(),
					TrackFailOnError:     c.TrackFailOnError(),
					Description:          c.Description(),
					Color:                c.Color(),
					NoColor:              c.NoColor(),
				}
				return subst.SyncReleases(&affectedReleases, helm, c.Values(), c.Concurrency(), syncOpts)
			}))

			if len(syncErrs) > 0 {
				errs = append(errs, syncErrs...)
			}
		}
	}

	affectedReleases.DisplayAffectedReleases(c.Logger(), !c.NoColor())

	for id := range releasesWithNoChange {
		r := releasesWithNoChange[id]
		if _, err := st.TriggerCleanupEvent(&r, "sync"); err != nil {
			a.Logger.Warnf("warn: %v\n", err)
		}
	}

	changesApplied := operationsAttempted && len(errs) == 0

	return true, changesApplied, errs
}

func (a *App) template(r *Run, c TemplateConfigProvider) (bool, []error) {
	return a.withNeeds(r, c, false, func(st *state.HelmState) []error {
		helm := r.helm

		args := GetArgs(c.Args(), st)

		// Reset the extra args if already set, not to break `helm fetch` by adding the args intended for `lint`
		helm.SetExtraArgs()

		if len(args) > 0 {
			helm.SetExtraArgs(args...)
		}

		opts := &state.TemplateOpts{
			Set:                  c.Set(),
			IncludeCRDs:          c.IncludeCRDs(),
			NoHooks:              c.NoHooks(),
			OutputDirTemplate:    c.OutputDirTemplate(),
			SkipCleanup:          c.SkipCleanup(),
			SkipTests:            c.SkipTests(),
			PostRenderer:         c.PostRenderer(),
			PostRendererArgs:     c.PostRendererArgs(),
			KubeVersion:          c.KubeVersion(),
			ShowOnly:             c.ShowOnly(),
			SkipSchemaValidation: c.SkipSchemaValidation(),
			TemplateArgs:         c.TemplateArgs(),
		}
		return st.TemplateReleases(helm, c.OutputDir(), c.Values(), args, c.Concurrency(), c.Validate(), opts)
	})
}

func (a *App) withNeeds(r *Run, c DAGConfig, includeDisabled bool, f func(*state.HelmState) []error) (bool, []error) {
	st := r.state

	selectedReleases, deduplicated, err := a.getSelectedReleases(r, false)
	if err != nil {
		return false, []error{err}
	}
	if len(selectedReleases) == 0 {
		return false, nil
	}

	// This is required when you're trying to deduplicate releases by the selector.
	// Without this, `PlanReleases` conflates duplicates and return both in `batches`,
	// even if we provided `SelectedReleases: selectedReleases`.
	// See https://github.com/roboll/helmfile/issues/1818 for more context.
	st.Releases = deduplicated

	includeNeeds := c.IncludeNeeds()
	if c.IncludeTransitiveNeeds() {
		includeNeeds = true
	}

	batches, err := st.PlanReleases(state.PlanOptions{
		Reverse:                false,
		SelectedReleases:       selectedReleases,
		IncludeNeeds:           includeNeeds,
		IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(),
		SkipNeeds:              c.SkipNeeds(),
	})

	if err != nil {
		return false, []error{err}
	}

	var selectedReleasesWithNeeds []state.ReleaseSpec
	for _, rs := range batches {
		for _, r := range rs {
			selectedReleasesWithNeeds = append(selectedReleasesWithNeeds, r.ReleaseSpec)
		}
	}

	var toRender []state.ReleaseSpec

	releasesToUninstall := map[string]state.ReleaseSpec{}
	for _, r := range selectedReleasesWithNeeds {
		release := r
		id := state.ReleaseToID(&release)
		if !release.Desired() {
			releasesToUninstall[id] = release
		} else {
			toRender = append(toRender, release)
		}
	}

	var rels []state.ReleaseSpec

	if len(toRender) > 0 {
		// toRender already contains the direct and transitive needs depending on the DAG options.
		// That's why we don't pass in `IncludeNeeds: c.IncludeNeeds(), IncludeTransitiveNeeds: c.IncludeTransitiveNeeds()` here.
		// Otherwise, in case include-needs=true, it will include the needs of needs, which results in unexpectedly introducing transitive needs,
		// even if include-transitive-needs=true is unspecified.
		if _, errs := withDAG(st, r.helm, a.Logger, state.PlanOptions{SelectedReleases: toRender, Reverse: false, SkipNeeds: c.SkipNeeds(), IncludeNeeds: includeNeeds}, a.WrapWithoutSelector(func(subst *state.HelmState, helm helmexec.Interface) []error {
			rels = append(rels, subst.Releases...)
			return nil
		})); len(errs) > 0 {
			return false, errs
		}
	}

	if c.EnforceNeedsAreInstalled() {
		for _, r := range toRender {
			visited := make(map[string]bool)
			if depErr := st.HasTransitiveDependencyWithInstalledFalse(r, visited); depErr != nil {
				return false, []error{fmt.Errorf("Release %s has a transitive dependency %s marked as installed=false", depErr.Release.Name, depErr.Dependency.Name)}
			}
		}
	}

	if includeDisabled {
		for _, d := range releasesToUninstall {
			rels = append(rels, d)
		}
	}

	// Traverse DAG of all the releases so that we don't suffer from false-positive missing dependencies
	// and we don't fail on dependenciese on disabled releases.
	// In diff, we need to diff on disabled releases to show to-be-uninstalled releases.
	// In lint and template, we'd need to run respective helm commands only on enabled releases,
	// without failing on disabled releases.
	st.Releases = rels

	errs := f(st)

	return true, errs
}

func (a *App) test(r *Run, c TestConfigProvider) []error {
	cleanup := c.Cleanup()
	timeout := c.Timeout()
	concurrency := c.Concurrency()

	st := r.state

	toTest, _, err := a.getSelectedReleases(r, false)
	if err != nil {
		return []error{err}
	}

	if len(toTest) == 0 {
		return nil
	}

	// Do test only on selected releases, because that's what the user intended
	// with conditions and selectors
	st.Releases = toTest

	r.helm.SetExtraArgs(GetArgs(c.Args(), r.state)...)

	return st.TestReleases(r.helm, cleanup, timeout, concurrency, state.Logs(c.Logs()))
}

func (a *App) writeValues(r *Run, c WriteValuesConfigProvider) (bool, []error) {
	st := r.state
	helm := r.helm

	toRender, _, err := a.getSelectedReleases(r, false)
	if err != nil {
		return false, []error{err}
	}
	if len(toRender) == 0 {
		return false, nil
	}

	// Do build deps and prepare only on selected releases so that we won't waste time
	// on running various helm commands on unnecessary releases
	st.Releases = toRender

	releasesToWrite := map[string]state.ReleaseSpec{}
	for _, r := range toRender {
		release := r
		id := state.ReleaseToID(&release)
		if release.Desired() {
			releasesToWrite[id] = release
		}
	}

	var errs []error

	// Note: We don't calculate the DAG of releases here unlike other helmfile operations,
	// because there's no need to do so for just writing values.
	// See the first bullet in https://github.com/roboll/helmfile/issues/1460#issuecomment-691863465
	if len(releasesToWrite) > 0 {
		var rs []state.ReleaseSpec

		for _, r := range releasesToWrite {
			rs = append(rs, r)
		}

		st.Releases = rs

		opts := &state.WriteValuesOpts{
			Set:                c.Set(),
			OutputFileTemplate: c.OutputFileTemplate(),
			SkipCleanup:        c.SkipCleanup(),
		}
		errs = st.WriteReleasesValues(helm, c.Values(), opts)
	}

	return true, errs
}

// Error is a wrapper around an error that adds context to the error.
type Error struct {
	msg string

	Errors []error

	code *int
}

func (e *Error) Error() string {
	var cause string
	if e.Errors == nil {
		return e.msg
	}
	if len(e.Errors) == 1 {
		if e.Errors[0] == nil {
			panic(fmt.Sprintf("[bug] assertion error: unexpected state: e.Errors: %v", e.Errors))
		}
		cause = e.Errors[0].Error()
	} else {
		msgs := []string{}
		for i, err := range e.Errors {
			if err == nil {
				continue
			}
			msgs = append(msgs, fmt.Sprintf("err %d: %v", i, err.Error()))
		}
		cause = fmt.Sprintf("%d errors:\n%s", len(e.Errors), strings.Join(msgs, "\n"))
	}
	msg := ""
	if e.msg != "" {
		msg = fmt.Sprintf("%s: %s", e.msg, cause)
	} else {
		msg = cause
	}
	return msg
}

func (e *Error) Code() int {
	if e.code != nil {
		return *e.code
	}

	allDiff := false
	anyNonZero := false
	for _, err := range e.Errors {
		switch ee := err.(type) {
		case *state.ReleaseError:
			if anyNonZero {
				allDiff = allDiff && ee.Code == 2
			} else {
				allDiff = ee.Code == 2
			}
		case *Error:
			if anyNonZero {
				allDiff = allDiff && ee.Code() == 2
			} else {
				allDiff = ee.Code() == 2
			}
		}
		anyNonZero = true
	}

	if anyNonZero {
		if allDiff {
			return 2
		}
		return 1
	}
	panic(fmt.Sprintf("[bug] assertion error: unexpected state: unable to handle errors: %v", e.Errors))
}

func appError(msg string, err error) *Error {
	return &Error{msg: msg, Errors: []error{err}}
}

func (c context) clean(errs []error) error {
	if errs == nil {
		errs = []error{}
	}

	if !c.retainValues {
		cleanErrs := c.st.Clean()
		if cleanErrs != nil {
			errs = append(errs, cleanErrs...)
		}
	}

	return c.wrapErrs(errs...)
}

type context struct {
	app *App
	st  *state.HelmState

	retainValues bool
}

func (c context) wrapErrs(errs ...error) error {
	if len(errs) > 0 {
		for _, err := range errs {
			switch e := err.(type) {
			case *state.ReleaseError:
				c.app.Logger.Debugf("err: release \"%s\" in \"%s\" failed: %v", e.Name, c.st.FilePath, e)
			default:
				c.app.Logger.Debugf("err: %v", e)
			}
		}
		return &Error{Errors: errs}
	}
	return nil
}

func (a *App) ShowCacheDir(c CacheConfigProvider) error {
	fmt.Printf("Cache directory: %s\n", remote.CacheDir())

	if !a.fs.DirectoryExistsAt(remote.CacheDir()) {
		return nil
	}
	dirs, err := a.fs.ReadDir(remote.CacheDir())
	if err != nil {
		return err
	}
	for _, e := range dirs {
		fmt.Printf("- %s\n", e.Name())
	}

	return nil
}

func (a *App) CleanCacheDir(c CacheConfigProvider) error {
	if !a.fs.DirectoryExistsAt(remote.CacheDir()) {
		return nil
	}
	fmt.Printf("Cleaning up cache directory: %s\n", remote.CacheDir())
	dirs, err := os.ReadDir(remote.CacheDir())
	if err != nil {
		return err
	}
	for _, e := range dirs {
		fmt.Printf("- %s\n", e.Name())
		err := os.RemoveAll(filepath.Join(remote.CacheDir(), e.Name()))
		if err != nil {
			return err
		}
	}

	return nil
}

func GetArgs(args string, state *state.HelmState) []string {
	baseArgs := []string{}
	stateArgs := []string{}
	if len(args) > 0 {
		baseArgs = argparser.CollectArgs(args)
	}

	if len(state.HelmDefaults.Args) > 0 {
		stateArgs = argparser.CollectArgs(strings.Join(state.HelmDefaults.Args, " "))
	}
	state.HelmDefaults.Args = append(baseArgs, stateArgs...)

	return state.HelmDefaults.Args
}
