/*
Copyright 2024 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

// Package scaffolds implements the scaffolding engine for the Helm v1-alpha plugin.
//
// Deprecated: The helm/v1alpha/scaffolds package is deprecated. Use [helm/v2alpha/scaffolds] instead.
// The new helm/v2-alpha plugin replaces the deprecated helm/v1-alpha and brings major improvements in
// flexibility and maintainability, championing changes driven by community feedback. Chart values are
// now better exposed, enabling easier customization and addressing long-standing issues.
// See [Helm Plugin (helm/v2-alpha)].
//
// This package is kept for compatibility and will eventually be removed.
//
// [helm/v2alpha/scaffolds]: https://pkg.go.dev/sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v2alpha/scaffolds
// [Helm Plugin (helm/v2-alpha)]: https://book.kubebuilder.io/plugins/available/helm-v2-alpha
package scaffolds

import (
	"errors"
	"fmt"
	log "log/slog"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"sigs.k8s.io/yaml"

	"sigs.k8s.io/kubebuilder/v4/pkg/config"
	"sigs.k8s.io/kubebuilder/v4/pkg/machinery"
	"sigs.k8s.io/kubebuilder/v4/pkg/plugin"
	"sigs.k8s.io/kubebuilder/v4/pkg/plugins"
	deployimagev1alpha1 "sigs.k8s.io/kubebuilder/v4/pkg/plugins/golang/deploy-image/v1alpha1"
	"sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v1alpha/scaffolds/internal/templates"
	charttemplates "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v1alpha/scaffolds/internal/templates/chart-templates"
	templatescertmanager "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v1alpha/scaffolds/internal/templates/chart-templates/cert-manager"
	"sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v1alpha/scaffolds/internal/templates/chart-templates/manager"
	templatesmetrics "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v1alpha/scaffolds/internal/templates/chart-templates/metrics"
	"sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v1alpha/scaffolds/internal/templates/chart-templates/prometheus"
	templateswebhooks "sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v1alpha/scaffolds/internal/templates/chart-templates/webhook"
	"sigs.k8s.io/kubebuilder/v4/pkg/plugins/optional/helm/v1alpha/scaffolds/internal/templates/github"
)

var _ plugins.Scaffolder = &editScaffolder{}

type editScaffolder struct {
	config config.Config

	fs machinery.Filesystem

	force bool
}

// NewHelmScaffolder returns a new Scaffolder for HelmPlugin
func NewHelmScaffolder(cfg config.Config, force bool) plugins.Scaffolder {
	return &editScaffolder{
		config: cfg,
		force:  force,
	}
}

// InjectFS implements cmdutil.Scaffolder
func (s *editScaffolder) InjectFS(fs machinery.Filesystem) {
	s.fs = fs
}

// Scaffold scaffolds the Helm chart with the necessary files.
func (s *editScaffolder) Scaffold() error {
	log.Info("Generating Helm Chart to distribute project")

	imagesEnvVars := s.getDeployImagesEnvVars()

	scaffold := machinery.NewScaffold(s.fs,
		machinery.WithConfig(s.config),
	)

	// Found webhooks by looking at the config our scaffolds files
	mutatingWebhooks, validatingWebhooks, err := s.extractWebhooksFromGeneratedFiles()
	if err != nil {
		return fmt.Errorf("failed to extract webhooks: %w", err)
	}
	hasWebhooks := hasWebhooksWith(s.config) || (len(mutatingWebhooks) > 0 && len(validatingWebhooks) > 0)

	buildScaffold := []machinery.Builder{
		&github.HelmChartCI{},
		&templates.HelmChart{},
		&templates.HelmValues{
			HasWebhooks:  hasWebhooks,
			DeployImages: imagesEnvVars,
			Force:        s.force,
		},
		&templates.HelmIgnore{},
		&charttemplates.HelmHelpers{},
		&manager.Deployment{
			Force:        s.force,
			DeployImages: len(imagesEnvVars) > 0,
			HasWebhooks:  hasWebhooks,
		},
		&templatescertmanager.Certificate{HasWebhooks: hasWebhooks},
		&templatesmetrics.Service{},
		&prometheus.Monitor{},
	}

	if len(mutatingWebhooks) > 0 || len(validatingWebhooks) > 0 {
		buildScaffold = append(buildScaffold,
			&templateswebhooks.Template{
				MutatingWebhooks:   mutatingWebhooks,
				ValidatingWebhooks: validatingWebhooks,
			},
		)
	}

	if hasWebhooks {
		buildScaffold = append(buildScaffold,
			&templateswebhooks.Service{},
		)
	}

	if err = scaffold.Execute(buildScaffold...); err != nil {
		return fmt.Errorf("error scaffolding helm-chart manifests: %w", err)
	}

	// Copy relevant files from config/ to dist/chart/templates/
	err = s.copyConfigFiles()
	if err != nil {
		return fmt.Errorf("failed to copy manifests from config to dist/chart/templates/: %w", err)
	}

	return nil
}

// getDeployImagesEnvVars will return the values to append the envvars for projects
// which has the APIs scaffolded with DeployImage plugin
func (s *editScaffolder) getDeployImagesEnvVars() map[string]string {
	deployImages := make(map[string]string)

	pluginConfig := struct {
		Resources []struct {
			Kind    string            `json:"kind"`
			Options map[string]string `json:"options"`
		} `json:"resources"`
	}{}

	key := plugin.GetPluginKeyForConfig(s.config.GetPluginChain(), deployimagev1alpha1.Plugin{})
	canonicalKey := plugin.KeyFor(deployimagev1alpha1.Plugin{})

	err := s.config.DecodePluginConfig(key, &pluginConfig)
	if err != nil {
		switch {
		case errors.As(err, &config.UnsupportedFieldError{}):
			return deployImages
		case errors.As(err, &config.PluginKeyNotFoundError{}):
			if key != canonicalKey {
				if err2 := s.config.DecodePluginConfig(canonicalKey, &pluginConfig); err2 != nil {
					if errors.As(err2, &config.UnsupportedFieldError{}) {
						return deployImages
					}
					if !errors.As(err2, &config.PluginKeyNotFoundError{}) {
						log.Warn("error decoding deploy-image configuration", "error", err2, "key", canonicalKey)
					}
				}
			}
		default:
			log.Warn("error decoding deploy-image configuration", "error", err, "key", key)
		}
	}

	for _, res := range pluginConfig.Resources {
		image, ok := res.Options["image"]
		if ok {
			deployImages[strings.ToUpper(res.Kind)] = image
		}
	}
	return deployImages
}

// extractWebhooksFromGeneratedFiles parses the files generated by controller-gen under
// config/webhooks and created Mutating and Validating helper structures to
// generate the webhook manifest for the helm-chart
func (s *editScaffolder) extractWebhooksFromGeneratedFiles() (mutatingWebhooks []templateswebhooks.DataWebhook,
	validatingWebhooks []templateswebhooks.DataWebhook, err error,
) {
	manifestFile := "config/webhook/manifests.yaml"

	if _, err = os.Stat(manifestFile); os.IsNotExist(err) {
		log.Info("webhook manifests were not found", "path", manifestFile)
		return nil, nil, nil
	}

	content, err := os.ReadFile(manifestFile)
	if err != nil {
		return nil, nil,
			fmt.Errorf("failed to read %q: %w", manifestFile, err)
	}

	docs := strings.SplitSeq(string(content), "---")
	for doc := range docs {
		var webhookConfig struct {
			Kind     string `yaml:"kind"`
			Webhooks []struct {
				Name         string `yaml:"name"`
				ClientConfig struct {
					Service struct {
						Name      string `yaml:"name"`
						Namespace string `yaml:"namespace"`
						Path      string `yaml:"path"`
					} `yaml:"service"`
				} `yaml:"clientConfig"`
				Rules                   []templateswebhooks.DataWebhookRule `yaml:"rules"`
				FailurePolicy           string                              `yaml:"failurePolicy"`
				SideEffects             string                              `yaml:"sideEffects"`
				AdmissionReviewVersions []string                            `yaml:"admissionReviewVersions"`
			} `yaml:"webhooks"`
		}

		if err := yaml.Unmarshal([]byte(doc), &webhookConfig); err != nil {
			log.Error("failed to unmarshal webhook YAML", "error", err)
			continue
		}

		for _, w := range webhookConfig.Webhooks {
			for i := range w.Rules {
				if len(w.Rules[i].APIGroups) == 0 {
					w.Rules[i].APIGroups = []string{""}
				}
			}
			webhook := templateswebhooks.DataWebhook{
				Name:                    w.Name,
				ServiceName:             fmt.Sprintf("%s-webhook-service", s.config.GetProjectName()),
				Path:                    w.ClientConfig.Service.Path,
				FailurePolicy:           w.FailurePolicy,
				SideEffects:             w.SideEffects,
				AdmissionReviewVersions: w.AdmissionReviewVersions,
				Rules:                   w.Rules,
			}

			switch webhookConfig.Kind {
			case "MutatingWebhookConfiguration":
				mutatingWebhooks = append(mutatingWebhooks, webhook)
			case "ValidatingWebhookConfiguration":
				validatingWebhooks = append(validatingWebhooks, webhook)
			}
		}
	}

	return mutatingWebhooks, validatingWebhooks, nil
}

const helmChartRBACSubDir = "rbac"

// Helper function to copy files from config/ to dist/chart/templates/
func (s *editScaffolder) copyConfigFiles() error {
	configDirs := []struct {
		SrcDir  string
		DestDir string
		SubDir  string
	}{
		{"config/rbac", "dist/chart/templates/rbac", helmChartRBACSubDir},
		{"config/crd/bases", "dist/chart/templates/crd", "crd"},
		{"config/network-policy", "dist/chart/templates/network-policy", "networkPolicy"},
	}

	for _, dir := range configDirs {
		// Check if the source directory exists
		if _, err := os.Stat(dir.SrcDir); os.IsNotExist(err) {
			// Skip if the source directory does not exist
			continue
		}

		files, err := filepath.Glob(filepath.Join(dir.SrcDir, "*.yaml"))
		if err != nil {
			return fmt.Errorf("failed finding files in %q: %w", dir.SrcDir, err)
		}

		// Skip processing if the directory is empty (no matching files)
		if len(files) == 0 {
			continue
		}

		// Ensure destination directory exists
		if err := os.MkdirAll(dir.DestDir, 0o755); err != nil {
			return fmt.Errorf("failed to create directory %q: %w", dir.DestDir, err)
		}

		for _, srcFile := range files {
			destFile := filepath.Join(dir.DestDir, filepath.Base(srcFile))

			hasConvertionalWebhook := false
			if hasWebhooksWith(s.config) {
				resources, err := s.config.GetResources()
				if err != nil {
					break
				}
				for _, res := range resources {
					if res.HasConversionWebhook() {
						hasConvertionalWebhook = true
						break
					}
				}
			}

			err := copyFileWithHelmLogic(srcFile, destFile, dir.SubDir, s.config.GetProjectName(), hasConvertionalWebhook)
			if err != nil {
				return err
			}
		}
	}

	return nil
}

// copyFileWithHelmLogic reads the source file, modifies the content for Helm, applies patches
// to spec.conversion if applicable, and writes it to the destination
func copyFileWithHelmLogic(srcFile, destFile, subDir, projectName string, hasConvertionalWebhook bool) error {
	if _, err := os.Stat(srcFile); os.IsNotExist(err) {
		log.Info("Source file does not exist", "source_file", srcFile)
		return fmt.Errorf("source file does not exist %q: %w", srcFile, err)
	}

	content, err := os.ReadFile(srcFile)
	if err != nil {
		log.Info("Error reading source file", "source_file", srcFile)
		return fmt.Errorf("failed to read file %q: %w", srcFile, err)
	}

	contentStr := string(content)

	// Skip kustomization.yaml or kustomizeconfig.yaml files
	if strings.HasSuffix(srcFile, "kustomization.yaml") ||
		strings.HasSuffix(srcFile, "kustomizeconfig.yaml") {
		return nil
	}

	// Apply RBAC-specific replacements
	if subDir == "rbac" {
		contentStr = strings.ReplaceAll(contentStr,
			"name: controller-manager",
			"name: {{ .Values.controllerManager.serviceAccountName }}")
		contentStr = strings.Replace(contentStr,
			"name: metrics-reader",
			fmt.Sprintf("name: %s-metrics-reader", projectName), 1)

		contentStr = strings.ReplaceAll(contentStr,
			"name: metrics-auth-role",
			fmt.Sprintf("name: %s-metrics-auth-role", projectName))
		contentStr = strings.Replace(contentStr,
			"name: metrics-auth-rolebinding",
			fmt.Sprintf("name: %s-metrics-auth-rolebinding", projectName), 1)

		if strings.Contains(contentStr, ".Values.controllerManager.serviceAccountName") &&
			strings.Contains(contentStr, "kind: ServiceAccount") &&
			!strings.Contains(contentStr, "RoleBinding") {
			// The generated Service Account does not have the annotations field so we must add it.
			contentStr = strings.Replace(contentStr,
				"metadata:", `metadata:
  {{- if and .Values.controllerManager.serviceAccount .Values.controllerManager.serviceAccount.annotations }}
  annotations:
    {{- range $key, $value := .Values.controllerManager.serviceAccount.annotations }}
    {{ $key }}: {{ $value }}
    {{- end }}
  {{- end }}`, 1)
		}
		contentStr = strings.ReplaceAll(contentStr,
			"name: leader-election-role",
			fmt.Sprintf("name: %s-leader-election-role", projectName))
		contentStr = strings.Replace(contentStr,
			"name: leader-election-rolebinding",
			fmt.Sprintf("name: %s-leader-election-rolebinding", projectName), 1)
		contentStr = strings.ReplaceAll(contentStr,
			"name: manager-role",
			fmt.Sprintf("name: %s-manager-role", projectName))
		contentStr = strings.Replace(contentStr,
			"name: manager-rolebinding",
			fmt.Sprintf("name: %s-manager-rolebinding", projectName), 1)

		// The generated files do not include the namespace
		if strings.Contains(contentStr, "leader-election-rolebinding") ||
			strings.Contains(contentStr, "leader-election-role") {
			namespace := `
  namespace: {{ .Release.Namespace }}`
			contentStr = strings.Replace(contentStr, "metadata:", "metadata:"+namespace, 1)
		}
	}

	// Conditionally handle CRD patches and annotations for CRDs
	if subDir == "crd" {
		kind, group := extractKindAndGroupFromFileName(filepath.Base(srcFile))
		hasWebhookPatch := false

		// Retrieve patch content for the CRD's spec.conversion, if it exists
		patchContent, patchExists, errPatch := getCRDPatchContent(kind, group)
		if errPatch != nil {
			return errPatch
		}

		// If patch content exists, inject it under spec.conversion with Helm conditional
		if patchExists {
			conversionSpec := extractConversionSpec(patchContent)
			// Projects scaffolded with old Kubebuilder versions does not have the conversion
			// webhook properly generated because before 4.4.0 this feature was not fully addressed.
			// The patch was added by default when should not. See the related fixes:
			//
			// Issue fixed in release 4.3.1: (which will cause the injection of webhook conditionals for projects without
			// conversion webhooks)
			// (kustomize/v2, go/v4): Corrected the generation of manifests under config/crd/patches
			// to ensure the /convert service patch is only created for webhooks configured with --conversion. (#4280)
			//
			// Conversion webhook fully fixed in release 4.4.0:
			// (kustomize/v2, go/v4): Fixed CA injection for conversion webhooks. Previously, the CA injection
			// was applied incorrectly to all CRDs instead of only conversion types. The issue dates back to release 3.5.0
			// due to kustomize/v2-alpha changes. Now, conversion webhooks are properly generated. (#4254, #4282)
			if len(conversionSpec) > 0 && !hasConvertionalWebhook {
				log.Warn("\n" +
					"============================================================\n" +
					"| [WARNING] Webhook Patch Issue Detected                   |\n" +
					"============================================================\n" +
					"Webhook patch found, but no conversion webhook is configured for this project.\n\n" +
					"Note: Older scaffolds have an issue where the conversion webhook patch was \n" +
					"      scaffolded by default, and conversion webhook injection was not properly limited \n" +
					"      to specific CRDs.\n\n" +
					"Recommended Action:\n" +
					"   - Upgrade your project to the latest available version.\n" +
					"   - Consider using the 'alpha generate' command.\n\n" +
					"The cert-manager injection and webhook conversion patch found for CRDs will\n" +
					"be skipped and NOT added to the Helm chart.\n" +
					"============================================================")

				hasWebhookPatch = false
			} else {
				contentStr = injectConversionSpecWithCondition(contentStr, conversionSpec)
				hasWebhookPatch = true
			}
		}

		// Inject annotations after "annotations:" in a single block without extra spaces
		contentStr = injectAnnotations(contentStr, hasWebhookPatch)
	}

	// Remove existing labels if necessary
	contentStr = removeLabels(contentStr)

	// Replace namespace with Helm template variable
	contentStr = strings.ReplaceAll(contentStr, "namespace: system", "namespace: {{ .Release.Namespace }}")

	contentStr = strings.Replace(contentStr, "metadata:", `metadata:
  labels:
    {{- include "chart.labels" . | nindent 4 }}`, 1)

	// Append project name to webhook service name
	contentStr = strings.ReplaceAll(contentStr, "name: webhook-service", "name: "+projectName+"-webhook-service")

	var wrappedContent string
	if isMetricRBACFile(subDir, srcFile) {
		wrappedContent = fmt.Sprintf(
			"{{- if and .Values.rbac.enable .Values.metrics.enable }}\n%s{{- end -}}\n", contentStr)
	} else {
		wrappedContent = fmt.Sprintf(
			"{{- if .Values.%s.enable }}\n%s{{- end -}}\n", subDir, contentStr)
	}

	if err = os.MkdirAll(filepath.Dir(destFile), 0o755); err != nil {
		return fmt.Errorf("error creating directory %q: %w", filepath.Dir(destFile), err)
	}

	err = os.WriteFile(destFile, []byte(wrappedContent), 0o644)
	if err != nil {
		log.Info("Error writing destination file", "destination_file", destFile)
		return fmt.Errorf("error writing destination file %q: %w", destFile, err)
	}

	log.Info("Successfully copied file", "from", srcFile, "to", destFile)
	return nil
}

// extractKindAndGroupFromFileName extracts the kind and group from a CRD filename
func extractKindAndGroupFromFileName(fileName string) (kind, group string) {
	parts := strings.Split(fileName, "_")
	if len(parts) >= 2 {
		group = strings.Split(parts[0], ".")[0] // Extract group up to the first dot
		kind = strings.TrimSuffix(parts[1], ".yaml")
	}
	return kind, group
}

// getCRDPatchContent finds and reads the appropriate patch content for a given kind and group
func getCRDPatchContent(kind, group string) (string, bool, error) {
	// First, look for patches that contain both "webhook", the group, and kind in their filename
	groupKindPattern := fmt.Sprintf("config/crd/patches/webhook_*%s*%s*.yaml", group, kind)
	patchFiles, err := filepath.Glob(groupKindPattern)
	if err != nil {
		return "", false, fmt.Errorf("failed to list patches: %w", err)
	}

	// If no group-specific patch found, search for patches that contain only "webhook" and the kind
	if len(patchFiles) == 0 {
		kindOnlyPattern := fmt.Sprintf("config/crd/patches/webhook_*%s*.yaml", kind)
		patchFiles, err = filepath.Glob(kindOnlyPattern)
		if err != nil {
			return "", false, fmt.Errorf("failed to list patches: %w", err)
		}
	}

	// Read the first matching patch file (if any)
	if len(patchFiles) > 0 {
		patchContent, err := os.ReadFile(patchFiles[0])
		if err != nil {
			return "", false, fmt.Errorf("failed to read patch file %q: %w", patchFiles[0], err)
		}
		return string(patchContent), true, nil
	}

	return "", false, nil
}

// extractConversionSpec extracts only the conversion section from the patch content
func extractConversionSpec(patchContent string) string {
	specStart := strings.Index(patchContent, "conversion:")
	if specStart == -1 {
		return ""
	}
	return patchContent[specStart:]
}

// injectConversionSpecWithCondition inserts the conversion spec under the main spec field with Helm conditional
func injectConversionSpecWithCondition(contentStr, conversionSpec string) string {
	specPosition := strings.Index(contentStr, "spec:")
	if specPosition == -1 {
		return contentStr // No spec field found; return unchanged
	}
	conditionalSpec := fmt.Sprintf("\n  {{- if .Values.webhook.enable }}\n  %s\n  {{- end }}",
		strings.TrimRight(conversionSpec, "\n"))
	return contentStr[:specPosition+5] + conditionalSpec + contentStr[specPosition+5:]
}

// injectAnnotations inserts the required annotations after the "annotations:" field in a single block without
// extra spaces
func injectAnnotations(contentStr string, hasWebhookPatch bool) string {
	annotationsBlock := `
    {{- if .Values.certmanager.enable }}
    cert-manager.io/inject-ca-from: "{{ .Release.Namespace }}/serving-cert"
    {{- end }}
    {{- if .Values.crd.keep }}
    "helm.sh/resource-policy": keep
    {{- end }}`
	if hasWebhookPatch {
		return strings.Replace(contentStr, "annotations:", "annotations:"+annotationsBlock, 1)
	}

	// Apply only resource policy if no webhook patch
	resourcePolicy := `
    {{- if .Values.crd.keep }}
    "helm.sh/resource-policy": keep
    {{- end }}`
	return strings.Replace(contentStr, "annotations:", "annotations:"+resourcePolicy, 1)
}

// isMetricRBACFile checks if the file is in the "rbac"
// subdirectory and matches one of the metric-related RBAC filenames
func isMetricRBACFile(subDir, srcFile string) bool {
	return subDir == "rbac" && (strings.HasSuffix(srcFile, "metrics_auth_role.yaml") ||
		strings.HasSuffix(srcFile, "metrics_auth_role_binding.yaml") ||
		strings.HasSuffix(srcFile, "metrics_reader_role.yaml"))
}

// removeLabels removes any existing labels section from the content
func removeLabels(content string) string {
	labelRegex := `(?m)^  labels:\n(?:    [^\n]+\n)*`
	re := regexp.MustCompile(labelRegex)

	return re.ReplaceAllString(content, "")
}

func hasWebhooksWith(c config.Config) bool {
	// Get the list of resources
	resources, err := c.GetResources()
	if err != nil {
		return false // If there's an error getting resources, assume no webhooks
	}

	for _, res := range resources {
		if res.HasDefaultingWebhook() || res.HasValidationWebhook() || res.HasConversionWebhook() {
			return true
		}
	}

	return false
}
