package redesign

import (
	"bytes"
	stderrors "errors"
	"io/fs"
	"path/filepath"
	"sort"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclparse"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/hashicorp/hcl/v2/hclwrite"
	"github.com/zclconf/go-cty/cty"

	"github.com/gruntwork-io/terragrunt/internal/errors"
	"github.com/gruntwork-io/terragrunt/internal/vfs"
	"github.com/gruntwork-io/terragrunt/pkg/config"
)

const (
	// valuesFileName is the sibling file Terragrunt reads to populate the
	// `values` object when evaluating a unit or stack configuration.
	valuesFileName = "terragrunt.values.hcl"

	// valuesStubPerm is the mode the generated stub is written with.
	valuesStubPerm = 0o644

	// valuesStubHeader leads the generated file.
	valuesStubHeader = "# Auto-generated by `terragrunt catalog` from the `values.*` references in this unit/stack.\n" +
		"# Populate each `\"TODO\"` entry with a real value before running terragrunt.\n" +
		"#\n" +
		"# Required values come first. These are referenced directly by the unit or\n" +
		"# stack, so terragrunt will fail until you fill them in.\n" +
		"#\n" +
		"# Optional values come next, pre-populated with the defaults pulled from each\n" +
		"# reference's `try(...)` fallback. Edit any you want to override, or delete\n" +
		"# lines you're happy leaving at the fallback.\n\n"

	requiredSectionHeader = "# === Required ===\n"
	optionalSectionHeader = "\n# === Optional (defaults from try() fallbacks) ===\n"
)

// ValuesReferences captures the outcome of a single HCL walk: names that
// must be populated (referenced outside any try) and names that are
// optional, each paired with a literal default when the try fallback was
// constant enough to evaluate without context.
type ValuesReferences struct {
	Required []string
	Optional []OptionalValue
}

// OptionalValue pairs an optional `values.<name>` reference with the literal
// default from its immediate enclosing `try(...)` call. Only references
// whose fallback evaluates without a context (i.e. pure literals) appear here.
type OptionalValue struct {
	Default cty.Value
	Name    string
}

// IsEmpty reports whether the walk surfaced no references at all.
func (r ValuesReferences) IsEmpty() bool {
	return len(r.Required) == 0 && len(r.Optional) == 0
}

// allNames returns the union of required and optional names, for use in
// diagnostic messages where the required/optional distinction doesn't matter.
func (r ValuesReferences) allNames() []string {
	names := make([]string, 0, len(r.Required)+len(r.Optional))
	names = append(names, r.Required...)

	for _, o := range r.Optional {
		names = append(names, o.Name)
	}

	sort.Strings(names)

	return names
}

// CollectValuesReferences parses the HCL file at path and classifies every
// `values.<name>` reference as required (not inside a try) or optional
// (inside a try). For optional references, the innermost try's last arg is
// evaluated in an empty context; if the eval succeeds and the value is
// wholly known, it's recorded as the default.
//
// If path does not exist, returns (zero ValuesReferences, nil).
func CollectValuesReferences(fsys vfs.FS, path string) (ValuesReferences, error) {
	raw, err := vfs.ReadFile(fsys, path)
	if err != nil {
		if stderrors.Is(err, fs.ErrNotExist) {
			return ValuesReferences{}, nil
		}

		return ValuesReferences{}, errors.New(err)
	}

	parser := hclparse.NewParser()

	file, diags := parser.ParseHCL(raw, filepath.Base(path))
	if diags.HasErrors() {
		return ValuesReferences{}, errors.New(diags)
	}

	body, ok := file.Body.(*hclsyntax.Body)
	if !ok {
		return ValuesReferences{}, nil
	}

	collector := newValuesCollector()
	if walkDiags := hclsyntax.Walk(body, collector); walkDiags.HasErrors() {
		return ValuesReferences{}, errors.New(walkDiags)
	}

	return collector.result(), nil
}

// valuesCollector walks an hclsyntax tree, separating every `values.<name>`
// reference into required vs optional and, when possible, capturing each
// optional's literal fallback.
type valuesCollector struct {
	requiredSet  map[string]struct{}
	optionalVals map[string]cty.Value
	tryStack     []*hclsyntax.FunctionCallExpr
}

func newValuesCollector() *valuesCollector {
	return &valuesCollector{
		requiredSet:  map[string]struct{}{},
		optionalVals: map[string]cty.Value{},
	}
}

func (c *valuesCollector) Enter(node hclsyntax.Node) hcl.Diagnostics {
	if call, ok := node.(*hclsyntax.FunctionCallExpr); ok && call.Name == "try" {
		c.tryStack = append(c.tryStack, call)

		return nil
	}

	trav, ok := node.(*hclsyntax.ScopeTraversalExpr)
	if !ok {
		return nil
	}

	const minTraversalLen = 2

	if len(trav.Traversal) < minTraversalLen {
		return nil
	}

	root, ok := trav.Traversal[0].(hcl.TraverseRoot)
	if !ok || root.Name != config.MetadataValues {
		return nil
	}

	attr, ok := trav.Traversal[1].(hcl.TraverseAttr)
	if !ok {
		return nil
	}

	c.recordReference(attr.Name)

	return nil
}

func (c *valuesCollector) Exit(node hclsyntax.Node) hcl.Diagnostics {
	if call, ok := node.(*hclsyntax.FunctionCallExpr); ok && call.Name == "try" {
		c.tryStack = c.tryStack[:len(c.tryStack)-1]
	}

	return nil
}

// recordReference classifies a single reference to `values.<name>`. A name
// seen outside any try wins over any earlier optional classification: once
// we see a required use, the user must supply it.
func (c *valuesCollector) recordReference(name string) {
	if len(c.tryStack) == 0 {
		c.requiredSet[name] = struct{}{}

		delete(c.optionalVals, name)

		return
	}

	if _, isRequired := c.requiredSet[name]; isRequired {
		return
	}

	if _, alreadyCaptured := c.optionalVals[name]; alreadyCaptured {
		return
	}

	innermost := c.tryStack[len(c.tryStack)-1]
	if len(innermost.Args) == 0 {
		return
	}

	fallback := innermost.Args[len(innermost.Args)-1]

	val, diags := fallback.Value(&hcl.EvalContext{})
	if diags.HasErrors() || !val.IsWhollyKnown() || val.IsNull() {
		return
	}

	c.optionalVals[name] = val
}

// result returns the sorted required/optional classification captured so far.
func (c *valuesCollector) result() ValuesReferences {
	required := make([]string, 0, len(c.requiredSet))
	for name := range c.requiredSet {
		required = append(required, name)
	}

	sort.Strings(required)

	optional := make([]OptionalValue, 0, len(c.optionalVals))
	for name, val := range c.optionalVals {
		optional = append(optional, OptionalValue{Name: name, Default: val})
	}

	sort.Slice(optional, func(i, j int) bool {
		return optional[i].Name < optional[j].Name
	})

	return ValuesReferences{Required: required, Optional: optional}
}

// WriteValuesStub writes a terragrunt.values.hcl skeleton into dir. The
// file contains a required section (with "TODO" placeholders) followed by
// an optional section (with the evaluated try() defaults).
//
// If both sections would be empty the file is not written. If a
// terragrunt.values.hcl already exists in dir it is left untouched.
//
// Returns written=true only when a new file was created.
func WriteValuesStub(fsys vfs.FS, dir string, refs ValuesReferences) (bool, error) {
	if refs.IsEmpty() {
		return false, nil
	}

	filePath := filepath.Join(dir, valuesFileName)

	exists, err := vfs.FileExists(fsys, filePath)
	if err != nil {
		return false, errors.New(err)
	}

	if exists {
		return false, nil
	}

	var buf bytes.Buffer

	buf.WriteString(valuesStubHeader)

	// Sort defensively so callers passing a hand-constructed
	// ValuesReferences get deterministic output without needing to know that
	// CollectValuesReferences happens to pre-sort.
	if len(refs.Required) > 0 {
		buf.WriteString(requiredSectionHeader)

		sortedRequired := append([]string(nil), refs.Required...)
		sort.Strings(sortedRequired)

		required := hclwrite.NewEmptyFile()
		for _, name := range sortedRequired {
			required.Body().SetAttributeValue(name, cty.StringVal("TODO"))
		}

		buf.Write(required.Bytes())
	}

	if len(refs.Optional) > 0 {
		buf.WriteString(optionalSectionHeader)

		sortedOptional := append([]OptionalValue(nil), refs.Optional...)
		sort.Slice(sortedOptional, func(i, j int) bool {
			return sortedOptional[i].Name < sortedOptional[j].Name
		})

		optional := hclwrite.NewEmptyFile()
		for _, o := range sortedOptional {
			optional.Body().SetAttributeValue(o.Name, o.Default)
		}

		buf.Write(optional.Bytes())
	}

	if err := vfs.WriteFile(fsys, filePath, buf.Bytes(), valuesStubPerm); err != nil {
		return false, errors.New(err)
	}

	return true, nil
}

// configFileForKind returns the filename the given component kind's main HCL
// file is expected to use, or "" if the kind has no associated HCL config.
func configFileForKind(kind ComponentKind) string {
	switch kind {
	case ComponentKindUnit:
		return config.DefaultTerragruntConfigPath
	case ComponentKindStack:
		return config.DefaultStackFile
	case ComponentKindModule, ComponentKindTemplate:
		return ""
	}

	return ""
}
