// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package internal // import "go.opentelemetry.io/collector/cmd/mdatagen/internal"

import (
	"bytes"
	"errors"
	"fmt"
	"go/format"
	"io/fs"
	"os"
	"path/filepath"
	"regexp"
	"runtime/debug"
	"slices"
	"sort"
	"strings"
	"text/template"

	"github.com/spf13/cobra"
	"golang.org/x/text/cases"
	"golang.org/x/text/language"
	"gopkg.in/yaml.v3"
)

const (
	statusStart = "<!-- status autogenerated section -->"
	statusEnd   = "<!-- end autogenerated section -->"
)

var nonComponents = []string{
	"cmd",
	"converter",
	"pkg",
	"provider",
}

func getVersion() (string, error) {
	// the second returned value is a boolean, which is true if the binaries are built with module support.
	info, ok := debug.ReadBuildInfo()
	if !ok {
		return "", errors.New("could not read build info")
	}
	return info.Main.Version, nil
}

// NewCommand constructs a new cobra.Command using the given Settings.
// Any URIs specified in CollectorSettings.ConfigProviderSettings.ResolverSettings.URIs
// are considered defaults and will be overwritten by config flags passed as
// command-line arguments to the executable.
// At least one Provider must be set.
func NewCommand() (*cobra.Command, error) {
	ver, err := getVersion()
	if err != nil {
		return nil, err
	}
	rootCmd := &cobra.Command{
		Use:          "mdatagen",
		Version:      ver,
		SilenceUsage: true,
		RunE: func(_ *cobra.Command, args []string) error {
			return run(args[0])
		},
	}
	return rootCmd, nil
}

func run(ymlPath string) error {
	if ymlPath == "" {
		return errors.New("argument must be metadata.yaml file")
	}
	ymlPath, err := filepath.Abs(ymlPath)
	if err != nil {
		return fmt.Errorf("failed to get absolute path for %v: %w", ymlPath, err)
	}

	ymlDir := filepath.Dir(ymlPath)
	packageName := filepath.Base(ymlDir)

	raw, readErr := os.ReadFile(ymlPath) //nolint:gosec // G304: abs path is cleaned/validated above; safe to read
	if readErr != nil {
		return fmt.Errorf("failed reading %v: %w", ymlPath, readErr)
	}

	if err = validateYAMLKeyOrder(raw); err != nil {
		return fmt.Errorf("metadata.yaml ordering check failed: %w", err)
	}

	md, err := LoadMetadata(ymlPath)
	if err != nil {
		return fmt.Errorf("failed loading %v: %w", ymlPath, err)
	}

	tmplDir := "templates"

	codeDir := filepath.Join(ymlDir, "internal", md.GeneratedPackageName)
	toGenerate := map[string]string{}
	if md.Status != nil {
		if !slices.Contains(nonComponents, md.Status.Class) {
			toGenerate[filepath.Join(tmplDir, "status.go.tmpl")] = filepath.Join(codeDir, "generated_status.go")
			err = generateFile(filepath.Join(tmplDir, "component_test.go.tmpl"),
				filepath.Join(ymlDir, "generated_component_test.go"), md, packageName)
			if err != nil {
				return err
			}
		} else {
			if _, err = os.Stat(filepath.Join(codeDir, "generated_status.go")); err == nil {
				err = os.Remove(filepath.Join(codeDir, "generated_status.go"))
				if err != nil {
					return err
				}
			}
			if _, err = os.Stat(filepath.Join(ymlDir, "generated_component_test.go")); err == nil {
				err = os.Remove(filepath.Join(ymlDir, "generated_component_test.go"))
				if err != nil {
					return err
				}
			}
		}

		err = generateFile(filepath.Join(tmplDir, "package_test.go.tmpl"),
			filepath.Join(ymlDir, "generated_package_test.go"), md, packageName)
		if err != nil {
			return err
		}

		if _, err = os.Stat(filepath.Join(ymlDir, "README.md")); err == nil {
			err = inlineReplace(
				filepath.Join(tmplDir, "readme.md.tmpl"),
				filepath.Join(ymlDir, "README.md"),
				md, statusStart, statusEnd, md.GeneratedPackageName)
			if err != nil {
				return err
			}
		}
	}

	if len(md.Telemetry.Metrics) != 0 { // if there are telemetry metrics, generate telemetry specific files
		testDir := filepath.Join(ymlDir, "internal", md.GeneratedPackageName+"test")
		if err = os.MkdirAll(testDir, 0o700); err != nil {
			return fmt.Errorf("unable to create output test directory %q: %w", codeDir, err)
		}
		toGenerate[filepath.Join(tmplDir, "telemetry.go.tmpl")] = filepath.Join(codeDir, "generated_telemetry.go")
		toGenerate[filepath.Join(tmplDir, "telemetry_test.go.tmpl")] = filepath.Join(codeDir, "generated_telemetry_test.go")
		toGenerate[filepath.Join(tmplDir, "telemetrytest.go.tmpl")] = filepath.Join(testDir, "generated_telemetrytest.go")
		toGenerate[filepath.Join(tmplDir, "telemetrytest_test.go.tmpl")] = filepath.Join(testDir, "generated_telemetrytest_test.go")
	} else {
		if _, err = os.Stat(filepath.Join(ymlDir, "generated_telemetry.go")); err == nil {
			err = os.Remove(filepath.Join(ymlDir, "generated_telemetry.go"))
			if err != nil {
				return err
			}
		}
		if _, err = os.Stat(filepath.Join(ymlDir, "generated_telemetry_test.go")); err == nil {
			err = os.Remove(filepath.Join(ymlDir, "generated_telemetry_test.go"))
			if err != nil {
				return err
			}
		}
		if _, err = os.Stat(filepath.Join(ymlDir, "generated_telemetrytest.go")); err == nil {
			err = os.Remove(filepath.Join(ymlDir, "generated_telemetrytest.go"))
			if err != nil {
				return err
			}
		}
		if _, err = os.Stat(filepath.Join(ymlDir, "generated_telemetrytest_test.go")); err == nil {
			err = os.Remove(filepath.Join(ymlDir, "generated_telemetrytest_test.go"))
			if err != nil {
				return err
			}
		}
	}

	if len(md.Metrics) != 0 || len(md.Telemetry.Metrics) != 0 || len(md.ResourceAttributes) != 0 || len(md.Events) != 0 { // if there's metrics or internal metrics or events, generate documentation for them
		toGenerate[filepath.Join(tmplDir, "documentation.md.tmpl")] = filepath.Join(ymlDir, "documentation.md")
	}

	if len(md.Metrics) > 0 || len(md.Events) > 0 || len(md.ResourceAttributes) > 0 {
		testdataDir := filepath.Join(codeDir, "testdata")
		if err = os.MkdirAll(filepath.Join(codeDir, "testdata"), 0o700); err != nil {
			return fmt.Errorf("unable to create output directory %q: %w", testdataDir, err)
		}

		toGenerate[filepath.Join(tmplDir, "testdata", "config.yaml.tmpl")] = filepath.Join(testdataDir, "config.yaml")
		toGenerate[filepath.Join(tmplDir, "config.go.tmpl")] = filepath.Join(codeDir, "generated_config.go")
		toGenerate[filepath.Join(tmplDir, "config_test.go.tmpl")] = filepath.Join(codeDir, "generated_config_test.go")
	}

	if len(md.ResourceAttributes) > 0 { // only generate resource files if resource attributes are configured
		toGenerate[filepath.Join(tmplDir, "resource.go.tmpl")] = filepath.Join(codeDir, "generated_resource.go")
		toGenerate[filepath.Join(tmplDir, "resource_test.go.tmpl")] = filepath.Join(codeDir, "generated_resource_test.go")
	}

	if len(md.Metrics) > 0 { // only generate metrics if metrics are present
		toGenerate[filepath.Join(tmplDir, "metrics.go.tmpl")] = filepath.Join(codeDir, "generated_metrics.go")
		toGenerate[filepath.Join(tmplDir, "metrics_test.go.tmpl")] = filepath.Join(codeDir, "generated_metrics_test.go")
	}

	if md.supportsSignal("logs") &&
		(md.Status.Class == "receiver" || md.Status.Class == "scraper") {
		toGenerate[filepath.Join(tmplDir, "logs.go.tmpl")] = filepath.Join(codeDir, "generated_logs.go")
		toGenerate[filepath.Join(tmplDir, "logs_test.go.tmpl")] = filepath.Join(codeDir, "generated_logs_test.go")
	}

	// If at least one file to generate, will need the codeDir
	if len(toGenerate) > 0 {
		if err = os.MkdirAll(codeDir, 0o700); err != nil {
			return fmt.Errorf("unable to create output directory %q: %w", codeDir, err)
		}
	}

	for tmpl, dst := range toGenerate {
		if err := generateFile(tmpl, dst, md, md.GeneratedPackageName); err != nil {
			return err
		}
	}

	return nil
}

func templatize(tmplFile string, md Metadata) *template.Template {
	return template.Must(
		template.
			New(filepath.Base(tmplFile)).
			Option("missingkey=error").
			Funcs(map[string]any{
				"publicVar": func(s string) (string, error) {
					return FormatIdentifier(s, true)
				},
				"attributeInfo": func(an AttributeName) Attribute {
					return md.Attributes[an]
				},
				"getEventConditionalAttributes": func(attrs map[AttributeName]Attribute) []AttributeName {
					seen := make(map[AttributeName]bool)
					used := make([]AttributeName, 0)

					for _, event := range md.Events {
						for _, attribute := range event.Attributes {
							v, exists := attrs[attribute]
							if exists && v.IsConditional() && !seen[attribute] {
								used = append(used, attribute)
								seen[attribute] = true
							}
						}
					}
					sort.Slice(used, func(i, j int) bool { return string(used[i]) < string(used[j]) })

					return used
				},
				"getMetricConditionalAttributes": func(attrs map[AttributeName]Attribute) []AttributeName {
					seen := make(map[AttributeName]bool)
					used := make([]AttributeName, 0)

					for _, event := range md.Metrics {
						for _, attribute := range event.Attributes {
							v, exists := attrs[attribute]
							if exists && v.IsConditional() && !seen[attribute] {
								used = append(used, attribute)
								seen[attribute] = true
							}
						}
					}
					sort.Slice(used, func(i, j int) bool { return string(used[i]) < string(used[j]) })

					return used
				},
				"metricInfo": func(mn MetricName) Metric {
					return md.Metrics[mn]
				},
				"eventInfo": func(en EventName) Event {
					return md.Events[en]
				},
				"telemetryInfo": func(mn MetricName) Metric {
					return md.Telemetry.Metrics[mn]
				},
				"parseImportsRequired": func(metrics map[MetricName]Metric) bool {
					for _, m := range metrics {
						if m.Data().HasMetricInputType() {
							return true
						}
					}
					return false
				},
				"stringsJoin":  strings.Join,
				"stringsSplit": strings.Split,
				"userLinks": func(elems []string) []string {
					result := make([]string, len(elems))
					for i, elem := range elems {
						if elem == "open-telemetry/collector-approvers" {
							result[i] = "[@open-telemetry/collector-approvers](https://github.com/orgs/open-telemetry/teams/collector-approvers)"
						} else {
							result[i] = fmt.Sprintf("[@%s](https://www.github.com/%s)", elem, elem)
						}
					}
					return result
				},
				"casesTitle":  cases.Title(language.English).String,
				"toLowerCase": strings.ToLower,
				"toCamelCase": func(s string) string {
					caser := cases.Title(language.English).String
					parts := strings.Split(s, "_")
					var result strings.Builder
					for _, part := range parts {
						fmt.Fprintf(&result, "%s", caser(part))
					}
					return result.String()
				},
				"inc":       func(i int) int { return i + 1 },
				"distroURL": distroURL,
				"isExporter": func() bool {
					return md.Status.Class == "exporter"
				},
				"isProcessor": func() bool {
					return md.Status.Class == "processor"
				},
				"isReceiver": func() bool {
					return md.Status.Class == "receiver"
				},
				"isExtension": func() bool {
					return md.Status.Class == "extension"
				},
				"isConnector": func() bool {
					return md.Status.Class == "connector"
				},
				"isScraper": func() bool {
					return md.Status.Class == "scraper"
				},
				"isCommand": func() bool {
					return md.Status.Class == "cmd"
				},
				"supportsLogs":             func() bool { return md.supportsSignal("logs") },
				"supportsMetrics":          func() bool { return md.supportsSignal("metrics") },
				"supportsTraces":           func() bool { return md.supportsSignal("traces") },
				"supportsLogsToLogs":       func() bool { return md.supportsSignal("logs_to_logs") },
				"supportsLogsToMetrics":    func() bool { return md.supportsSignal("logs_to_metrics") },
				"supportsLogsToTraces":     func() bool { return md.supportsSignal("logs_to_traces") },
				"supportsMetricsToLogs":    func() bool { return md.supportsSignal("metrics_to_logs") },
				"supportsMetricsToMetrics": func() bool { return md.supportsSignal("metrics_to_metrics") },
				"supportsMetricsToTraces":  func() bool { return md.supportsSignal("metrics_to_traces") },
				"supportsTracesToLogs":     func() bool { return md.supportsSignal("traces_to_logs") },
				"supportsTracesToMetrics":  func() bool { return md.supportsSignal("traces_to_metrics") },
				"supportsTracesToTraces":   func() bool { return md.supportsSignal("traces_to_traces") },
				"expectConsumerError": func() bool {
					return md.Tests.ExpectConsumerError
				},
				// ParseFS delegates the parsing of the files to `Glob`
				// which uses the `\` as a special character.
				// Meaning on windows based machines, the `\` needs to be replaced
				// with a `/` for it to find the file.
			}).ParseFS(TemplateFS, "templates/helper.tmpl", strings.ReplaceAll(tmplFile, "\\", "/")))
}

func executeTemplate(tmplFile string, md Metadata, goPackage string) ([]byte, error) {
	tmpl := templatize(tmplFile, md)
	buf := bytes.Buffer{}

	if err := tmpl.Execute(&buf, TemplateContext{Metadata: md, Package: goPackage}); err != nil {
		return []byte{}, fmt.Errorf("failed executing template: %w", err)
	}
	return buf.Bytes(), nil
}

func inlineReplace(tmplFile, outputFile string, md Metadata, start, end, goPackage string) error {
	var readmeContents []byte
	var err error
	if readmeContents, err = os.ReadFile(outputFile); err != nil { //nolint:gosec
		return err
	}

	re := regexp.MustCompile(fmt.Sprintf("%s[\\s\\S]*%s", start, end))
	if !re.Match(readmeContents) {
		return nil
	}

	if md.GithubProject == "" {
		md.GithubProject = "open-telemetry/opentelemetry-collector-contrib"
	}

	buf, err := executeTemplate(tmplFile, md, goPackage)
	if err != nil {
		return err
	}

	s := re.ReplaceAllString(string(readmeContents), string(buf))
	if err := os.WriteFile(outputFile, []byte(s), 0o600); err != nil {
		return fmt.Errorf("failed writing %q: %w", outputFile, err)
	}

	return nil
}

func generateFile(tmplFile, outputFile string, md Metadata, goPackage string) error {
	if err := os.Remove(outputFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
		return fmt.Errorf("unable to remove generated file %q: %w", outputFile, err)
	}

	result, err := executeTemplate(tmplFile, md, goPackage)
	if err != nil {
		return err
	}
	var formatErr error
	if strings.HasSuffix(outputFile, ".go") {
		if formatted, err := format.Source(result); err == nil {
			result = formatted
		} else {
			formatErr = fmt.Errorf("failed formatting %s:%w", outputFile, err)
		}
	}

	if err := os.WriteFile(outputFile, result, 0o600); err != nil {
		return fmt.Errorf("failed writing %q: %w", outputFile, err)
	}

	return formatErr
}

func validateMappingKeysSorted(root *yaml.Node, path ...string) error {
	// unwrap doc
	n := root
	if n.Kind == yaml.DocumentNode && len(n.Content) > 0 {
		n = n.Content[0]
	}
	// follow path
	for _, seg := range path {
		if n.Kind != yaml.MappingNode {
			return nil
		}
		var next *yaml.Node
		for i := 0; i < len(n.Content); i += 2 {
			if n.Content[i].Value == seg {
				next = n.Content[i+1]
				break
			}
		}
		if next == nil {
			return nil
		}
		n = next
	}
	if n.Kind != yaml.MappingNode {
		return nil
	}

	// collect keys
	keys := make([]string, 0, len(n.Content)/2)
	for i := 0; i < len(n.Content); i += 2 {
		keys = append(keys, n.Content[i].Value)
	}

	if !slices.IsSorted(keys) {
		return fmt.Errorf("%v keys are not sorted: %v", path, keys)
	}
	return nil
}

// ValidateYAMLKeyOrder checks the sections we care about.
func validateYAMLKeyOrder(raw []byte) error {
	var doc yaml.Node
	if err := yaml.Unmarshal(raw, &doc); err != nil {
		return err
	}
	for _, p := range [][]string{
		{"resource_attributes"},
		{"entities"},
		{"attributes"},
		{"metrics"},
		{"events"},
		{"telemetry", "metrics"},
	} {
		if err := validateMappingKeysSorted(&doc, p...); err != nil {
			return err
		}
	}
	return nil
}
