// 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"
	"go.yaml.in/yaml/v3"
	"golang.org/x/text/cases"
	"golang.org/x/text/language"

	"go.opentelemetry.io/collector/cmd/mdatagen/internal/cfggen"
	"go.opentelemetry.io/collector/cmd/mdatagen/internal/helpers"
)

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,
		Args:         cobra.ExactArgs(1),
		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)
	importRootPath, err := helpers.RootPackage(ymlDir)
	if err != nil {
		return fmt.Errorf("unable to determine import root path: %w", err)
	}

	raw, readErr := os.ReadFile(filepath.Clean(ymlPath))
	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, importRootPath)
			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, importRootPath)
		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, importRootPath)
			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 || len(md.FeatureGates) != 0 { // if there's metrics or internal metrics or events or feature gates, 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")
		toGenerate[filepath.Join(tmplDir, "config.schema.yaml.tmpl")] = filepath.Join(codeDir, "config.schema.yaml")
	}

	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 len(md.FeatureGates) > 0 { // only generate feature gates if feature gates are present
		toGenerate[filepath.Join(tmplDir, "feature_gates.go.tmpl")] = filepath.Join(codeDir, "generated_feature_gates.go")
	}

	if len(md.Entities) > 0 && len(md.Metrics) > 0 { // only generate entity metrics if entities are defined
		toGenerate[filepath.Join(tmplDir, "entity_metrics.go.tmpl")] = filepath.Join(codeDir, "generated_entity_metrics.go")
		toGenerate[filepath.Join(tmplDir, "entity_metrics_test.go.tmpl")] = filepath.Join(codeDir, "generated_entity_metrics_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, importRootPath); err != nil {
			return err
		}
	}

	if err := generateConfigFiles(md, ymlDir, importRootPath); err != nil {
		return fmt.Errorf("failed to generate config files: %w", err)
	}

	return nil
}

func getTemplateFuncMap(md Metadata, importRootPath string) template.FuncMap {
	return template.FuncMap{
		"publicVar": func(s string) (string, error) {
			return helpers.FormatIdentifier(s, true)
		},
		"attributeInfo": func(an AttributeName) Attribute {
			return md.Attributes[an]
		},
		"defaultAttributes": func(ans []AttributeName) []string {
			var atts []string
			for _, an := range ans {
				if md.Attributes[an].IsNotOptIn() {
					atts = append(atts, string(md.Attributes[an].Name()))
				}
			}
			return atts
		},
		"requiredAttributes": func(ans []AttributeName) []string {
			var atts []string
			for _, an := range ans {
				if md.Attributes[an].IsRequired() {
					atts = append(atts, string(md.Attributes[an].Name()))
				}
			}
			return atts
		},
		"hasAggregatableAttributes": func(ans []AttributeName) bool {
			for _, an := range ans {
				if md.Attributes[an].RequirementLevel == AttributeRequirementLevelRecommended ||
					md.Attributes[an].RequirementLevel == AttributeRequirementLevelOptIn {
					return true
				}
			}
			return false
		},
		"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 {
			return joinCamelCase(strings.Split(s, "_"), true)
		},
		"toLowerCamelCase": func(s string) string {
			return joinCamelCase(strings.Split(s, "_"), false)
		},
		"schemaRef": func(ref string) string {
			return cfggen.LocalizeRef(ref, importRootPath)
		},
		"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") },
		"supportsProfiles":           func() bool { return md.supportsSignal("profiles") },
		"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") },
		"supportsLogsToProfiles":     func() bool { return md.supportsSignal("logs_to_profiles") },
		"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") },
		"supportsMetricsToProfiles":  func() bool { return md.supportsSignal("metrics_to_profiles") },
		"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") },
		"supportsTracesToProfiles":   func() bool { return md.supportsSignal("traces_to_profiles") },
		"supportsProfilesToLogs":     func() bool { return md.supportsSignal("profiles_to_logs") },
		"supportsProfilesToMetrics":  func() bool { return md.supportsSignal("profiles_to_metrics") },
		"supportsProfilesToTraces":   func() bool { return md.supportsSignal("profiles_to_traces") },
		"supportsProfilesToProfiles": func() bool { return md.supportsSignal("profiles_to_profiles") },
		"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.
	}
}

func templatize(tmplFile string, funcMap template.FuncMap) *template.Template {
	return template.Must(
		template.
			New(filepath.Base(tmplFile)).
			Option("missingkey=error").
			Funcs(funcMap).
			ParseFS(TemplateFS, "templates/helper.tmpl", strings.ReplaceAll(tmplFile, "\\", "/")))
}

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

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

func generateFile(tmplFile, outputFile string, md Metadata, goPackage, importRootPath string) error {
	return generateFileWithFns(tmplFile, outputFile, md, goPackage, importRootPath, getTemplateFuncMap(md, importRootPath))
}

func inlineReplace(tmplFile, outputFile string, md Metadata, start, end, goPackage, importRootPath string) error {
	var readmeContents []byte
	var err error
	if readmeContents, err = os.ReadFile(filepath.Clean(outputFile)); err != nil {
		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, importRootPath, getTemplateFuncMap(md, importRootPath))
	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 generateFileWithFns(tmplFile, outputFile string, md Metadata, goPackage, importRootPath string, fns template.FuncMap) 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, importRootPath, fns)
	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
}

func generateConfigFiles(md Metadata, mdDir, _ string) error {
	if md.Config != nil {
		resolver := cfggen.NewResolver(md.PackageName, md.Status.Class, md.Type, mdDir)
		resolvedSchema, err := resolver.ResolveSchema(md.Config)
		if err != nil {
			return fmt.Errorf("failed to resolve config schema: %w", err)
		}

		err = cfggen.WriteJSONSchema(mdDir, resolvedSchema)
		if err != nil {
			return fmt.Errorf("failed to write config schema: %w", err)
		}

		if err := generateConfigGoStruct(md, mdDir); err != nil {
			return fmt.Errorf("failed to generate config Go struct: %w", err)
		}
	}
	return nil
}

func generateConfigGoStruct(md Metadata, outputDir string) error {
	rootPkg, err := helpers.RootPackage(outputDir)
	if err != nil {
		return fmt.Errorf("unable to determine root package: %w", err)
	}

	packageName := filepath.Base(outputDir)
	tmplFile := filepath.Join("templates", "config_from_cfggen.go.tmpl")
	dstFile := filepath.Join(outputDir, "generated_config.go")

	fns := cfggen.WithCfgFns(getTemplateFuncMap(md, rootPkg), rootPkg, md.PackageName)
	return generateFileWithFns(tmplFile, dstFile, md, packageName, rootPkg, fns)
}

func joinCamelCase(parts []string, exported bool) string {
	caser := cases.Title(language.English).String
	var result strings.Builder
	for i, part := range parts {
		if i == 0 && !exported {
			fmt.Fprintf(&result, "%s", strings.ToLower(part))
		} else {
			fmt.Fprintf(&result, "%s", caser(part))
		}
	}
	return result.String()
}
