// Package format recursively looks for hcl files in the directory tree starting at workingDir, and formats them
// based on the language style guides provided by Hashicorp. This is done using the official hcl2 library.
package format

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"sync"

	"github.com/gruntwork-io/terragrunt/internal/component"
	"github.com/gruntwork-io/terragrunt/internal/filter"
	"github.com/gruntwork-io/terragrunt/pkg/config"
	"github.com/gruntwork-io/terragrunt/pkg/log"
	"github.com/gruntwork-io/terragrunt/pkg/log/writer"
	"github.com/rogpeppe/go-internal/diff"
	"golang.org/x/exp/slices"
	"golang.org/x/sync/errgroup"

	"errors"

	"github.com/hashicorp/hcl/v2/hclwrite"

	"github.com/gruntwork-io/terragrunt/internal/util"
	"github.com/gruntwork-io/terragrunt/pkg/config/hclparse"
	"github.com/gruntwork-io/terragrunt/pkg/options"
)

var excludePaths = []string{
	util.TerragruntCacheDir,
	util.DefaultBoilerplateDir,
	config.StackDir,
}

func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error {
	workingDir := opts.WorkingDir
	targetFile := opts.HclFile
	stdIn := opts.HclFromStdin

	if stdIn {
		if targetFile != "" {
			return errors.New("both stdin and path flags are specified")
		}

		return formatFromStdin(l, opts)
	}

	if targetFile != "" {
		if !filepath.IsAbs(targetFile) {
			targetFile = filepath.Join(workingDir, targetFile)
		}

		l.Debugf("Formatting hcl file at: %s.", targetFile)

		return formatTgHCL(l, opts, targetFile)
	}

	var (
		filters filter.Filters
		err     error
	)

	filters = opts.Filters

	// We use lightweight discovery here instead of the full discovery used by
	// the discovery package because we want to find non-comps like includes.
	files := []string{}

	err = filepath.WalkDir(workingDir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		basename := filepath.Base(path)
		if slices.Contains(excludePaths, basename) {
			l.Debugf("%s directory ignored by default", path)
			return filepath.SkipDir
		}

		if slices.Contains(opts.HclExclude, basename) {
			l.Debugf("%s directory ignored due to the %s flag", path, ExcludeDirFlagName)
			return filepath.SkipDir
		}

		if d.IsDir() {
			return nil
		}

		if !strings.HasSuffix(path, ".hcl") {
			return nil
		}

		files = append(files, path)

		return nil
	})
	if err != nil {
		return err
	}

	var components component.Components

	components, err = filters.EvaluateOnFiles(l, files, workingDir)
	if err != nil {
		return err
	}

	g, _ := errgroup.WithContext(ctx)

	// Use user-specified parallelism, falling back to available CPUs.
	limit := opts.Parallelism
	if limit == options.DefaultParallelism {
		limit = runtime.GOMAXPROCS(0)
	}

	g.SetLimit(limit)

	// Pre-allocate the errs slice with max possible length
	// so we don't need to hold a lock to append to it.
	errs := make([]error, len(components))

	for i, c := range components {
		g.Go(func() error {
			err := formatTgHCL(l, opts, c.Path())
			if err != nil {
				errs[i] = err
			}

			return nil
		})
	}

	_ = g.Wait()

	return errors.Join(errs...)
}

// RunForFiles formats only the specified files instead of walking the entire directory tree.
// Files may be relative paths (resolved against baseDir) or absolute paths.
// This is used by scaffold to format only the files that were generated by boilerplate.
func RunForFiles(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, baseDir string, files []string) error {
	g, _ := errgroup.WithContext(ctx)

	// Use user-specified parallelism, falling back to available CPUs.
	limit := opts.Parallelism
	if limit == options.DefaultParallelism {
		limit = runtime.GOMAXPROCS(0)
	}

	g.SetLimit(limit)

	var (
		mu   sync.Mutex
		errs []error
	)

	for _, file := range files {
		if !strings.HasSuffix(file, ".hcl") {
			continue
		}

		if !filepath.IsAbs(file) {
			file = filepath.Join(baseDir, file)
		}

		g.Go(func() error {
			if err := formatTgHCL(l, opts, file); err != nil {
				mu.Lock()

				errs = append(errs, err)
				mu.Unlock()
			}

			return nil
		})
	}

	_ = g.Wait()

	return errors.Join(errs...)
}

func formatFromStdin(l log.Logger, opts *options.TerragruntOptions) error {
	contents, err := io.ReadAll(os.Stdin)
	if err != nil {
		l.Errorf("Error reading from stdin: %s", err)

		return fmt.Errorf("error reading from stdin: %w", err)
	}

	if err = checkErrors(l, l.Formatter().DisabledColors(), contents, "stdin"); err != nil {
		l.Errorf("Error parsing hcl from stdin")

		return fmt.Errorf("error parsing hcl from stdin: %w", err)
	}

	newContents := hclwrite.Format(contents)

	buf := bufio.NewWriter(opts.Writers.Writer)

	if _, err = buf.Write(newContents); err != nil {
		l.Errorf("Failed to write to stdout")

		return fmt.Errorf("failed to write to stdout: %w", err)
	}

	if err = buf.Flush(); err != nil {
		l.Errorf("Failed to flush to stdout")

		return fmt.Errorf("failed to flush to stdout: %w", err)
	}

	return nil
}

// formatTgHCL uses the hcl2 library to format the hcl file. This will attempt to parse the HCL file first to
// ensure that there are no syntax errors, before attempting to format it.
func formatTgHCL(l log.Logger, opts *options.TerragruntOptions, tgHclFile string) error {
	l.Debugf("Formatting %s", tgHclFile)

	info, err := os.Stat(tgHclFile)
	if err != nil {
		l.Errorf("Error retrieving file info of %s", tgHclFile)
		return fmt.Errorf("failed to get file info for %s: %w", tgHclFile, err)
	}

	contents, err := os.ReadFile(tgHclFile)
	if err != nil {
		l.Errorf("Error reading %s", tgHclFile)
		return fmt.Errorf("failed to read %s: %w", tgHclFile, err)
	}

	err = checkErrors(l, l.Formatter().DisabledColors(), contents, tgHclFile)
	if err != nil {
		l.Errorf("Error parsing %s", tgHclFile)
		return err
	}

	newContents := hclwrite.Format(contents)

	fileUpdated := !bytes.Equal(newContents, contents)

	if opts.Diff && fileUpdated {
		if _, err := opts.Writers.Writer.Write(bytesDiff(contents, newContents, tgHclFile)); err != nil {
			l.Errorf("Failed to print diff for %s", tgHclFile)
			return err
		}
	}

	if opts.Check && fileUpdated {
		return &FileNeedsFormattingError{Path: tgHclFile}
	}

	if fileUpdated {
		l.Infof("%s was updated", tgHclFile)
		return os.WriteFile(tgHclFile, newContents, info.Mode())
	}

	return nil
}

// checkErrors takes in the contents of a hcl file and looks for syntax errors.
func checkErrors(l log.Logger, disableColor bool, contents []byte, tgHclFile string) error {
	parser := hclparse.NewParser()
	_, diags := parser.ParseHCL(contents, tgHclFile)

	writer := writer.New(writer.WithLogger(l), writer.WithDefaultLevel(log.ErrorLevel))
	diagWriter := parser.GetDiagnosticsWriter(writer, disableColor)

	err := diagWriter.WriteDiagnostics(diags)
	if err != nil {
		return err
	}

	if diags.HasErrors() {
		return diags
	}

	return nil
}

// bytesDiff returns a unified diff between the original and formatted HCL contents.
func bytesDiff(b1, b2 []byte, path string) []byte {
	return diff.Diff(filepath.Join("old", path), b1, filepath.Join("new", path), b2)
}
