// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

// Package cliconfig has the types representing and the logic to load CLI-level
// configuration settings.
//
// The CLI config is a small collection of settings that a user can override via
// some files in their home directory or, in some cases, via environment
// variables. The CLI config is not the same thing as a OpenTofu configuration
// written in the Terraform language; the logic for those lives in the top-level
// directory "configs".
package cliconfig

import (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"log"
	"maps"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/hashicorp/hcl"
	"github.com/opentofu/svchost"

	"github.com/opentofu/opentofu/internal/tfdiags"
)

const pluginCacheDirEnvVar = "TF_PLUGIN_CACHE_DIR"
const pluginCacheMayBreakLockFileEnvVar = "TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE"

// Config is the structure of the configuration for the OpenTofu CLI.
//
// This is not the configuration for OpenTofu itself. That is in the
// "config" package.
type Config struct {
	// If set, enables local caching of plugins in this directory to
	// avoid repeatedly re-downloading over the Internet.
	PluginCacheDir string `hcl:"plugin_cache_dir"`

	// PluginCacheMayBreakDependencyLockFile is an interim accommodation for
	// those who wish to use the Plugin Cache Dir even in cases where doing so
	// will cause the dependency lock file to be incomplete.
	//
	// This is likely to become a silent no-op in future OpenTofu versions but
	// is here in recognition of the fact that the dependency lock file is not
	// yet a good fit for all OpenTofu workflows and folks in that category
	// would prefer to have the plugin cache dir's behavior to take priority
	// over the requirements of the dependency lock file.
	PluginCacheMayBreakDependencyLockFile bool `hcl:"plugin_cache_may_break_dependency_lock_file"`

	Hosts map[string]*ConfigHost `hcl:"host"`

	Credentials        map[string]map[string]any           `hcl:"credentials"`
	CredentialsHelpers map[string]*ConfigCredentialsHelper `hcl:"credentials_helper"`

	// RegistryProtocols contains some settings for tailoring the request
	// timeout and retry count for metadata requests made by our registry
	// protocol clients.
	//
	// [LoadConfig] guarantees that this will always be present and contain
	// values. If these settings are not configured either in files or the
	// environment then we use some reasonable default settings.
	RegistryProtocols *RegistryProtocolsConfig

	// ProviderInstallation represents any provider_installation blocks
	// in the configuration. Only one of these is allowed across the whole
	// configuration, but we decode into a slice here so that we can handle
	// that validation at validation time rather than initial decode time.
	ProviderInstallation []*ProviderInstallation

	// OCIDefaultCredentials and OCIRepositoryCredentials together represent
	// the individual OCI-credentials-related blocks in the configuration.
	//
	// Only one OCIDefaultCredentials element is allowed, but we validate
	// that after loading the configuration. Zero or more OCICredentials
	// blocks are allowed, but they must each have a unique repository
	// prefix.
	OCIDefaultCredentials    []*OCIDefaultCredentials
	OCIRepositoryCredentials []*OCIRepositoryCredentials
}

// ConfigHost is the structure of the "host" nested block within the CLI
// configuration, which can be used to override the default service host
// discovery behavior for a particular hostname.
type ConfigHost struct {
	Services map[string]any `hcl:"services"`
}

// ConfigCredentialsHelper is the structure of the "credentials_helper"
// nested block within the CLI configuration.
type ConfigCredentialsHelper struct {
	Args []string `hcl:"args"`
}

// BuiltinConfig is the built-in defaults for the configuration. These
// can be overridden by user configurations.
var BuiltinConfig Config

// ConfigFile returns the default path to the configuration file.
//
// On Unix-like systems this is the ".tofurc" file in the home directory.
// On Windows, this is the "tofu.rc" file in the application data
// directory.
func ConfigFile() (string, error) {
	return standardConfigLoader().configFile()
}

func (cl *ConfigLoader) ConfigFile() (string, error) {
	return cl.configFile()
}

// ConfigDir returns the configuration directory for OpenTofu.
func ConfigDir() (string, error) {
	return standardConfigLoader().configDir()
}

func (cl *ConfigLoader) ConfigDir() (string, error) {
	return cl.configDir()
}

// DataDirs returns the data directories for OpenTofu.
func DataDirs() ([]string, error) {
	return standardConfigLoader().dataDirs()
}

func (cl *ConfigLoader) DataDirs() ([]string, error) {
	return cl.dataDirs()
}

// LoadConfig reads the CLI configuration from the various filesystem locations
// and from the environment, returning a merged configuration along with any
// diagnostics (errors and warnings) encountered along the way.
func LoadConfig(ctx context.Context) (*Config, tfdiags.Diagnostics) {
	return standardConfigLoader().LoadConfig(ctx)
}

func (cl *ConfigLoader) LoadConfig(_ context.Context) (*Config, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics
	configVal := BuiltinConfig // copy
	config := &configVal

	if mainFilename, mainFileDiags := cl.cliConfigFile(); len(mainFileDiags) == 0 {
		if _, err := cl.Stat(mainFilename); err == nil {
			mainConfig, mainDiags := cl.loadConfigFile(mainFilename)
			diags = diags.Append(mainDiags)
			// NOTE: The order of arguments to merge below seems confusing
			// given that below we tend to do it the other way around, but
			// this was a historical inconsistency that we only discovered
			// after making BuiltinConfig not just be completely empty and
			// so we've swapped these but left the others unswapped to avoid
			// changing those behaviors. It was unfortunately never well
			// specified what is overriding what here.
			config = mainConfig.Merge(config)
		}
	} else {
		diags = diags.Append(mainFileDiags)
	}

	// Unless the user has specifically overridden the configuration file
	// location using an environment variable, we'll also load what we find
	// in the config directory. We skip the config directory when source
	// file override is set because we interpret the environment variable
	// being set as an intention to ignore the default set of CLI config
	// files because we're doing something special, like running OpenTofu
	// in automation with a locally-customized configuration.
	if cliConfigFileOverride() == "" {
		if configDir, err := cl.ConfigDir(); err == nil {
			if info, err := cl.Stat(configDir); err == nil && info.IsDir() {
				dirConfig, dirDiags := cl.loadConfigDir(configDir)
				diags = diags.Append(dirDiags)
				config = config.Merge(dirConfig)
			}
		}
	} else {
		log.Printf("[DEBUG] Not reading CLI config directory because config location is overridden by environment variable")
	}

	if envConfig := EnvConfig(); envConfig != nil {
		// envConfig takes precedence
		config = envConfig.Merge(config)
	}

	diags = diags.Append(config.Validate())

	return config, diags
}

// loadConfigFile loads the CLI configuration from ".tofurc" files.
func loadConfigFile(path string) (*Config, tfdiags.Diagnostics) {
	return standardConfigLoader().loadConfigFile(path)
}

func (cl *ConfigLoader) loadConfigFile(path string) (*Config, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics
	result := &Config{}

	log.Printf("Loading CLI configuration from %s", path)

	// Read the HCL file and prepare for parsing
	d, err := cl.ReadFile(path)
	if err != nil {
		diags = diags.Append(fmt.Errorf("Error reading %s: %w", path, err))
		return result, diags
	}

	// Parse it
	obj, err := hcl.Parse(string(d))
	if err != nil {
		diags = diags.Append(fmt.Errorf("Error parsing %s: %w", path, err))
		return result, diags
	}

	// Build up the result
	if err := hcl.DecodeObject(&result, obj); err != nil {
		diags = diags.Append(fmt.Errorf("Error parsing %s: %w", path, err))
		return result, diags
	}

	// A few other blocks need some more special treatment because we are
	// using a structure that is not compatible with HCL 1's DecodeObject,
	// or HCL 1 would be too liberal in parsing and thus make it harder
	// for us to potentially transition to using HCL 2 later.
	registryProtocolsConfig, registryProtocolsDiags := decodeRegistryProtocolsConfigFromConfig(obj)
	diags = diags.Append(registryProtocolsDiags)
	result.RegistryProtocols = registryProtocolsConfig
	providerInstBlocks, providerInstDiags := decodeProviderInstallationFromConfig(obj)
	diags = diags.Append(providerInstDiags)
	result.ProviderInstallation = providerInstBlocks
	ociDefaultCredsBlocks, ociDefaultCredsDiags := decodeOCIDefaultCredentialsFromConfig(obj, path)
	diags = diags.Append(ociDefaultCredsDiags)
	result.OCIDefaultCredentials = ociDefaultCredsBlocks
	ociCredsBlocks, ociCredsDiags := decodeOCIRepositoryCredentialsFromConfig(obj)
	diags = diags.Append(ociCredsDiags)
	result.OCIRepositoryCredentials = ociCredsBlocks

	if result.PluginCacheDir != "" {
		result.PluginCacheDir = os.ExpandEnv(result.PluginCacheDir)
	}

	return result, diags
}

func (cl *ConfigLoader) loadConfigDir(path string) (*Config, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics
	result := &Config{}

	entries, err := cl.ReadDir(path)
	if err != nil {
		diags = diags.Append(fmt.Errorf("Error reading %s: %w", path, err))
		return result, diags
	}

	for _, entry := range entries {
		name := entry.Name()
		// Ignoring errors here because it is used only to indicate pattern
		// syntax errors, and our patterns are hard-coded here.
		hclMatched, _ := filepath.Match("*.tfrc", name)
		jsonMatched, _ := filepath.Match("*.tfrc.json", name)
		if !hclMatched && !jsonMatched {
			continue
		}

		filePath := filepath.Join(path, name)
		fileConfig, fileDiags := loadConfigFile(filePath)
		diags = diags.Append(fileDiags)
		result = result.Merge(fileConfig)
	}

	return result, diags
}

// EnvConfig returns a Config populated from environment variables.
//
// Any values specified in this config should override those set in the
// configuration file.
func EnvConfig() *Config {
	env := makeEnvMap(os.Environ())
	return envConfig(env)
}

func envConfig(env map[string]string) *Config {
	config := &Config{}

	if envPluginCacheDir := env[pluginCacheDirEnvVar]; envPluginCacheDir != "" {
		// No ExpandEnv here, because expanding environment variables inside
		// an environment variable would be strange and seems unnecessary.
		// (User can expand variables into the value while setting it using
		// standard shell features.)
		config.PluginCacheDir = envPluginCacheDir
	}

	if envMayBreak := env[pluginCacheMayBreakLockFileEnvVar]; envMayBreak != "" && envMayBreak != "0" {
		// This is an environment variable analog to the
		// plugin_cache_may_break_dependency_lock_file setting. If either this
		// or the config file setting are enabled then it's enabled; there is
		// no way to override back to false if either location sets this to
		// true.
		config.PluginCacheMayBreakDependencyLockFile = true
	}

	// The environment config _always_ has opinions about the registry
	// protocols, because we include the default values in here if the
	// relevant environment variables aren't set.
	config.RegistryProtocols = decodeRegistryProtocolsConfigFromEnvironment()

	return config
}

func makeEnvMap(environ []string) map[string]string {
	if len(environ) == 0 {
		return nil
	}

	ret := make(map[string]string, len(environ))
	for _, entry := range environ {
		before, after, ok := strings.Cut(entry, "=")
		if !ok {
			continue
		}
		ret[before] = after
	}
	return ret
}

// Validate checks for errors in the configuration that cannot be detected
// just by HCL decoding, returning any problems as diagnostics.
//
// On success, the returned diagnostics will return false from the HasErrors
// method. A non-nil diagnostics is not necessarily an error, since it may
// contain just warnings.
func (c *Config) Validate() tfdiags.Diagnostics {
	var diags tfdiags.Diagnostics

	if c == nil {
		return diags
	}

	// FIXME: Right now our config parsing doesn't retain enough information
	// to give proper source references to any errors. We should improve
	// on this when we change the CLI config parser to use HCL2.

	// Check that all "host" blocks have valid hostnames.
	for givenHost := range c.Hosts {
		_, err := svchost.ForComparison(givenHost)
		if err != nil {
			diags = diags.Append(
				fmt.Errorf("The host %q block has an invalid hostname: %w", givenHost, err),
			)
		}
	}

	// Check that all "credentials" blocks have valid hostnames.
	for givenHost := range c.Credentials {
		_, err := svchost.ForComparison(givenHost)
		if err != nil {
			diags = diags.Append(
				fmt.Errorf("The credentials %q block has an invalid hostname: %w", givenHost, err),
			)
		}
	}

	// Should have zero or one "credentials_helper" blocks
	if len(c.CredentialsHelpers) > 1 {
		diags = diags.Append(
			fmt.Errorf("No more than one credentials_helper block may be specified"),
		)
	}

	// Should have zero or one "provider_installation" blocks
	if len(c.ProviderInstallation) > 1 {
		diags = diags.Append(
			fmt.Errorf("No more than one provider_installation block may be specified"),
		)
	}

	// Should have zero or one "oci_default_credentials" blocks
	if len(c.OCIDefaultCredentials) > 1 {
		diags = diags.Append(
			//nolint:stylecheck // Despite typical Go idiom, our existing precedent here is to return full sentences suitable for inclusion in diagnostics.
			fmt.Errorf("No more than one oci_default_credentials block may be specified"),
		)
	}
	if len(c.OCIRepositoryCredentials) != 0 {
		seenOCICredentialsAddrs := make(map[string]struct{})
		for _, creds := range c.OCIRepositoryCredentials {
			if _, ok := seenOCICredentialsAddrs[creds.RepositoryPrefix]; ok {
				diags = diags.Append(
					//nolint:stylecheck // Despite typical Go idiom, our existing precedent here is to return full sentences suitable for inclusion in diagnostics.
					fmt.Errorf("Duplicate oci_credentials block for %q", creds.RepositoryPrefix),
				)
				continue
			}
			seenOCICredentialsAddrs[creds.RepositoryPrefix] = struct{}{}
		}
	}

	if c.PluginCacheDir != "" {
		_, err := os.Stat(c.PluginCacheDir)
		if err != nil {
			diags = diags.Append(
				fmt.Errorf("The specified plugin cache dir %s cannot be opened: %w", c.PluginCacheDir, err),
			)
		}
	}

	return diags
}

// Merge merges two configurations and returns a third entirely
// new configuration with the two merged.
func (c *Config) Merge(c2 *Config) *Config {
	var result Config

	result.PluginCacheDir = c.PluginCacheDir
	if result.PluginCacheDir == "" {
		result.PluginCacheDir = c2.PluginCacheDir
	}

	if c.PluginCacheMayBreakDependencyLockFile || c2.PluginCacheMayBreakDependencyLockFile {
		// This setting saturates to "on"; once either configuration sets it,
		// there is no way to override it back to off again.
		result.PluginCacheMayBreakDependencyLockFile = true
	}

	if (len(c.Hosts) + len(c2.Hosts)) > 0 {
		result.Hosts = make(map[string]*ConfigHost)
		maps.Copy(result.Hosts, c.Hosts)
		maps.Copy(result.Hosts, c2.Hosts)
	}

	if (len(c.Credentials) + len(c2.Credentials)) > 0 {
		result.Credentials = make(map[string]map[string]any)
		maps.Copy(result.Credentials, c.Credentials)
		// We just clobber an entry from the other file right now. Will
		// improve on this later using the more-robust merging behavior
		// built in to HCL2.
		maps.Copy(result.Credentials, c2.Credentials)
	}

	if (len(c.CredentialsHelpers) + len(c2.CredentialsHelpers)) > 0 {
		result.CredentialsHelpers = make(map[string]*ConfigCredentialsHelper)
		maps.Copy(result.CredentialsHelpers, c.CredentialsHelpers)
		maps.Copy(result.CredentialsHelpers, c2.CredentialsHelpers)
	}

	result.RegistryProtocols = mergeRegistryProtocolConfigs(c2.RegistryProtocols, c.RegistryProtocols)

	if (len(c.ProviderInstallation) + len(c2.ProviderInstallation)) > 0 {
		result.ProviderInstallation = append(result.ProviderInstallation, c.ProviderInstallation...)
		result.ProviderInstallation = append(result.ProviderInstallation, c2.ProviderInstallation...)
	}

	if (len(c.OCIDefaultCredentials) + len(c2.OCIDefaultCredentials)) > 0 {
		result.OCIDefaultCredentials = append(result.OCIDefaultCredentials, c.OCIDefaultCredentials...)
		result.OCIDefaultCredentials = append(result.OCIDefaultCredentials, c2.OCIDefaultCredentials...)
	}
	if (len(c.OCIRepositoryCredentials) + len(c2.OCIRepositoryCredentials)) > 0 {
		result.OCIRepositoryCredentials = append(result.OCIRepositoryCredentials, c.OCIRepositoryCredentials...)
		result.OCIRepositoryCredentials = append(result.OCIRepositoryCredentials, c2.OCIRepositoryCredentials...)
	}

	return &result
}

func (cl *ConfigLoader) cliConfigFile() (string, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics
	mustExist := true

	configFilePath := cliConfigFileOverride()
	if configFilePath == "" {
		var err error
		configFilePath, err = cl.ConfigFile()
		mustExist = false

		if err != nil {
			log.Printf(
				"[ERROR] Error detecting default CLI config file path: %s",
				err)
		}
	}

	log.Printf("[DEBUG] Attempting to open CLI config file: %s", configFilePath)
	f, err := cl.Open(configFilePath)
	if err == nil {
		f.Close()
		return configFilePath, diags
	}

	if mustExist || !errors.Is(err, fs.ErrNotExist) {
		diags = append(diags, tfdiags.Sourceless(
			tfdiags.Warning,
			"Unable to open CLI configuration file",
			fmt.Sprintf("The CLI configuration file at %q does not exist.", configFilePath),
		))
	}

	log.Println("[DEBUG] File doesn't exist, but doesn't need to. Ignoring.")
	return "", diags
}

func cliConfigFileOverride() string {
	configFilePath := os.Getenv("TF_CLI_CONFIG_FILE")
	if configFilePath == "" {
		configFilePath = os.Getenv("TERRAFORM_CONFIG")
	}
	return configFilePath
}

const (
	// providerDownloadRetryCountEnvName is the environment variable name used to customize
	// the HTTP retry count for module downloads.
	providerDownloadRetryCountEnvName = "TF_PROVIDER_DOWNLOAD_RETRY"

	providerDownloadDefaultRetry = 2
)

// ProviderDownloadRetries will attempt for requests with retryable errors, like 502 status codes
func ProviderDownloadRetries() int {
	r := providerDownloadDefaultRetry
	if v := os.Getenv(providerDownloadRetryCountEnvName); v != "" {
		retry, err := strconv.Atoi(v)
		if err == nil && retry > 0 {
			r = retry
		}
	}
	return r
}
