package scaffold

import (
	"context"
	"fmt"
	"io/fs"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strings"

	"github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/format"
	"github.com/gruntwork-io/terragrunt/internal/cli/flags/shared"
	"github.com/gruntwork-io/terragrunt/internal/configbridge"
	"github.com/gruntwork-io/terragrunt/internal/shell"
	"github.com/gruntwork-io/terragrunt/internal/telemetry"
	"github.com/gruntwork-io/terragrunt/internal/venv"
	"github.com/gruntwork-io/terragrunt/internal/vfs"
	"github.com/gruntwork-io/terragrunt/pkg/config"
	"github.com/gruntwork-io/terragrunt/pkg/log"

	"github.com/gruntwork-io/terragrunt/internal/tf"

	"github.com/gruntwork-io/terragrunt/internal/util"

	"errors"

	"github.com/gruntwork-io/boilerplate/manifest"
	boilerplateoptions "github.com/gruntwork-io/boilerplate/options"
	"github.com/gruntwork-io/boilerplate/templates"
	"github.com/gruntwork-io/boilerplate/variables"
	"github.com/gruntwork-io/terragrunt/internal/getter"
	"github.com/gruntwork-io/terragrunt/pkg/options"
)

const (
	sourceURLTypeHTTPS = "git-https"
	sourceURLTypeGit   = "git-ssh"
	sourceGitSSHUser   = "git"

	sourceURLTypeVar    = "SourceUrlType"
	sourceGitSSHUserVar = "SourceGitSshUser"
	refVar              = "Ref"
	// refParam - ?ref param from url
	refParam = "ref"

	moduleURLPattern = `(?:git|hg|s3|gcs)::([^:]+)://([^/]+)(/.*)`
	moduleURLParts   = 4

	// TODO: Make the root configuration file name configurable
	DefaultBoilerplateConfig = `
variables:
  - name: EnableRootInclude
    description: Should include root module
    type: bool
    default: true
  - name: RootFileName
    description: Name of the root Terragrunt configuration file
    type: string
`
	DefaultTerragruntTemplate = `
# This is a Terragrunt unit generated by Gruntwork Boilerplate (https://github.com/gruntwork-io/boilerplate).
terraform {
  source = "{{ .sourceUrl }}"
}
{{ if .EnableRootInclude }}
include "root" {
  path = find_in_parent_folders("{{ .RootFileName }}")
}
{{ end }}
inputs = {
  # --------------------------------------------------------------------------------------------------------------------
  # Required input variables
  # --------------------------------------------------------------------------------------------------------------------
  {{ range .requiredVariables }}
  {{- if eq 1 (regexSplit "\n" .Description -1 | len ) }}
  # Description: {{ .Description }}
  {{- else }}
  # Description:
    {{- range $line := regexSplit "\n" .Description -1 }}
    # {{ $line | indent 2 }}
    {{- end }}
  {{- end }}
  # Type: {{ .Type }}
  {{- if .UserValue }}
  {{ .Name }} = {{ .UserValue }}
  {{- else }}
  {{ .Name }} = {{ .DefaultValuePlaceholder }}  # TODO: fill in value
  {{- end }}
  {{ end }}

  # --------------------------------------------------------------------------------------------------------------------
  # Optional input variables
  # Uncomment the ones you wish to set
  # --------------------------------------------------------------------------------------------------------------------
  {{ range .optionalVariables }}
  {{- if eq 1 (regexSplit "\n" .Description -1 | len ) }}
  # Description: {{ .Description }}
  {{- else }}
  # Description:
    {{- range $line := regexSplit "\n" .Description -1 }}
    # {{ $line | indent 2 }}
    {{- end }}
  {{- end }}
  # Type: {{ .Type }}
  {{ if .UserValue }}{{ .Name }} = {{ .UserValue }}{{ else }}# {{ .Name }} = {{ .DefaultValue }}{{ end }}
  {{ end }}
}
`
)

var moduleURLRegex = regexp.MustCompile(moduleURLPattern)

const (
	enableRootInclude = "EnableRootInclude"
	rootFileName      = "RootFileName"
)

// NewBoilerplateOptions creates a new BoilerplateOptions struct
func NewBoilerplateOptions(
	templateFolder,
	outputFolder string,
	vars map[string]any,
	terragruntOpts *options.TerragruntOptions,
) *boilerplateoptions.BoilerplateOptions {
	return &boilerplateoptions.BoilerplateOptions{
		TemplateFolder:          templateFolder,
		OutputFolder:            outputFolder,
		OnMissingKey:            boilerplateoptions.DefaultMissingKeyAction,
		OnMissingConfig:         boilerplateoptions.DefaultMissingConfigAction,
		Vars:                    vars,
		ShellCommandAnswers:     map[string]bool{},
		NoShell:                 terragruntOpts.NoShell,
		NoHooks:                 terragruntOpts.NoHooks,
		NonInteractive:          terragruntOpts.NonInteractive,
		DisableDependencyPrompt: terragruntOpts.NoDependencyPrompt,
	}
}

// Plan captures the state of a scaffold preparation: the source module has
// been downloaded, its variables parsed, and the boilerplate template
// located. Generate finishes the render, optionally injecting user-supplied
// HCL fragments via the values argument, and Cleanup removes the temporary
// directories Prepare allocated. Callers must invoke Cleanup exactly once,
// typically via defer.
//
//nolint:govet // field order chosen for readability over alignment
type Plan struct {
	logger            log.Logger
	Required          []*config.ParsedVariable
	Optional          []*config.ParsedVariable
	tempDirs          []string
	vars              map[string]any
	boilerplateDir    string
	originalModuleURL string
	resolvedModuleURL string
	outputDir         string
}

// Cleanup removes the temporary directories allocated during Prepare.
// Safe to call more than once; subsequent calls are no-ops.
func (p *Plan) Cleanup() {
	for _, dir := range p.tempDirs {
		if err := os.RemoveAll(dir); err != nil {
			p.logger.Warnf("Failed to clean up dir %s: %v", dir, err)
		}
	}

	p.tempDirs = nil
}

// Prepare downloads the source module and template, parses the module's
// variable blocks, and returns a Plan ready for rendering. The caller is
// responsible for invoking Plan.Cleanup() (typically via defer) once it has
// either rendered the result via Generate or decided to abandon the work.
// v is the virtualized environment threaded through the module download and
// boilerplate preparation.
func Prepare(
	ctx context.Context,
	l log.Logger,
	v *venv.Venv,
	opts *options.TerragruntOptions,
	moduleURL, templateURL string,
) (*Plan, error) {
	// Apply catalog configuration settings, with CLI flags taking precedence
	applyCatalogConfigToScaffold(ctx, l, v, opts)

	outputDir := opts.ScaffoldOutputFolder
	if outputDir == "" {
		outputDir = opts.WorkingDir
	}

	// scaffold only in empty directories
	if empty, err := util.IsDirectoryEmpty(opts.WorkingDir); !empty || err != nil {
		if err != nil {
			return nil, err
		}

		l.Warnf("The working directory %s is not empty.", opts.WorkingDir)
	}

	if moduleURL == "" {
		return nil, NoModuleURLPassed{}
	}

	moduleURL = tf.RewriteLegacyGCSPublicSource(ctx, l, moduleURL, opts.StrictControls)
	templateURL = tf.RewriteLegacyGCSPublicSource(ctx, l, templateURL, opts.StrictControls)

	// create temporary directory where to download module
	tempDir, err := os.MkdirTemp("", "scaffold")
	if err != nil {
		return nil, err
	}

	plan := &Plan{
		logger:    l,
		outputDir: outputDir,
		tempDirs:  []string{tempDir},
	}

	// On any error after this point, clean up the temp dir we just created
	// so a partial Prepare doesn't leak.
	success := false

	defer func() {
		if !success {
			plan.Cleanup()
		}
	}()

	// prepare variables
	vars, err := variables.ParseVars(opts.ScaffoldVars, opts.ScaffoldVarFiles)
	if err != nil {
		return nil, err
	}

	// Save the original URL before go-getter transformation so the scaffolded
	// source attribute matches the catalog config format.
	plan.originalModuleURL = moduleURL

	// parse module url (transforms for go-getter download)
	resolvedURL, err := parseModuleURL(ctx, l, v, opts, vars, moduleURL)
	if err != nil {
		return nil, err
	}

	plan.resolvedModuleURL = resolvedURL

	l.Debugf("Scaffolding a new Terragrunt module %s to %s", resolvedURL, outputDir)

	if err := telemetry.TelemeterFromContext(ctx).
		Collect(ctx, l, "scaffold_get_module", map[string]any{
			"module_url": resolvedURL,
		}, func(ctx context.Context, l log.Logger) error {
			if _, getErr := getter.GetAny(ctx, tempDir, resolvedURL); getErr != nil {
				return fmt.Errorf("downloading scaffold module from %s: %w", resolvedURL, getErr)
			}

			return nil
		}); err != nil {
		return nil, err
	}

	// extract variables from downloaded module
	requiredVariables, optionalVariables, err := parseVariables(l, v.FS, opts, tempDir)
	if err != nil {
		return nil, err
	}

	plan.Required = requiredVariables
	plan.Optional = optionalVariables

	l.Debugf(
		"Parsed %d required variables and %d optional variables",
		len(requiredVariables),
		len(optionalVariables),
	)

	// prepare boilerplate files to render Terragrunt files
	boilerplateDir, err := prepareBoilerplateFiles(ctx, l, v, opts, templateURL, tempDir)
	if err != nil {
		return nil, err
	}

	plan.boilerplateDir = boilerplateDir
	plan.vars = vars

	success = true

	return plan, nil
}

// Generate renders the prepared scaffold into the output directory. When
// values is non-nil, each entry replaces the matching parsed variable's
// UserValue, so the template emits a real HCL assignment instead of the
// `# TODO: fill in value` placeholder. Variables not present in values
// fall through to the placeholder line.
func (p *Plan) Generate(
	ctx context.Context,
	l log.Logger,
	v *venv.Venv,
	opts *options.TerragruntOptions,
	values map[string]string,
) error {
	applyUserValues(p.Required, values)
	applyUserValues(p.Optional, values)

	// add additional variables
	p.vars["requiredVariables"] = p.Required
	p.vars["optionalVariables"] = p.Optional

	// Build sourceUrl from the original URL with the ref that parseModuleURL resolved.
	p.vars["sourceUrl"] = BuildSourceURL(p.originalModuleURL, p.resolvedModuleURL, p.vars)

	// Only set these if the `vars` map doesn't already have them set
	setVarDefault(
		l,
		p.vars,
		enableRootInclude,
		!opts.ScaffoldNoIncludeRoot,
		shared.NoIncludeRootFlagName,
	)
	setVarDefault(l, p.vars, rootFileName, opts.ScaffoldRootFileName, shared.RootFileNameFlagName)

	l.Debugf("Running boilerplate generation to %s", p.outputDir)
	boilerplateOpts := NewBoilerplateOptions(p.boilerplateDir, p.outputDir, p.vars, opts)

	emptyDep := &variables.Dependency{}

	result, err := templates.ProcessTemplateWithContext(
		ctx,
		l,
		boilerplateOpts,
		boilerplateOpts,
		emptyDep,
	)
	if err != nil {
		return err
	}

	depFiles, err := collectDependencyFiles(result.Dependencies, 0)
	if err != nil {
		return err
	}

	allFiles := slices.Concat(result.GeneratedFiles, depFiles)

	for i, f := range allFiles {
		allFiles[i] = filepath.Clean(f)
	}

	allFiles = slices.Compact(slices.Sorted(slices.Values(allFiles)))

	l.Debugf("Running fmt on generated code %s", p.outputDir)

	if err := format.RunForFiles(ctx, l, v, opts, p.outputDir, allFiles); err != nil {
		return err
	}

	l.Debug("Scaffolding completed")

	return nil
}

// setVarDefault writes value to vars[key] when the key is absent; if
// it's already set, it logs a warning that flagName will be ignored.
func setVarDefault(l log.Logger, vars map[string]any, key string, value any, flagName string) {
	if _, found := vars[key]; found {
		l.Warnf(
			"The %s variable is already set in the var flag(s). The --%s flag will be ignored.",
			key,
			flagName,
		)

		return
	}

	vars[key] = value
}

// applyUserValues copies non-empty entries from values onto the matching
// ParsedVariable.UserValue, so the template emits the supplied HCL fragment
// instead of the TODO placeholder line. Empty values are treated as
// "not supplied" and leave the existing UserValue untouched.
func applyUserValues(vars []*config.ParsedVariable, values map[string]string) {
	if len(values) == 0 {
		return
	}

	for _, v := range vars {
		val, ok := values[v.Name]
		if !ok || val == "" {
			continue
		}

		v.UserValue = val
	}
}

// Run downloads the source module, discovers its variables, and renders a
// terragrunt.hcl with `# TODO: fill in value` placeholders for every input.
// It is the non-interactive entry point used by the CLI scaffold command and
// the catalog TUI's `S` (placeholder) keybind.
func Run(
	ctx context.Context,
	l log.Logger,
	v *venv.Venv,
	opts *options.TerragruntOptions,
	moduleURL, templateURL string,
) error {
	plan, err := Prepare(ctx, l, v, opts, moduleURL, templateURL)
	if err != nil {
		return err
	}

	defer plan.Cleanup()

	return plan.Generate(ctx, l, v, opts, nil)
}

// BuildSourceURL returns the original module URL with the ref query param
// from the resolved URL appended, so the scaffolded source preserves the
// user's original URL format while including the resolved version tag.
//
// When vars sets SourceUrlType to git-ssh, the resolved URL is returned
// instead so that the scaffolded source carries the Git/SSH rewrite that
// rewriteModuleURL applied.
func BuildSourceURL(originalURL, resolvedURL string, vars map[string]any) string {
	if value, found := vars[sourceURLTypeVar]; found &&
		fmt.Sprintf("%s", value) == sourceURLTypeGit {
		return resolvedURL
	}

	refVal := ExtractQueryParam(resolvedURL, refParam)
	if refVal == "" {
		return originalURL
	}

	base, rawQuery := splitURLQuery(originalURL)

	params, err := url.ParseQuery(rawQuery)
	if err != nil || params.Has(refParam) {
		return originalURL
	}

	params.Set(refParam, refVal)

	return base + "?" + params.Encode()
}

// ExtractQueryParam extracts a query parameter value from a URL string.
// It splits on the last "?" to find the query portion, avoiding issues with
// go-getter prefixes (e.g. "git::") that confuse url.Parse.
func ExtractQueryParam(rawURL, param string) string {
	_, rawQuery := splitURLQuery(rawURL)

	params, err := url.ParseQuery(rawQuery)
	if err != nil {
		return ""
	}

	return params.Get(param)
}

// splitURLQuery splits a raw URL at the last "?" into the base and query
// string. Go-getter URLs may contain "::" prefixes that prevent full URL
// parsing, but the query string is always after the final "?".
func splitURLQuery(rawURL string) (string, string) {
	if idx := strings.LastIndex(rawURL, "?"); idx >= 0 {
		return rawURL[:idx], rawURL[idx+1:]
	}

	return rawURL, ""
}

// applyCatalogConfigToScaffold applies catalog configuration settings to scaffold options.
// CLI flags take precedence over config file settings.
func applyCatalogConfigToScaffold(
	ctx context.Context,
	l log.Logger,
	v *venv.Venv,
	opts *options.TerragruntOptions,
) {
	_, pctx := configbridge.NewParsingContext(ctx, l, opts)
	pctx = pctx.WithVenv(v)

	catalogCfg, err := config.ReadCatalogConfig(ctx, l, pctx)
	if err != nil {
		// Don't fail if catalog config can't be read - it's optional
		l.Debugf("Could not read catalog config for scaffold: %v", err)
		return
	}

	if catalogCfg == nil {
		return
	}

	// Apply config settings only if CLI flags weren't explicitly set
	// Since both NoShell and NoHooks default to false, we apply the config value
	// only if it's true (enabling the restriction)
	if catalogCfg.NoShell != nil && *catalogCfg.NoShell && !opts.NoShell {
		l.Debugf("Applying catalog config: no_shell = true")

		opts.NoShell = true
	}

	if catalogCfg.NoHooks != nil && *catalogCfg.NoHooks && !opts.NoHooks {
		l.Debugf("Applying catalog config: no_hooks = true")

		opts.NoHooks = true
	}
}

// generateDefaultTemplate - write default template to provided dir
func generateDefaultTemplate(boilerplateDir string) (string, error) {
	const ownerWriteGlobalReadPerms = 0o644
	if err := os.WriteFile(
		filepath.Join(
			boilerplateDir,
			config.DefaultTerragruntConfigPath,
		),
		[]byte(DefaultTerragruntTemplate),
		ownerWriteGlobalReadPerms,
	); err != nil {
		return "", err
	}

	if err := os.WriteFile(
		filepath.Join(
			boilerplateDir,
			"boilerplate.yml",
		),
		[]byte(DefaultBoilerplateConfig),
		ownerWriteGlobalReadPerms,
	); err != nil {
		return "", err
	}

	return boilerplateDir, nil
}

// downloadTemplate - parse URL, download files, and handle subfolders
func downloadTemplate(
	ctx context.Context,
	l log.Logger,
	v *venv.Venv,
	opts *options.TerragruntOptions,
	templateURL,
	tempDir string,
) (string, error) {
	parsedTemplateURL, err := tf.ToSourceURL(templateURL, tempDir)
	if err != nil {
		return "", err
	}

	// Split the processed URL to get the base URL and subfolder
	baseURL, subFolder, err := tf.SplitSourceURL(l, parsedTemplateURL)
	if err != nil {
		return "", err
	}

	// Go-getter expects a pathspec or . for file paths
	if baseURL.Scheme == "" || baseURL.Scheme == "file" {
		baseURL.Path = filepath.ToSlash(strings.TrimSuffix(baseURL.Path, "/")) + "//."
	}

	baseURL, err = rewriteTemplateURL(ctx, l, v, opts, baseURL)
	if err != nil {
		return "", err
	}

	templateDir, err := os.MkdirTemp(tempDir, "template")
	if err != nil {
		return "", err
	}

	l.Debugf("Downloading template from %s into %s", baseURL.String(), templateDir)
	// Downloading baseURL to support boilerplate dependencies and partials.
	// Go-getter discards all but specified folder if one is provided.
	if err := telemetry.TelemeterFromContext(ctx).
		Collect(ctx, l, "scaffold_get_template", map[string]any{
			"template_url": baseURL.String(),
		}, func(ctx context.Context, l log.Logger) error {
			if _, getErr := getter.GetAny(ctx, templateDir, baseURL.String()); getErr != nil {
				return fmt.Errorf(
					"downloading scaffold template from %s: %w",
					baseURL.String(),
					getErr,
				)
			}

			return nil
		}); err != nil {
		return "", err
	}

	// Add subfolder to templateDir if provided, as scaffold needs path to boilerplate.yml file
	if subFolder != "" {
		subFolder = strings.TrimPrefix(subFolder, "/")
		templateDir = filepath.Join(templateDir, subFolder)
		// Verify that subfolder exists
		if _, err := os.Stat(templateDir); errors.Is(err, fs.ErrNotExist) {
			return "", fmt.Errorf(
				"subfolder \"//%s\" not found in downloaded template from %s",
				subFolder,
				templateURL,
			)
		}
	}

	return templateDir, nil
}

// prepareBoilerplateFiles - prepare boilerplate files from provided template, tf module, or (custom) default template
func prepareBoilerplateFiles(
	ctx context.Context,
	l log.Logger,
	v *venv.Venv,
	opts *options.TerragruntOptions,
	templateURL,
	tempDir string,
) (string, error) {
	boilerplateDir := filepath.Join(tempDir, util.DefaultBoilerplateDir)

	// process template url if it was passed. This overrides the .boilerplate folder in the OpenTofu/Terraform module
	if templateURL != "" {
		// process template url if it was passed
		tempTemplateDir, err := downloadTemplate(ctx, l, v, opts, templateURL, tempDir)
		if err != nil {
			return "", err
		}

		boilerplateDir = tempTemplateDir
	}

	// if boilerplate dir is not found, create one with default template
	if !util.IsDir(boilerplateDir) {
		_, pctx := configbridge.NewParsingContext(ctx, l, opts)
		pctx = pctx.WithVenv(v)

		config, err := config.ReadCatalogConfig(ctx, l, pctx)
		if err != nil {
			return "", err
		}

		// use defaultTemplateURL if defined in config, otherwise use basic default template
		if config != nil && config.DefaultTemplate != "" {
			// process template url if available
			tempTemplateDir, err := downloadTemplate(
				ctx,
				l,
				v,
				opts,
				config.DefaultTemplate,
				tempDir,
			)
			if err != nil {
				return "", err
			}

			boilerplateDir = tempTemplateDir
		} else {
			defaultTempDir, err := os.MkdirTemp(tempDir, "boilerplate")
			if err != nil {
				return "", err
			}

			boilerplateDir = defaultTempDir

			boilerplateDir, err = generateDefaultTemplate(boilerplateDir)
			if err != nil {
				return "", err
			}
		}
	}

	return boilerplateDir, nil
}

// parseVariables - parse variables from tf files.
func parseVariables(
	l log.Logger,
	fsys vfs.FS,
	opts *options.TerragruntOptions,
	moduleDir string,
) ([]*config.ParsedVariable, []*config.ParsedVariable, error) {
	inputs, err := config.ParseVariables(l, fsys, opts.StrictControls, moduleDir)
	if err != nil {
		return nil, nil, err
	}

	// separate variables that require value and with default value
	var (
		requiredVariables []*config.ParsedVariable
		optionalVariables []*config.ParsedVariable
	)

	for _, value := range inputs {
		if value.DefaultValue == "" {
			requiredVariables = append(requiredVariables, value)
		} else {
			optionalVariables = append(optionalVariables, value)
		}
	}

	return requiredVariables, optionalVariables, nil
}

// parseModuleURL - parse module url and rewrite it if required
func parseModuleURL(
	ctx context.Context,
	l log.Logger,
	v *venv.Venv,
	opts *options.TerragruntOptions,
	vars map[string]any,
	moduleURL string,
) (string, error) {
	parsedModuleURL, err := tf.ToSourceURL(moduleURL, opts.WorkingDir)
	if err != nil {
		return "", err
	}

	moduleURL = parsedModuleURL.String()

	// rewrite module url, if required
	parsedModuleURL, err = rewriteModuleURL(l, opts, vars, moduleURL)
	if err != nil {
		return "", err
	}

	// add ref to module url, if required
	parsedModuleURL, err = addRefToModuleURL(ctx, l, v, opts, parsedModuleURL, vars)
	if err != nil {
		return "", err
	}

	// regenerate module url with all changes
	return parsedModuleURL.String(), nil
}

// rewriteModuleURL rewrites module url to git ssh if required
// github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs =>
// git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs
func rewriteModuleURL(
	l log.Logger,
	opts *options.TerragruntOptions,
	vars map[string]any,
	moduleURL string,
) (*url.URL, error) {
	updatedModuleURL := moduleURL

	sourceURLType := sourceURLTypeHTTPS
	if value, found := vars[sourceURLTypeVar]; found {
		sourceURLType = fmt.Sprintf("%s", value)
	}

	// expand module url
	parsedValue, err := parseURL(l, moduleURL)
	if err != nil {
		l.Warnf("Failed to parse module url %s", moduleURL)

		parsedModuleURL, err := tf.ToSourceURL(updatedModuleURL, opts.WorkingDir)
		if err != nil {
			return nil, err
		}

		return parsedModuleURL, nil
	}
	// try to rewrite module url if is https and is requested to be git
	// git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs =>
	// git::ssh://git@github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs
	if parsedValue.scheme == "https" && sourceURLType == sourceURLTypeGit {
		gitUser := sourceGitSSHUser
		if value, found := vars[sourceGitSSHUserVar]; found {
			gitUser = fmt.Sprintf("%s", value)
		}

		path := strings.TrimPrefix(parsedValue.path, "/")
		updatedModuleURL = fmt.Sprintf("%s@%s:%s", gitUser, parsedValue.host, path)
	}

	// persist changes in url.URL
	parsedModuleURL, err := tf.ToSourceURL(updatedModuleURL, opts.WorkingDir)
	if err != nil {
		return nil, err
	}

	return parsedModuleURL, nil
}

// rewriteTemplateURL rewrites template url with reference to tag
// github.com/denis256/terragrunt-tests.git//scaffold/base-template =>
// github.com/denis256/terragrunt-tests.git//scaffold/base-template?ref=v0.53.8
func rewriteTemplateURL(
	ctx context.Context,
	l log.Logger,
	v *venv.Venv,
	opts *options.TerragruntOptions,
	parsedTemplateURL *url.URL,
) (*url.URL, error) {
	var (
		updatedTemplateURL = parsedTemplateURL
		templateParams     = updatedTemplateURL.Query()
	)

	ref := templateParams.Get(refParam)
	if ref == "" {
		rootSourceURL, _, err := tf.SplitSourceURL(l, updatedTemplateURL)
		if err != nil {
			return nil, err
		}

		if rootSourceURL.Scheme == "" || rootSourceURL.Scheme == "file" {
			l.Debugf("Skipping git tag lookup for local template path: %s", rootSourceURL)
			return updatedTemplateURL, nil
		}

		tag, err := shell.GitLastReleaseTag(ctx, l, v, opts.WorkingDir, rootSourceURL)
		if err != nil || tag == "" {
			l.Warnf(
				"Failed to find last release tag for URL %s, so will not add a ref param to the URL",
				rootSourceURL,
			)
		} else {
			templateParams.Add(refParam, tag)
			updatedTemplateURL.RawQuery = templateParams.Encode()
		}
	}

	return updatedTemplateURL, nil
}

// addRefToModuleURL adds ref to module url if is passed through variables or find it from git tags
func addRefToModuleURL(
	ctx context.Context,
	l log.Logger,
	v *venv.Venv,
	opts *options.TerragruntOptions,
	parsedModuleURL *url.URL,
	vars map[string]any,
) (*url.URL, error) {
	moduleURL := parsedModuleURL
	// append ref to source url, if is passed through variables or find it from git tags
	params := moduleURL.Query()

	refReplacement, refVarPassed := vars[refVar]
	if refVarPassed {
		params.Set(refParam, fmt.Sprintf("%s", refReplacement))
		moduleURL.RawQuery = params.Encode()
	}

	ref := params.Get(refParam)
	if ref == "" {
		// if ref is not passed, find last release tag
		// git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs =>
		// git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs?ref=v0.53.8
		rootSourceURL, _, err := tf.SplitSourceURL(l, moduleURL)
		if err != nil {
			return nil, err
		}

		tag, err := shell.GitLastReleaseTag(ctx, l, v, opts.WorkingDir, rootSourceURL)
		if err != nil || tag == "" {
			l.Warnf("Failed to find last release tag for %s", rootSourceURL)
		} else {
			params.Add(refParam, tag)
			moduleURL.RawQuery = params.Encode()
		}
	}

	return moduleURL, nil
}

// parseURL parses module url to scheme, host and path
func parseURL(l log.Logger, moduleURL string) (*parsedURL, error) {
	matches := moduleURLRegex.FindStringSubmatch(moduleURL)
	if len(matches) != moduleURLParts {
		l.Warnf("Failed to parse url %s", moduleURL)
		return nil, failedToParseURLError{}
	}

	return &parsedURL{
		scheme: matches[1],
		host:   matches[2],
		path:   matches[3],
	}, nil
}

type parsedURL struct {
	scheme string
	host   string
	path   string
}

const maxDependencyDepth = 100

// collectDependencyFiles recursively collects file paths from all boilerplate
// dependencies and their nested sub-dependencies up to maxDependencyDepth.
func collectDependencyFiles(deps []manifest.ManifestDependency, depth int) ([]string, error) {
	if depth >= maxDependencyDepth {
		return nil, MaxDependencyDepthExceededError{}
	}

	var files []string

	for i := range deps {
		for _, f := range deps[i].Files {
			files = append(files, f.Path)
		}

		subFiles, err := collectDependencyFiles(deps[i].Dependencies, depth+1)
		if err != nil {
			return nil, err
		}

		files = append(files, subFiles...)
	}

	return files, nil
}

type MaxDependencyDepthExceededError struct{}

func (err MaxDependencyDepthExceededError) Error() string {
	return fmt.Sprintf(
		"dependency depth limit of %d exceeded, possible circular dependencies",
		maxDependencyDepth,
	)
}

type failedToParseURLError struct{}

func (err failedToParseURLError) Error() string {
	return "Failed to parse Url."
}

type NoModuleURLPassed struct{}

func (err NoModuleURLPassed) Error() string {
	return "No module URL passed."
}
