package configs

import (
	"fmt"

	"github.com/camptocamp/terraboard/internal/terraform/experiments"
	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/terraform/version"
	"github.com/zclconf/go-cty/cty"
)

// When developing UI for experimental features, you can temporarily disable
// the experiment warning by setting this package-level variable to a non-empty
// value using a link-time flag:
//
// go install -ldflags="-X 'github.com/hashicorp/terraform/internal/configs.disableExperimentWarnings=yes'"
//
// This functionality is for development purposes only and is not a feature we
// are committing to supporting for end users.
var disableExperimentWarnings = ""

// sniffActiveExperiments does minimal parsing of the given body for
// "terraform" blocks with "experiments" attributes, returning the
// experiments found.
//
// This is separate from other processing so that we can be sure that all of
// the experiments are known before we process the result of the module config,
// and thus we can take into account which experiments are active when deciding
// how to decode.
func sniffActiveExperiments(body hcl.Body) (experiments.Set, hcl.Diagnostics) {
	rootContent, _, diags := body.PartialContent(configFileTerraformBlockSniffRootSchema)

	ret := experiments.NewSet()

	for _, block := range rootContent.Blocks {
		content, _, blockDiags := block.Body.PartialContent(configFileExperimentsSniffBlockSchema)
		diags = append(diags, blockDiags...)

		if attr, exists := content.Attributes["language"]; exists {
			// We don't yet have a sense of selecting an edition of the
			// language, but we're reserving this syntax for now so that
			// if and when we do this later older versions of Terraform
			// will emit a more helpful error message than just saying
			// this attribute doesn't exist. Handling this as part of
			// experiments is a bit odd for now but justified by the
			// fact that a future fuller implementation of switchable
			// languages would be likely use a similar implementation
			// strategy as experiments, and thus would lead to this
			// function being refactored to deal with both concerns at
			// once. We'll see, though!
			kw := hcl.ExprAsKeyword(attr.Expr)
			currentVersion := version.SemVer.String()
			const firstEdition = "TF2021"
			switch {
			case kw == "": // (the expression wasn't a keyword at all)
				diags = diags.Append(&hcl.Diagnostic{
					Severity: hcl.DiagError,
					Summary:  "Invalid language edition",
					Detail: fmt.Sprintf(
						"The language argument expects a bare language edition keyword. Terraform %s supports only language edition %s, which is the default.",
						currentVersion, firstEdition,
					),
					Subject: attr.Expr.Range().Ptr(),
				})
			case kw != firstEdition:
				rel := "different"
				if kw > firstEdition { // would be weird for this not to be true, but it's user input so anything goes
					rel = "newer"
				}
				diags = diags.Append(&hcl.Diagnostic{
					Severity: hcl.DiagError,
					Summary:  "Unsupported language edition",
					Detail: fmt.Sprintf(
						"Terraform v%s only supports language edition %s. This module requires a %s version of Terraform CLI.",
						currentVersion, firstEdition, rel,
					),
					Subject: attr.Expr.Range().Ptr(),
				})
			}
		}

		attr, exists := content.Attributes["experiments"]
		if !exists {
			continue
		}

		exps, expDiags := decodeExperimentsAttr(attr)
		diags = append(diags, expDiags...)
		if !expDiags.HasErrors() {
			ret = experiments.SetUnion(ret, exps)
		}
	}

	return ret, diags
}

func decodeExperimentsAttr(attr *hcl.Attribute) (experiments.Set, hcl.Diagnostics) {
	var diags hcl.Diagnostics

	exprs, moreDiags := hcl.ExprList(attr.Expr)
	diags = append(diags, moreDiags...)
	if moreDiags.HasErrors() {
		return nil, diags
	}

	var ret = experiments.NewSet()
	for _, expr := range exprs {
		kw := hcl.ExprAsKeyword(expr)
		if kw == "" {
			diags = diags.Append(&hcl.Diagnostic{
				Severity: hcl.DiagError,
				Summary:  "Invalid experiment keyword",
				Detail:   "Elements of \"experiments\" must all be keywords representing active experiments.",
				Subject:  expr.Range().Ptr(),
			})
			continue
		}

		exp, err := experiments.GetCurrent(kw)
		switch err := err.(type) {
		case experiments.UnavailableError:
			diags = diags.Append(&hcl.Diagnostic{
				Severity: hcl.DiagError,
				Summary:  "Unknown experiment keyword",
				Detail:   fmt.Sprintf("There is no current experiment with the keyword %q.", kw),
				Subject:  expr.Range().Ptr(),
			})
		case experiments.ConcludedError:
			diags = diags.Append(&hcl.Diagnostic{
				Severity: hcl.DiagError,
				Summary:  "Experiment has concluded",
				Detail:   fmt.Sprintf("Experiment %q is no longer available. %s", kw, err.Message),
				Subject:  expr.Range().Ptr(),
			})
		case nil:
			// No error at all means it's valid and current.
			ret.Add(exp)

			if disableExperimentWarnings == "" {
				// However, experimental features are subject to breaking changes
				// in future releases, so we'll warn about them to help make sure
				// folks aren't inadvertently using them in places where that'd be
				// inappropriate, particularly if the experiment is active in a
				// shared module they depend on.
				diags = diags.Append(&hcl.Diagnostic{
					Severity: hcl.DiagWarning,
					Summary:  fmt.Sprintf("Experimental feature %q is active", exp.Keyword()),
					Detail:   "Experimental features are subject to breaking changes in future minor or patch releases, based on feedback.\n\nIf you have feedback on the design of this feature, please open a GitHub issue to discuss it.",
					Subject:  expr.Range().Ptr(),
				})
			}

		default:
			// This should never happen, because GetCurrent is not documented
			// to return any other error type, but we'll handle it to be robust.
			diags = diags.Append(&hcl.Diagnostic{
				Severity: hcl.DiagError,
				Summary:  "Invalid experiment keyword",
				Detail:   fmt.Sprintf("Could not parse %q as an experiment keyword: %s.", kw, err.Error()),
				Subject:  expr.Range().Ptr(),
			})
		}
	}
	return ret, diags
}

func checkModuleExperiments(m *Module) hcl.Diagnostics {
	var diags hcl.Diagnostics

	// When we have current experiments, this is a good place to check that
	// the features in question can only be used when the experiments are
	// active. Return error diagnostics if a feature is being used without
	// opting in to the feature. For example:
	/*
		if !m.ActiveExperiments.Has(experiments.ResourceForEach) {
			for _, rc := range m.ManagedResources {
				if rc.ForEach != nil {
					diags = append(diags, &hcl.Diagnostic{
						Severity: hcl.DiagError,
						Summary:  "Resource for_each is experimental",
						Detail:   "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding resource_for_each to the list of active experiments.",
						Subject:  rc.ForEach.Range().Ptr(),
					})
				}
			}
			for _, rc := range m.DataResources {
				if rc.ForEach != nil {
					diags = append(diags, &hcl.Diagnostic{
						Severity: hcl.DiagError,
						Summary:  "Resource for_each is experimental",
						Detail:   "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding resource_for_each to the list of active experiments.",
						Subject:  rc.ForEach.Range().Ptr(),
					})
				}
			}
		}
	*/

	if !m.ActiveExperiments.Has(experiments.ModuleVariableOptionalAttrs) {
		for _, v := range m.Variables {
			if typeConstraintHasOptionalAttrs(v.ConstraintType) {
				diags = diags.Append(&hcl.Diagnostic{
					Severity: hcl.DiagError,
					Summary:  "Optional object type attributes are experimental",
					Detail:   "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding module_variable_optional_attrs to the list of active experiments.",
					Subject:  v.DeclRange.Ptr(),
				})
			}
		}
	}

	return diags
}

func typeConstraintHasOptionalAttrs(ty cty.Type) bool {
	if ty == cty.NilType {
		// Weird, but we'll just ignore it to avoid crashing.
		return false
	}

	switch {
	case ty.IsPrimitiveType():
		return false
	case ty.IsCollectionType():
		return typeConstraintHasOptionalAttrs(ty.ElementType())
	case ty.IsObjectType():
		if len(ty.OptionalAttributes()) != 0 {
			return true
		}
		for _, aty := range ty.AttributeTypes() {
			if typeConstraintHasOptionalAttrs(aty) {
				return true
			}
		}
		return false
	case ty.IsTupleType():
		for _, ety := range ty.TupleElementTypes() {
			if typeConstraintHasOptionalAttrs(ety) {
				return true
			}
		}
		return false
	default:
		return false
	}
}
