package runcfg

import (
	"fmt"
	"net/url"
	"path/filepath"
	"regexp"
	"slices"
	"strings"

	"github.com/zclconf/go-cty/cty"

	"github.com/gruntwork-io/terragrunt/internal/ctyhelper"
	"github.com/gruntwork-io/terragrunt/internal/engine"
	"github.com/gruntwork-io/terragrunt/internal/errorconfig"
	"github.com/gruntwork-io/terragrunt/internal/getter"
	"github.com/gruntwork-io/terragrunt/internal/iam"
	"github.com/gruntwork-io/terragrunt/internal/tf"
	"github.com/gruntwork-io/terragrunt/internal/util"
	"github.com/gruntwork-io/terragrunt/pkg/log"
)

// DefaultEngineType is the default engine type.
const DefaultEngineType = "rpc"

// CopyLockFile copies the lock file from the source folder to the destination folder.
//
// Terraform 0.14 now generates a lock file when you run `terraform init`.
// If any such file exists, this function will copy the lock file to the destination folder.
func CopyLockFile(
	l log.Logger, rootWorkingDir string, logShowAbsPaths bool, sourceFolder, destinationFolder string,
) error {
	sourceLockFilePath := filepath.Join(sourceFolder, tf.TerraformLockFile)
	destinationLockFilePath := filepath.Join(destinationFolder, tf.TerraformLockFile)

	if util.FileExists(sourceLockFilePath) {
		l.Debugf(
			"Copying lock file from %s to %s",
			util.RelPathForLog(
				rootWorkingDir,
				sourceLockFilePath,
				logShowAbsPaths,
			),
			util.RelPathForLog(
				rootWorkingDir,
				destinationLockFilePath,
				logShowAbsPaths,
			),
		)

		return util.CopyFile(sourceLockFilePath, destinationLockFilePath)
	}

	return nil
}

// GetTerraformSourceURL returns the source URL for OpenTofu/Terraform configuration.
//
// There are two ways a user can tell Terragrunt that it needs to download Terraform configurations from a specific
// URL: via a command-line option or via an entry in the Terragrunt configuration. If the user used one of these, this
// method returns the source URL. If neither is specified, returns "." to indicate the current directory should be
// used as the source, ensuring a .terragrunt-cache directory is always created for consistency.
func GetTerraformSourceURL(
	source string, sourceMap map[string]string, originalConfigPath string, cfg *RunConfig,
) (string, error) {
	switch {
	case source != "":
		return source, nil
	case cfg != nil && cfg.Terraform.Source != "":
		return AdjustSourceWithMap(sourceMap, cfg.Terraform.Source, originalConfigPath)
	default:
		return ".", nil
	}
}

// AdjustSourceWithMap implements the --terragrunt-source-map feature. This function will check if the URL portion of a
// terraform source matches any entry in the provided source map and if it does, replace it with the configured source
// in the map. Note that this only performs literal matches with the URL portion.
//
// Example:
// Suppose terragrunt is called with:
//
//	--terragrunt-source-map git::ssh://git@github.com/gruntwork-io/i-dont-exist.git=/path/to/local-modules
//
// and the terraform source is:
//
//	git::ssh://git@github.com/gruntwork-io/i-dont-exist.git//fixtures/source-map/modules/app?ref=master
//
// This function will take that source and transform it to:
//
//	/path/to/local-modules//fixtures/source-map/modules/app
func AdjustSourceWithMap(sourceMap map[string]string, source string, modulePath string) (string, error) {
	// Skip logic if source map is not configured
	if len(sourceMap) == 0 {
		return source, nil
	}

	// Split the module source string into a valid URL and subdirectory (if // is present).
	moduleURL, moduleSubdir := getter.SourceDirSubdir(source)

	// if both URL and subdir are missing, something went terribly wrong
	if moduleURL == "" && moduleSubdir == "" {
		return "", InvalidSourceURLWithMapError{ModulePath: modulePath, ModuleSourceURL: source}
	}

	// If module URL is missing, return the source as is as it will not match anything in the map.
	if moduleURL == "" {
		return source, nil
	}

	// Before looking up in sourceMap, make sure to drop any query parameters.
	moduleURLParsed, err := url.Parse(moduleURL)
	if err != nil {
		return source, err
	}

	moduleURLParsed.RawQuery = ""
	moduleURLQuery := moduleURLParsed.String()

	// Check if there is an entry to replace the URL portion in the map. Return the source as is if there is no entry in
	// the map.
	sourcePath, hasKey := sourceMap[moduleURLQuery]
	if !hasKey {
		return source, nil
	}

	// Since there is a source mapping, replace the module URL portion with the entry in the map, and join with the
	// subdir.
	// If subdir is missing, check if we can obtain a valid module name from the URL portion.
	if moduleSubdir == "" {
		moduleSubdirFromURL, err := GetModulePathFromSourceURL(moduleURL)
		if err != nil {
			return moduleSubdirFromURL, err
		}

		moduleSubdir = moduleSubdirFromURL
	}

	return util.JoinTerraformModulePath(sourcePath, moduleSubdir), nil
}

// InvalidSourceURLWithMapError is an error type for invalid source URLs when using source map.
type InvalidSourceURLWithMapError struct {
	ModulePath      string
	ModuleSourceURL string
}

func (err InvalidSourceURLWithMapError) Error() string {
	return fmt.Sprintf(
		"The --source-map parameter was passed in, but the source URL in the module at '%s' is invalid: '%s'."+
			" Note that the module URL must have a double-slash to separate the repo URL from the path within the repo!",
		err.ModulePath, err.ModuleSourceURL,
	)
}

// ParsingModulePathError is an error type for when module path cannot be parsed from source URL.
type ParsingModulePathError struct {
	ModuleSourceURL string
}

func (err ParsingModulePathError) Error() string {
	return fmt.Sprintf(
		"Unable to obtain the module path from the source URL '%s'."+
			" Ensure that the URL is in a supported format.",
		err.ModuleSourceURL,
	)
}

// Regexp for module name extraction. It assumes that the query string has already been stripped off.
// Then we simply capture anything after the last slash, and before `.` or end of string.
var moduleNameRegexp = regexp.MustCompile(`(?:.+/)(.+?)(?:\.|$)`)

// GetModulePathFromSourceURL parses sourceUrl not containing '//', and attempt to obtain a module path.
// Example:
//
// sourceUrl = "git::ssh://git@ghe.ourcorp.com/OurOrg/module-name.git"
// will return "module-name".
func GetModulePathFromSourceURL(sourceURL string) (string, error) {
	// strip off the query string if present
	sourceURL = strings.Split(sourceURL, "?")[0]

	matches := moduleNameRegexp.FindStringSubmatch(sourceURL)

	// if regexp returns less/more than the full match + 1 capture group,
	// then something went wrong with regex (invalid source string)
	const matchedPats = 2
	if len(matches) != matchedPats {
		return "", ParsingModulePathError{ModuleSourceURL: sourceURL}
	}

	return matches[1], nil
}

// EngineOptions fetches engine options from the RunConfig.
func (cfg *RunConfig) EngineOptions() (*engine.EngineConfig, error) {
	if !cfg.Engine.Enable {
		return nil, nil
	}

	// in case of Meta is null, set empty meta
	meta := map[string]any{}

	if cfg.Engine.Meta != nil {
		parsedMeta, err := ctyhelper.ParseCtyValueToMap(*cfg.Engine.Meta)
		if err != nil {
			return nil, err
		}

		meta = parsedMeta
	}

	version := cfg.Engine.Version
	engineType := cfg.Engine.Type
	// if type is null or empty, set to "rpc"
	if len(engineType) == 0 {
		engineType = DefaultEngineType
	}

	return &engine.EngineConfig{
		Source:  cfg.Engine.Source,
		Version: version,
		Type:    engineType,
		Meta:    meta,
	}, nil
}

// GetIAMRoleOptions returns the IAM role options from the RunConfig.
func (cfg *RunConfig) GetIAMRoleOptions() iam.RoleOptions {
	return cfg.IAMRole
}

// ErrorsConfig fetches errors configuration from the RunConfig.
// Returns nil when no retry or ignore blocks are defined, so callers
// can preserve default error handling (e.g. built-in retryable errors).
func (cfg *RunConfig) ErrorsConfig() (*errorconfig.Config, error) {
	if len(cfg.Errors.Retry) == 0 && len(cfg.Errors.Ignore) == 0 {
		return nil, nil
	}

	result := &errorconfig.Config{
		Retry:  make(map[string]*errorconfig.RetryConfig),
		Ignore: make(map[string]*errorconfig.IgnoreConfig),
	}

	for _, retryBlock := range cfg.Errors.Retry {
		if retryBlock == nil {
			continue
		}

		// Validate retry settings
		if retryBlock.MaxAttempts < 1 {
			return nil, fmt.Errorf(
				"cannot have less than 1 max retry in errors.retry %q, but you specified %d",
				retryBlock.Label, retryBlock.MaxAttempts,
			)
		}

		if retryBlock.SleepIntervalSec < 0 {
			return nil, fmt.Errorf(
				"cannot sleep for less than 0 seconds in errors.retry %q, but you specified %d",
				retryBlock.Label, retryBlock.SleepIntervalSec,
			)
		}

		compiledPatterns := make([]*errorconfig.Pattern, 0, len(retryBlock.RetryableErrors))

		for _, pattern := range retryBlock.RetryableErrors {
			value, err := errorsPattern(pattern)
			if err != nil {
				return nil, fmt.Errorf("invalid retry pattern %q in block %q: %w",
					pattern, retryBlock.Label, err)
			}

			compiledPatterns = append(compiledPatterns, value)
		}

		result.Retry[retryBlock.Label] = &errorconfig.RetryConfig{
			Name:             retryBlock.Label,
			RetryableErrors:  compiledPatterns,
			MaxAttempts:      retryBlock.MaxAttempts,
			SleepIntervalSec: retryBlock.SleepIntervalSec,
		}
	}

	for _, ignoreBlock := range cfg.Errors.Ignore {
		if ignoreBlock == nil {
			continue
		}

		var signals map[string]any

		if ignoreBlock.Signals != nil {
			value := convertValuesMapToCtyVal(ignoreBlock.Signals)

			var err error

			signals, err = ctyhelper.ParseCtyValueToMap(value)
			if err != nil {
				return nil, err
			}
		}

		compiledPatterns := make([]*errorconfig.Pattern, 0, len(ignoreBlock.IgnorableErrors))

		for _, pattern := range ignoreBlock.IgnorableErrors {
			value, err := errorsPattern(pattern)
			if err != nil {
				return nil, fmt.Errorf("invalid ignore pattern %q in block %q: %w",
					pattern, ignoreBlock.Label, err)
			}

			compiledPatterns = append(compiledPatterns, value)
		}

		result.Ignore[ignoreBlock.Label] = &errorconfig.IgnoreConfig{
			Name:            ignoreBlock.Label,
			IgnorableErrors: compiledPatterns,
			Message:         ignoreBlock.Message,
			Signals:         signals,
		}
	}

	return result, nil
}

// errorsPattern builds an ErrorsPattern from a string pattern.
func errorsPattern(pattern string) (*errorconfig.Pattern, error) {
	isNegative := false
	p := pattern

	if len(p) > 0 && p[0] == '!' {
		isNegative = true
		p = p[1:]
	}

	compiled, err := regexp.Compile(p)
	if err != nil {
		return nil, err
	}

	return &errorconfig.Pattern{
		Pattern:  compiled,
		Negative: isNegative,
	}, nil
}

// convertValuesMapToCtyVal takes a map of name - cty.Value pairs and converts to a single cty.Value object.
func convertValuesMapToCtyVal(valMap map[string]cty.Value) cty.Value {
	if len(valMap) == 0 {
		// Return an empty object instead of NilVal for empty maps.
		return cty.EmptyObjectVal
	}

	// Use cty.ObjectVal directly instead of gocty.ToCtyValue to preserve marks (like sensitive())
	return cty.ObjectVal(valMap)
}

// Exclude action constants
const (
	AllActions              = "all"
	AllExcludeOutputActions = "all_except_output"
	TgOutput                = "output"
)

// IsActionListedInExclude checks if the action is listed in the exclude block actions.
// This is a shared utility function that provides a single source of truth for exclude action matching logic.
// It handles special action values:
//   - "all": matches any action
//   - "all_except_output": matches any action except "output"
//   - Case-insensitive matching for regular actions
func IsActionListedInExclude(actions []string, action string) bool {
	if len(actions) == 0 {
		return false
	}

	actionLower := strings.ToLower(action)

	for _, checkAction := range actions {
		if checkAction == AllActions {
			return true
		}

		if checkAction == AllExcludeOutputActions && actionLower != TgOutput {
			return true
		}

		if strings.ToLower(checkAction) == actionLower {
			return true
		}
	}

	return false
}

// ShouldPreventRunBasedOnExclude determines if execution should be prevented based on exclude configuration.
// This is a shared utility function that provides a single source of truth for exclude run prevention logic.
// Parameters:
//   - actions: list of actions in the exclude block
//   - noRun: pointer to no_run flag (nil means not set)
//   - ifCondition: the if condition value
//   - command: the command/action to check
func ShouldPreventRunBasedOnExclude(actions []string, noRun *bool, ifCondition bool, command string) bool {
	if !ifCondition {
		return false
	}

	switch {
	case noRun == nil:
		// When no_run isn't set, preserve legacy behavior: only exact action matches prevent a run.
		return slices.Contains(actions, command)
	case !*noRun:
		// When no_run is explicitly false, never prevent the run.
		return false
	default:
		// When no_run is explicitly true, use the shared action matcher (supports special values).
		return IsActionListedInExclude(actions, command)
	}
}
