package hclparse

import (
	"cmp"
	"fmt"
	"path/filepath"
	"slices"

	"github.com/gruntwork-io/terragrunt/internal/vfs"
	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/hashicorp/hcl/v2/hclwrite"
	"github.com/zclconf/go-cty/cty"
)

// AutoIncludeKind identifies whether a generated autoinclude file is unit-level or stack-level. The values intentionally match internal/component.{UnitKind,StackKind} but are defined here because internal/component -> pkg/config -> internal/hclparse is the established import direction; reusing the component constants would create a cycle.
type AutoIncludeKind string

const (
	// KindUnit selects the unit-level filename (terragrunt.autoinclude.hcl). Value matches component.UnitKind.
	KindUnit AutoIncludeKind = "unit"
	// KindStack selects the stack-level filename (terragrunt.autoinclude.stack.hcl). Value matches component.StackKind.
	KindStack AutoIncludeKind = "stack"
)

const (
	// AutoIncludeFile is the filename for generated unit-level autoinclude files.
	AutoIncludeFile = "terragrunt.autoinclude.hcl"
	// AutoIncludeStackFile is the filename for generated stack-level autoinclude files; the .stack.hcl suffix mirrors terragrunt.stack.hcl so tooling (LSP, read_terragrunt_config) can distinguish unit vs stack autoincludes by name.
	AutoIncludeStackFile = "terragrunt.autoinclude.stack.hcl"
	// autoIncludeFilePerm is the file permission for generated autoinclude files.
	autoIncludeFilePerm = 0644
	// autoIncludeDirPerm is the directory permission for generated autoinclude dirs.
	autoIncludeDirPerm = 0755
)

// AutoIncludeFileNameForKind returns the autoinclude filename to generate for the given kind. Panics on unknown kinds (programmer error) so a misspelled or missing kind cannot silently produce the wrong filename.
func AutoIncludeFileNameForKind(kind AutoIncludeKind) string {
	switch kind {
	case KindUnit:
		return AutoIncludeFile
	case KindStack:
		return AutoIncludeStackFile
	default:
		panic(fmt.Sprintf("hclparse.AutoIncludeFileNameForKind: unknown kind %q (expected %q or %q)", kind, KindUnit, KindStack))
	}
}

// GenerateAutoIncludeFile writes a terragrunt.autoinclude.hcl file in the
// given directory from a resolved autoinclude.
//
// The generated file contains:
//   - dependency blocks with resolved config_path and all other attributes
//     (mock_outputs, mock_outputs_allowed_terraform_commands, etc.) copied
//     from the original AST source bytes.
//   - All non-dependency content (inputs, retry blocks, etc.) copied verbatim
//     from the original AST source bytes: dependency.*.outputs.* references
//     are preserved without evaluation.
//
// Requires non-nil fs and non-empty targetDir (panics otherwise). resolved may be nil (no-op). srcBytes must be the bytes of the file resolved.RawBody was parsed from; for includes pass resolved.SourceBytes. evalCtx may be nil.
func GenerateAutoIncludeFile(fs vfs.FS, resolved *AutoIncludeResolved, targetDir string, srcBytes []byte, evalCtx *hcl.EvalContext) error {
	if fs == nil {
		panic(fmt.Sprintf("hclparse.GenerateAutoIncludeFile: fs is nil (targetDir=%q, sourceFile=%q)", targetDir, resolvedSourceFile(resolved)))
	}

	if targetDir == "" {
		panic(fmt.Sprintf("hclparse.GenerateAutoIncludeFile: targetDir is empty (sourceFile=%q)", resolvedSourceFile(resolved)))
	}

	if resolved == nil {
		return nil
	}

	body, ok := resolved.RawBody.(*hclsyntax.Body)
	if !ok {
		return UnexpectedBodyTypeError{FilePath: "autoinclude"}
	}

	out := hclwrite.NewEmptyFile()
	outBody := out.Body()

	outBody.AppendUnstructuredTokens(CommentTokens("# Generated by Terragrunt from autoinclude block. Do not edit manually.\n"))

	// Write dependency blocks with resolved config_path.
	for _, dep := range resolved.Dependencies {
		origBlock := FindBlock(body, blockDependency, dep.Name)
		if origBlock == nil {
			continue
		}

		writeDependencyBlock(outBody, dep, origBlock, srcBytes, targetDir)
		outBody.AppendNewline()
	}

	// Write non-dependency content with binary per-attribute evaluation.
	writeNonDependencyContent(outBody, body, srcBytes, evalCtx)

	filePath := filepath.Join(targetDir, AutoIncludeFileNameForKind(resolved.Kind))

	if err := fs.MkdirAll(targetDir, autoIncludeDirPerm); err != nil {
		return DirCreateError{DirPath: targetDir, Err: err}
	}

	formatted := hclwrite.Format(out.Bytes())

	if err := vfs.WriteFile(fs, filePath, formatted, autoIncludeFilePerm); err != nil {
		return FileWriteError{FilePath: filePath, Err: err}
	}

	return nil
}

// Copier copies HCL blocks from AST source to hclwrite output.
// When an eval context is set, attributes are partially evaluated
// (local.* resolved, dependency.* preserved). Without it, attributes
// are copied verbatim from source bytes.
type Copier struct {
	evalCtx *hcl.EvalContext
}

// NewCopier creates a Copier that copies blocks verbatim.
func NewCopier() *Copier {
	return &Copier{}
}

// WithEvalCtx returns the Copier with partial evaluation enabled.
func (c *Copier) WithEvalCtx(evalCtx *hcl.EvalContext) *Copier {
	c.evalCtx = evalCtx

	return c
}

// CopyBlock copies a block from the original AST to hclwrite output.
func (c *Copier) CopyBlock(outBody *hclwrite.Body, block *hclsyntax.Block, srcBytes []byte) {
	newBlock := outBody.AppendNewBlock(block.Type, block.Labels)
	blockBody := newBlock.Body()

	for _, attr := range SortedAttributes(block.Body.Attributes) {
		if c.evalCtx == nil {
			blockBody.SetAttributeRaw(attr.Name, RawTokens(RangeBytes(srcBytes, attr.Expr.Range())))

			continue
		}

		result := PartialEval(attr.Expr, &EvalArgs{EvalCtx: c.evalCtx, Deferred: deferredRoots, SrcBytes: srcBytes})
		blockBody.SetAttributeRaw(attr.Name, RawTokens(result))
	}

	for _, nested := range block.Body.Blocks {
		c.CopyBlock(blockBody, nested, srcBytes)
	}
}

// FindBlock finds a block by type and first label in the AST body.
// Exported for reuse by callers that need to locate specific blocks in parsed HCL bodies.
func FindBlock(body *hclsyntax.Body, blockType, label string) *hclsyntax.Block {
	for _, block := range body.Blocks {
		if block.Type == blockType && len(block.Labels) > 0 && block.Labels[0] == label {
			return block
		}
	}

	return nil
}

// RangeBytes extracts the source bytes for the given range.
func RangeBytes(src []byte, r hcl.Range) []byte {
	start := r.Start.Byte
	end := r.End.Byte

	if start >= len(src) || end > len(src) || start >= end {
		return nil
	}

	return src[start:end]
}

// RawTokens wraps raw bytes as a single hclwrite token. hclwrite.Format
// will handle the final formatting of the output.
func RawTokens(b []byte) hclwrite.Tokens {
	if len(b) == 0 {
		return nil
	}

	return hclwrite.Tokens{
		{Type: hclsyntax.TokenIdent, Bytes: b},
	}
}

// CommentTokens creates a comment token for hclwrite.
func CommentTokens(text string) hclwrite.Tokens {
	return hclwrite.Tokens{
		{Type: hclsyntax.TokenComment, Bytes: []byte(text)},
	}
}

// SortedAttributes returns attributes in a deterministic order by source position.
func SortedAttributes(attrs hclsyntax.Attributes) []*hclsyntax.Attribute {
	sorted := make([]*hclsyntax.Attribute, 0, len(attrs))
	for _, attr := range attrs {
		sorted = append(sorted, attr)
	}

	slices.SortFunc(sorted, func(a, b *hclsyntax.Attribute) int {
		return cmp.Compare(a.SrcRange.Start.Byte, b.SrcRange.Start.Byte)
	})

	return sorted
}

// writeDependencyBlock writes a single dependency block with resolved config_path.
// The config_path is converted to a path relative to targetDir. All other
// attributes are copied from source bytes to preserve original expressions.
func writeDependencyBlock(outBody *hclwrite.Body, dep AutoIncludeDependency, origBlock *hclsyntax.Block, srcBytes []byte, targetDir string) {
	depBlock := outBody.AppendNewBlock(blockDependency, []string{dep.Name})
	depBody := depBlock.Body()

	// Convert config_path to relative from the target directory.
	relPath, err := filepath.Rel(targetDir, dep.ConfigPath)
	if err != nil {
		relPath = dep.ConfigPath // fallback to absolute
	}

	depBody.SetAttributeRaw(attrConfigPath, quotedStringTokens(relPath))

	// Copy all other attributes from source bytes (mock_outputs, etc.).
	for _, attr := range SortedAttributes(origBlock.Body.Attributes) {
		if attr.Name == attrConfigPath {
			continue
		}

		exprBytes := RangeBytes(srcBytes, attr.Expr.Range())
		depBody.SetAttributeRaw(attr.Name, RawTokens(exprBytes))
	}

	// Copy nested blocks within the dependency (if any).
	for _, nested := range origBlock.Body.Blocks {
		NewCopier().CopyBlock(depBody, nested, srcBytes)
	}
}

// writeNonDependencyContent writes non-dependency attributes and blocks from
// the autoinclude body. Each attribute expression is partially evaluated:
// resolvable parts (locals, pure refs) become literals, while deferred parts
// (dependency.*) keep their original source text. This enables mixed
// expressions like "${local.env}-${dependency.vpc.outputs.vpc_id}" to be
// partially resolved.
//
// Non-dependency blocks are copied through Copier:
//   - evalCtx == nil: verbatim from source bytes
//   - evalCtx != nil: attributes are partially evaluated
func writeNonDependencyContent(outBody *hclwrite.Body, body *hclsyntax.Body, srcBytes []byte, evalCtx *hcl.EvalContext) {
	for _, attr := range SortedAttributes(body.Attributes) {
		if evalCtx == nil {
			exprBytes := RangeBytes(srcBytes, attr.Expr.Range())
			outBody.SetAttributeRaw(attr.Name, RawTokens(exprBytes))

			continue
		}

		result := PartialEval(attr.Expr, &EvalArgs{SrcBytes: srcBytes, EvalCtx: evalCtx, Deferred: deferredRoots})
		outBody.SetAttributeRaw(attr.Name, RawTokens(result))
	}

	for _, block := range body.Blocks {
		if block.Type == blockDependency {
			continue
		}

		NewCopier().WithEvalCtx(evalCtx).CopyBlock(outBody, block, srcBytes)
	}
}

// quotedStringTokens creates hclwrite tokens for a quoted string literal.
func quotedStringTokens(value string) hclwrite.Tokens {
	return hclwrite.TokensForValue(cty.StringVal(value))
}

// resolvedSourceFile extracts the originating HCL filename from a resolved
// autoinclude, used to enrich panic messages with file context. Reads the
// SrcRange struct field on the known concrete body type so no interface
// method is invoked — this keeps the helper panic-safe even when called
// from inside a panic formatter.
func resolvedSourceFile(resolved *AutoIncludeResolved) string {
	if resolved == nil {
		return ""
	}

	body, ok := resolved.RawBody.(*hclsyntax.Body)
	if !ok || body == nil {
		return ""
	}

	return body.SrcRange.Filename
}
