package hclparse

import (
	"cmp"
	"errors"
	"fmt"
	"maps"
	"os"
	"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.
	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 a resolved config_path; their other attributes
//     (mock_outputs, mock_outputs_allowed_terraform_commands, etc.) are partially
//     evaluated, so generate-time roots (local.*, values.*, unit.*, stack.*) and
//     function calls resolve to literals while dependency.*.outputs.* stays verbatim
//     (resolved inside the generated unit at run time).
//   - All non-dependency content (inputs, generate, remote_state, etc.) is partially
//     evaluated the same way: only dependency.* (and any expression referencing it) is
//     preserved verbatim for runtime resolution.
//
// 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
		}

		if err := writeDependencyBlock(
			outBody,
			dep,
			origBlock,
			srcBytes,
			targetDir,
			evalCtx,
		); err != nil {
			return err
		}

		outBody.AppendNewline()
	}

	// Write non-dependency content with binary per-attribute evaluation.
	if err := writeNonDependencyContent(outBody, body, srcBytes, evalCtx); err != nil {
		return err
	}

	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())

	// CAS may materialize the target as a read-only hard link, so remove it before writing
	if info, statErr := fs.Stat(filePath); statErr == nil && info.Mode().IsRegular() {
		if err := fs.Remove(filePath); err != nil && !errors.Is(err, os.ErrNotExist) {
			return FileWriteError{FilePath: filePath, Err: err}
		}
	}

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

	return nil
}

// maxBlockDepth bounds nested-block recursion so a deep autoinclude body cannot overflow the stack; the resolve-time value scan shares this bound and rejects over-deep bodies first, so this guard is unreachable defense-in-depth kept for the bounded-recursion rule.
const maxBlockDepth = 1000

// copyBlock copies block from the AST to hclwrite output; partially evaluates attributes when evalCtx is
// non-nil, otherwise verbatim. Generate-time roots (local.*, values.*, unit.*, stack.*) and function calls
// resolve to literals while dependency.*.outputs.* is deferred (kept verbatim, resolved inside the unit).
func copyBlock(
	outBody *hclwrite.Body,
	block *hclsyntax.Block,
	srcBytes []byte,
	evalCtx *hcl.EvalContext,
	depth int,
) error {
	if depth > maxBlockDepth {
		return BlockDepthExceededError{MaxDepth: maxBlockDepth}
	}

	newBlock := outBody.AppendNewBlock(block.Type, block.Labels)
	blockBody := newBlock.Body()

	for _, attr := range SortedAttributes(block.Body.Attributes) {
		if err := writeAttribute(blockBody, attr, srcBytes, evalCtx); err != nil {
			return err
		}
	}

	for _, nested := range block.Body.Blocks {
		if err := copyBlock(blockBody, nested, srcBytes, evalCtx, depth+1); err != nil {
			return err
		}
	}

	return nil
}

// 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 handles 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 {
	return slices.SortedFunc(maps.Values(attrs), func(a, b *hclsyntax.Attribute) int {
		return cmp.Compare(a.SrcRange.Start.Byte, b.SrcRange.Start.Byte)
	})
}

// writeDependencyBlock writes a single dependency block with config_path converted to relative-to-targetDir.
// Its other attributes (mock_outputs, etc.) are partially evaluated like the rest of the autoinclude body:
// generate-time roots (local.*, values.*, unit.*, stack.*) and function calls resolve to literals while
// dependency.*.outputs.* is kept verbatim (resolved inside the generated unit). When evalCtx is nil the
// attributes are copied from source bytes unchanged.
func writeDependencyBlock(
	outBody *hclwrite.Body,
	dep AutoIncludeDependency,
	origBlock *hclsyntax.Block,
	srcBytes []byte,
	targetDir string,
	evalCtx *hcl.EvalContext,
) error {
	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))

	// Render the remaining attributes (mock_outputs, etc.) with the same partial evaluation.
	for _, attr := range SortedAttributes(origBlock.Body.Attributes) {
		if attr.Name == attrConfigPath {
			continue
		}

		if err := writeAttribute(depBody, attr, srcBytes, evalCtx); err != nil {
			return err
		}
	}

	// Render nested blocks within the dependency (if any) with the same partial evaluation.
	for _, nested := range origBlock.Body.Blocks {
		if err := copyBlock(depBody, nested, srcBytes, evalCtx, 0); err != nil {
			return err
		}
	}

	return nil
}

// writeNonDependencyContent writes non-dependency attributes and blocks from the autoinclude body. Generate-time
// roots (local.*, values.*, unit.*, stack.*) and function calls resolve to literals; only dependency.* (and any
// expression referencing it) stays verbatim so it resolves in the generated unit.
func writeNonDependencyContent(
	outBody *hclwrite.Body,
	body *hclsyntax.Body,
	srcBytes []byte,
	evalCtx *hcl.EvalContext,
) error {
	for _, attr := range SortedAttributes(body.Attributes) {
		if err := writeAttribute(outBody, attr, srcBytes, evalCtx); err != nil {
			return err
		}
	}

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

		if err := copyBlock(outBody, block, srcBytes, evalCtx, 0); err != nil {
			return err
		}
	}

	return nil
}

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

// writeAttribute writes attr to body verbatim when evalCtx is nil, otherwise partially evaluated so generate-time
// roots resolve and deferred roots stay verbatim. A function that errors at generate time is kept verbatim by
// [PartialEval] (structural fallback) without surfacing an error, so it resolves in the generated unit; a genuine
// unresolved reference (e.g. a missing local.*/values.* key) surfaces as an error and fails generation.
func writeAttribute(
	body *hclwrite.Body,
	attr *hclsyntax.Attribute,
	srcBytes []byte,
	evalCtx *hcl.EvalContext,
) error {
	if evalCtx == nil {
		body.SetAttributeRaw(attr.Name, RawTokens(RangeBytes(srcBytes, attr.Expr.Range())))

		return nil
	}

	result, err := PartialEval(
		attr.Expr,
		&EvalArgs{SrcBytes: srcBytes, EvalCtx: evalCtx, Deferred: deferredRoots},
	)
	if err != nil {
		return err
	}

	body.SetAttributeRaw(attr.Name, RawTokens(result))

	return nil
}

// resolvedSourceFile returns the originating HCL filename from a resolved autoinclude for panic-message context.
func resolvedSourceFile(resolved *AutoIncludeResolved) string {
	if resolved == nil {
		return ""
	}

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

	return body.SrcRange.Filename
}
