package run

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"reflect"
	"slices"
	"strings"
	"sync"
	"time"

	"github.com/hashicorp/go-getter"
	getterv2 "github.com/hashicorp/go-getter/v2"

	"github.com/gruntwork-io/terragrunt/internal/cas"
	"github.com/gruntwork-io/terragrunt/internal/errors"
	"github.com/gruntwork-io/terragrunt/internal/experiment"
	"github.com/gruntwork-io/terragrunt/internal/report"
	"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/pkg/log"
)

// ModuleManifestName is the manifest for files copied from terragrunt module folder (i.e., the folder that contains the current terragrunt.hcl).
const (
	ModuleManifestName = ".terragrunt-module-manifest"

	// ModuleInitRequiredFile is a file to indicate that init should be executed.
	ModuleInitRequiredFile = ".terragrunt-init-required"

	tfLintConfig = ".tflint.hcl"

	fileURIScheme = "file://"
)

// DownloadTerraformSource downloads the given source URL, which should use Terraform's module source syntax,
// into a temporary folder, then:
// 1. Check if module directory exists in temporary folder
// 2. Copy the contents of opts.WorkingDir into the temporary folder.
// 3. Set opts.WorkingDir to the temporary folder.
//
// See the NewTerraformSource method for how we determine the temporary folder so we can reuse it across multiple
// runs of Terragrunt to avoid downloading everything from scratch every time.
func DownloadTerraformSource(
	ctx context.Context,
	l log.Logger,
	source string,
	opts *Options,
	cfg *runcfg.RunConfig,
	r *report.Report,
) (*Options, error) {
	walkWithSymlinks := opts.Experiments.Evaluate(experiment.Symlinks)

	terraformSource, err := tf.NewSource(l, source, opts.DownloadDir, opts.WorkingDir, walkWithSymlinks)
	if err != nil {
		return nil, err
	}

	// Serialize concurrent downloads to the same cache directory. Without this,
	// manifest.Clean() in one goroutine can delete files while another goroutine
	// is checking for them (e.g. during CheckFolderContainsTerraformCode).
	rawLock, _ := sourceChangeLocks.LoadOrStore(terraformSource.DownloadDir, &sync.Mutex{})
	dirLock := rawLock.(*sync.Mutex)
	dirLock.Lock()
	defer dirLock.Unlock()

	downloaded, err := DownloadTerraformSourceIfNecessary(ctx, l, terraformSource, opts, cfg, r)
	if err != nil {
		return nil, err
	}

	// When no download was needed (AlreadyHaveLatestCode=true) and the source
	// directory IS the working directory (source="."), skip the module copy: the
	// version hash incorporates all file mod times, so no files have changed and
	// the cache already has the correct content from a previous run. Skipping
	// avoids manifest.Clean() deleting files that a concurrent goroutine expects
	// to exist.
	//
	// When the source is a different directory (local or remote), the module copy
	// overlays working-dir files on top of the downloaded source. These files may
	// change independently of the source version hash, so the copy must always run.
	sourceIsWorkingDir := tf.IsLocalSource(terraformSource.CanonicalSourceURL) &&
		filepath.Clean(terraformSource.CanonicalSourceURL.Path) == filepath.Clean(opts.WorkingDir)
	needsModuleCopy := downloaded || !sourceIsWorkingDir

	if needsModuleCopy {
		l.Debugf(
			"Copying files from %s into %s",
			util.RelPathForLog(opts.WorkingDir, opts.WorkingDir, opts.Writers.LogShowAbsPaths),
			util.RelPathForLog(opts.RootWorkingDir, terraformSource.WorkingDir, opts.Writers.LogShowAbsPaths),
		)

		// Always include the .tflint.hcl file, if it exists
		includeInCopy := slices.Concat(cfg.Terraform.IncludeInCopy, []string{tfLintConfig})

		copyOpts := []util.CopyOption{
			util.WithIncludeInCopy(includeInCopy...),
			util.WithExcludeFromCopy(cfg.Terraform.ExcludeFromCopy...),
		}
		if isFastCopyEnabled(opts.StrictControls) {
			copyOpts = append(copyOpts, util.WithFastCopy())
		}

		err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "copy_folder_contents", map[string]any{
			"src":  opts.WorkingDir,
			"dest": terraformSource.WorkingDir,
		}, func(_ context.Context) error {
			return util.CopyFolderContents(l, opts.WorkingDir, terraformSource.WorkingDir, ModuleManifestName, copyOpts...)
		})
		if err != nil {
			return nil, err
		}
	}

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

	l.Debugf(
		"Setting working directory to %s",
		util.RelPathForLog(
			opts.RootWorkingDir,
			terraformSource.WorkingDir,
			opts.Writers.LogShowAbsPaths,
		),
	)
	updatedOpts.WorkingDir = terraformSource.WorkingDir

	return updatedOpts, nil
}

// DownloadTerraformSourceIfNecessary downloads the specified TerraformSource if the latest code hasn't already been
// downloaded. It returns true if a download was performed, or false if the existing cache was up to date.
func DownloadTerraformSourceIfNecessary(
	ctx context.Context,
	l log.Logger,
	terraformSource *tf.Source,
	opts *Options,
	cfg *runcfg.RunConfig,
	r *report.Report,
) (bool, error) {
	if opts.SourceUpdate {
		l.Debugf("The --source-update flag is set, so deleting the temporary folder %s before downloading source.", terraformSource.DownloadDir)

		if err := os.RemoveAll(terraformSource.DownloadDir); err != nil {
			return false, errors.New(err)
		}
	} else {
		alreadyLatest, err := AlreadyHaveLatestCode(l, terraformSource, opts)
		if err != nil {
			return false, err
		}

		if alreadyLatest {
			if err := ValidateWorkingDir(terraformSource); err != nil {
				return false, err
			}

			l.Debugf(
				"%s files in %s are up to date. Will not download again.",
				opts.TofuImplementation,
				util.RelPathForLog(
					opts.RootWorkingDir,
					terraformSource.WorkingDir,
					opts.Writers.LogShowAbsPaths,
				),
			)

			return false, nil
		}
	}

	var previousVersion = ""
	// read previous source version
	// https://github.com/gruntwork-io/terragrunt/issues/1921
	if util.FileExists(terraformSource.VersionFile) {
		var err error

		previousVersion, err = readVersionFile(terraformSource)
		if err != nil {
			return false, err
		}
	}

	// When downloading source, we need to process any hooks waiting on `init-from-module`. Therefore, we clone the
	// options struct, set the command to the value the hooks are expecting, and run the download action surrounded by
	// before and after hooks (if any).
	l, optsForDownload, err := opts.CloneWithConfigPath(l, opts.TerragruntConfigPath)
	if err != nil {
		return false, err
	}

	optsForDownload.TerraformCommand = tf.CommandNameInitFromModule

	downloadErr := RunActionWithHooks(
		ctx,
		l,
		"download source",
		optsForDownload,
		cfg,
		r,
		func(childCtx context.Context) error {
			if opts.Experiments.Evaluate(experiment.SlowTaskReporting) {
				sourceURL := strings.TrimPrefix(terraformSource.CanonicalSourceURL.String(), fileURIScheme)

				return util.NotifyIfSlow(childCtx, l, util.SpinnerWriter(), time.Second, util.SlowNotifyMsg{
					Spinner: "Downloading source from " + sourceURL + "...",
					Done:    "Downloaded source from " + sourceURL,
				}, func() error {
					return downloadSource(childCtx, l, terraformSource, opts, cfg, r)
				})
			}

			return downloadSource(childCtx, l, terraformSource, opts, cfg, r)
		},
	)
	if downloadErr != nil {
		return false, DownloadingTerraformSourceErr{ErrMsg: downloadErr, URL: terraformSource.CanonicalSourceURL.String()}
	}

	if err := terraformSource.WriteVersionFile(l); err != nil {
		return false, err
	}

	if err := ValidateWorkingDir(terraformSource); err != nil {
		return false, err
	}

	currentVersion, err := terraformSource.EncodeSourceVersion(l)
	// if source versions are different or calculating version failed, create file to run init
	// https://github.com/gruntwork-io/terragrunt/issues/1921
	if (previousVersion != "" && previousVersion != currentVersion) || err != nil {
		l.Debugf("Requesting re-init, source version has changed from %s to %s recently.", previousVersion, currentVersion)

		initFile := filepath.Join(terraformSource.WorkingDir, ModuleInitRequiredFile)

		f, createErr := os.Create(initFile)
		if createErr != nil {
			return false, createErr
		}

		defer f.Close()
	}

	return true, nil
}

// AlreadyHaveLatestCode returns true if the specified TerraformSource, of the exact same version, has already been downloaded into the
// DownloadFolder. This helps avoid downloading the same code multiple times. Note that if the TerraformSource points
// to a local file path, a hash will be generated from the contents of the source dir. See the ProcessTerraformSource method for more info.
func AlreadyHaveLatestCode(l log.Logger, terraformSource *tf.Source, opts *Options) (bool, error) {
	if !util.FileExists(terraformSource.DownloadDir) ||
		!util.FileExists(terraformSource.WorkingDir) ||
		!util.FileExists(terraformSource.VersionFile) {
		return false, nil
	}

	hasFiles, err := util.DirContainsTFFiles(terraformSource.WorkingDir)
	if err != nil {
		return false, errors.New(err)
	}

	if !hasFiles {
		l.Debugf("Working dir %s exists but contains no Terraform or OpenTofu files, so assuming code needs to be downloaded again.", terraformSource.WorkingDir)
		return false, nil
	}

	currentVersion, err := terraformSource.EncodeSourceVersion(l)
	// If we fail to calculate the source version (e.g. because walking the
	// directory tree failed) use a random version instead, bypassing the cache.
	if err != nil {
		currentVersion, err = util.GenerateRandomSha256()
		if err != nil {
			return false, err
		}
	}

	previousVersion, err := readVersionFile(terraformSource)
	if err != nil {
		return false, err
	}

	return previousVersion == currentVersion, nil
}

// Return the version number stored in the DownloadDir. This version number can be used to check if the Terraform code
// that has already been downloaded is the same as the version the user is currently requesting. The version number is
// calculated using the encodeSourceVersion method.
func readVersionFile(terraformSource *tf.Source) (string, error) {
	return util.ReadFileAsString(terraformSource.VersionFile)
}

// UpdateGetters returns the customized go-getter interfaces that Terragrunt relies on. Specifically:
//   - Local file path getter is updated to copy the files instead of creating symlinks, which is what go-getter defaults
//     to.
//   - Include the customized getter for fetching sources from the Terraform Registry.
//
// This creates a closure that returns a function so that we have access to the terragrunt configuration, which is
// necessary for customizing the behavior of the file getter.
func UpdateGetters(l log.Logger, opts *Options, cfg *runcfg.RunConfig) func(*getter.Client) error {
	return func(client *getter.Client) error {
		// We iterate over the global getter.Getters map and clone each getter
		// to avoid race conditions. The global map contains shared getter
		// instances, and when SetClient is called on them from multiple
		// goroutines, it causes data races. Cloning via dereference ensures
		// each client has its own getter state, while automatically picking
		// up any new getter types registered by go-getter.
		client.Getters = make(map[string]getter.Getter, len(getter.Getters))
		for name, g := range getter.Getters {
			v := reflect.ValueOf(g).Elem()
			clone := reflect.New(v.Type())
			clone.Elem().Set(v)
			client.Getters[name] = clone.Interface().(getter.Getter)
		}

		// Override with Terragrunt-specific customizations
		client.Getters["file"] = &FileCopyGetter{
			Logger:          l,
			IncludeInCopy:   cfg.Terraform.IncludeInCopy,
			ExcludeFromCopy: cfg.Terraform.ExcludeFromCopy,
			FastCopy:        isFastCopyEnabled(opts.StrictControls),
		}
		client.Getters["http"] = &getter.HttpGetter{Netrc: true}
		client.Getters["https"] = &getter.HttpGetter{Netrc: true}

		// Load in custom getters that are only supported in Terragrunt
		client.Getters["tfr"] = tf.NewRegistryGetter(l).
			WithTofuImplementation(opts.TofuImplementation)

		return nil
	}
}

// preserveSymlinksOption is a custom client option that ensures DisableSymlinks
// setting is preserved during git operations
func preserveSymlinksOption() getter.ClientOption {
	return func(c *getter.Client) error {
		// Create a custom git getter that preserves symlink settings
		if c.Getters != nil {
			if _, exists := c.Getters["git"]; exists {
				// Replace with a wrapper that preserves symlink settings.
				// We create a fresh GitGetter instance instead of wrapping the
				// existing one to avoid race conditions when multiple goroutines
				// share the same getter from the global getter.Getters map.
				c.Getters["git"] = &symlinkPreservingGitGetter{
					original: &getter.GitGetter{},
					client:   c,
				}
			}
		}

		// Ensure DisableSymlinks is set to false
		c.DisableSymlinks = false

		return nil
	}
}

// Download the code from the Canonical Source URL into the Download Folder using the go-getter library
func downloadSource(
	ctx context.Context,
	l log.Logger,
	src *tf.Source,
	opts *Options,
	cfg *runcfg.RunConfig,
	r *report.Report,
) error {
	canonicalSourceURL := src.CanonicalSourceURL.String()

	// Since we convert abs paths to rel in logs, `file://../../path/to/dir` doesn't look good,
	// so it's better to get rid of it.
	canonicalSourceURL = strings.TrimPrefix(canonicalSourceURL, fileURIScheme)

	l.Infof(
		"Downloading Terraform configurations from %s into %s",
		util.RelPathForLog(opts.RootWorkingDir, canonicalSourceURL, opts.Writers.LogShowAbsPaths),
		util.RelPathForLog(opts.RootWorkingDir, src.DownloadDir, opts.Writers.LogShowAbsPaths))

	allowCAS := opts.Experiments.Evaluate(experiment.CAS) && !opts.NoCAS

	if cfg.Terraform.UpdateSourceWithCAS && !allowCAS {
		return errors.New(&cas.UpdateSourceWithCASRequiresCASError{
			BlockType: "terraform",
			Path:      opts.TerragruntConfigPath,
		})
	}

	isLocalSource := tf.IsLocalSource(src.CanonicalSourceURL)

	if allowCAS && !isLocalSource {
		l.Debugf("CAS experiment enabled: attempting to use Content Addressable Storage for source: %s", canonicalSourceURL)

		if err := cas.ValidateCASCloneDepth(opts.CASCloneDepth); err != nil {
			return err
		}

		c, err := cas.New(cas.WithCloneDepth(opts.CASCloneDepth))
		if err != nil {
			l.Warnf("Failed to initialize CAS: %v. Falling back to standard getter.", err)
		} else {
			cloneOpts := cas.CloneOptions{
				Dir:              src.DownloadDir,
				IncludedGitFiles: []string{"HEAD", "config"},
			}

			casGetter := cas.NewCASGetter(l, c, &cloneOpts)
			casProtocol := cas.NewCASProtocolGetter(l, c)

			// Use go-getter v2 Client to properly process the Request.
			// CASProtocolGetter handles cas::sha1:<hash> sources (from CAS-rewritten stacks).
			// CASGetter handles git:: and other remote sources via CAS.
			client := getterv2.Client{
				Getters: []getterv2.Getter{casProtocol, casGetter},
			}

			// Set Pwd to the working directory so go-getter v2 can resolve relative paths
			req := &getterv2.Request{
				Src: src.CanonicalSourceURL.String(),
				Dst: src.DownloadDir,
				Pwd: opts.WorkingDir,
			}

			if _, casErr := client.Get(ctx, req); casErr == nil {
				l.Debugf("Successfully downloaded source using CAS: %s", canonicalSourceURL)
				return nil
			} else {
				l.Warnf("CAS download failed: %v. Falling back to standard getter.", casErr)
			}
		}
	}

	// Fallback to standard go-getter
	return opts.RunWithErrorHandling(ctx, l, r, func() error {
		return getter.GetAny(src.DownloadDir, src.CanonicalSourceURL.String(), UpdateGetters(l, opts, cfg), preserveSymlinksOption())
	})
}

// ValidateWorkingDir checks if working terraformSource.WorkingDir exists and is a directory
func ValidateWorkingDir(terraformSource *tf.Source) error {
	workingLocalDir := strings.ReplaceAll(terraformSource.WorkingDir, terraformSource.DownloadDir+filepath.FromSlash("/"), "")
	if util.IsFile(terraformSource.WorkingDir) {
		return WorkingDirNotDir{Dir: workingLocalDir, Source: terraformSource.CanonicalSourceURL.String()}
	}

	if !util.IsDir(terraformSource.WorkingDir) {
		return WorkingDirNotFound{Dir: workingLocalDir, Source: terraformSource.CanonicalSourceURL.String()}
	}

	return nil
}

type WorkingDirNotFound struct {
	Source string
	Dir    string
}

func (err WorkingDirNotFound) Error() string {
	return fmt.Sprintf("Working dir %s from source %s does not exist", err.Dir, err.Source)
}

type WorkingDirNotDir struct {
	Source string
	Dir    string
}

func (err WorkingDirNotDir) Error() string {
	return fmt.Sprintf("Valid working dir %s from source %s", err.Dir, err.Source)
}

type DownloadingTerraformSourceErr struct {
	ErrMsg error
	URL    string
}

func (err DownloadingTerraformSourceErr) Error() string {
	return fmt.Sprintf("downloading source url %s\n%v", err.URL, err.ErrMsg)
}

func (err DownloadingTerraformSourceErr) Unwrap() error {
	return err.ErrMsg
}
