package scaffold

import (
	"context"
	"fmt"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"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/shell"
	"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"

	boilerplate_options "github.com/gruntwork-io/boilerplate/options"
	"github.com/gruntwork-io/boilerplate/templates"
	"github.com/gruntwork-io/boilerplate/variables"
	"github.com/gruntwork-io/terragrunt/internal/errors"
	"github.com/gruntwork-io/terragrunt/pkg/options"
	"github.com/gruntwork-io/terratest/modules/files"
	"github.com/hashicorp/go-getter/v2"
)

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 }}
  {{ .Name }} = {{ .DefaultValuePlaceholder }}  # TODO: fill in value
  {{ 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 }}
  # {{ .Name }} = {{ .DefaultValue }}
  {{ 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,
) *boilerplate_options.BoilerplateOptions {
	return &boilerplate_options.BoilerplateOptions{
		TemplateFolder:          templateFolder,
		OutputFolder:            outputFolder,
		OnMissingKey:            boilerplate_options.DefaultMissingKeyAction,
		OnMissingConfig:         boilerplate_options.DefaultMissingConfigAction,
		Vars:                    vars,
		ShellCommandAnswers:     map[string]bool{},
		NoShell:                 terragruntOpts.NoShell,
		NoHooks:                 terragruntOpts.NoHooks,
		NonInteractive:          terragruntOpts.NonInteractive,
		DisableDependencyPrompt: terragruntOpts.NoDependencyPrompt,
	}
}

func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, moduleURL, templateURL string) error {
	// Apply catalog configuration settings, with CLI flags taking precedence
	applyCatalogConfigToScaffold(ctx, l, opts)

	// download remote repo to local
	dirsToClean := make([]string, 0, 1)
	// clean all temp dirs
	defer func() {
		for _, dir := range dirsToClean {
			if err := os.RemoveAll(dir); err != nil {
				l.Warnf("Failed to clean up dir %s: %v", dir, err)
			}
		}
	}()

	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 err
		}

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

	if moduleURL == "" {
		return errors.New(NoModuleURLPassed{})
	}

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

	dirsToClean = append(dirsToClean, tempDir)

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

	// parse module url
	moduleURL, err = parseModuleURL(ctx, l, opts, vars, moduleURL)
	if err != nil {
		return errors.New(err)
	}

	l.Infof("Scaffolding a new Terragrunt module %s to %s", moduleURL, outputDir)

	if _, err := getter.GetAny(ctx, tempDir, moduleURL); err != nil {
		return errors.New(err)
	}

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

	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, opts, templateURL, tempDir)
	if err != nil {
		return errors.New(err)
	}

	// add additional variables
	vars["requiredVariables"] = requiredVariables
	vars["optionalVariables"] = optionalVariables

	vars["sourceUrl"] = moduleURL

	// Only set these if the `vars` map doesn't already have them set
	if _, found := vars[enableRootInclude]; !found {
		vars[enableRootInclude] = !opts.ScaffoldNoIncludeRoot
	} else {
		l.Warnf(
			"The %s variable is already set in the var flag(s). The --%s flag will be ignored.",
			enableRootInclude,
			shared.NoIncludeRootFlagName,
		)
	}

	if _, found := vars[rootFileName]; !found {
		vars[rootFileName] = opts.ScaffoldRootFileName
	} else {
		l.Warnf(
			"The %s variable is already set in the var flag(s). The --%s flag will be ignored.",
			rootFileName,
			shared.NoIncludeRootFlagName,
		)
	}

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

	emptyDep := variables.Dependency{}
	if err := templates.ProcessTemplate(boilerplateOpts, boilerplateOpts, emptyDep); err != nil {
		return errors.New(err)
	}

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

	if err := format.Run(ctx, l, opts); err != nil {
		return errors.New(err)
	}

	l.Info("Scaffolding completed")

	return nil
}

// applyCatalogConfigToScaffold applies catalog configuration settings to scaffold options.
// CLI flags take precedence over config file settings.
func applyCatalogConfigToScaffold(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) {
	catalogCfg, err := config.ReadCatalogConfig(ctx, l, opts)
	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 = 0644
	if err := os.WriteFile(
		filepath.Join(
			boilerplateDir,
			config.DefaultTerragruntConfigPath,
		),
		[]byte(DefaultTerragruntTemplate),
		ownerWriteGlobalReadPerms,
	); err != nil {
		return "", errors.New(err)
	}

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

	return boilerplateDir, nil
}

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

	// Split the processed URL to get the base URL and subfolder
	baseURL, subFolder, err := tf.SplitSourceURL(l, parsedTemplateURL)
	if err != nil {
		return "", errors.New(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, opts, baseURL)
	if err != nil {
		return "", errors.New(err)
	}

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

	l.Infof("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 := getter.GetAny(ctx, templateDir, baseURL.String()); err != nil {
		return "", errors.New(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); os.IsNotExist(err) {
			return "", errors.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,
	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, opts, templateURL, tempDir)
		if err != nil {
			return "", errors.New(err)
		}

		boilerplateDir = tempTemplateDir
	}

	// if boilerplate dir is not found, create one with default template
	if !files.IsExistingDir(boilerplateDir) {
		config, err := config.ReadCatalogConfig(ctx, l, opts)
		if err != nil {
			return "", errors.New(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, opts, config.DefaultTemplate, tempDir)
			if err != nil {
				return "", errors.New(err)
			}

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

			boilerplateDir = defaultTempDir

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

	return boilerplateDir, nil
}

// parseVariables - parse variables from tf files.
func parseVariables(
	l log.Logger,
	opts *options.TerragruntOptions,
	moduleDir string,
) ([]*config.ParsedVariable, []*config.ParsedVariable, error) {
	inputs, err := config.ParseVariables(l, opts, moduleDir)
	if err != nil {
		return nil, nil, errors.New(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,
	opts *options.TerragruntOptions,
	vars map[string]any,
	moduleURL string,
) (string, error) {
	parsedModuleURL, err := tf.ToSourceURL(moduleURL, opts.WorkingDir)
	if err != nil {
		return "", errors.New(err)
	}

	moduleURL = parsedModuleURL.String()

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

	// add ref to module url, if required
	parsedModuleURL, err = addRefToModuleURL(ctx, l, opts, parsedModuleURL, vars)
	if err != nil {
		return "", errors.New(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) {
	var 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, errors.New(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, errors.New(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,
	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, errors.New(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, opts, 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,
	opts *options.TerragruntOptions,
	parsedModuleURL *url.URL,
	vars map[string]any,
) (*url.URL, error) {
	var 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, errors.New(err)
		}

		tag, err := shell.GitLastReleaseTag(ctx, l, opts, 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
}

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."
}
