/*
Copyright © 2022 - 2025 SUSE LLC

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 controllers

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"sort"
	"strings"

	elementalv1 "github.com/rancher/elemental-operator/api/v1beta1"
	"github.com/rancher/elemental-operator/pkg/log"
	"github.com/rancher/elemental-operator/pkg/util"
	fleetv1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
	upgradev1 "github.com/rancher/system-upgrade-controller/pkg/apis/upgrade.cattle.io/v1"
	"github.com/rancher/wrangler/v2/pkg/name"
	corev1 "k8s.io/api/core/v1"
	rbacv1 "k8s.io/api/rbac/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	meta "k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	errorutils "k8s.io/apimachinery/pkg/util/errors"
	"k8s.io/utils/ptr"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/reconcile"
)

const (
	rancherSystemNamespace = "cattle-system"
	fleetLocalNamespace    = "fleet-local"
)

var dnsLabelRegex = regexp.MustCompile("[^a-zA-Z0-9- ]+")

// ManagedOSImageReconciler reconciles a ManagedOSImage object.
type ManagedOSImageReconciler struct {
	client.Client
	DefaultRegistry string
	Scheme          *runtime.Scheme
}

// +kubebuilder:rbac:groups=elemental.cattle.io,resources=managedosimages,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=elemental.cattle.io,resources=managedosimages/status,verbs=get;update;patch;list
// +kubebuilder:rbac:groups=elemental.cattle.io,resources=managedosversions,verbs=get;list;watch
// +kubebuilder:rbac:groups="fleet.cattle.io",resources=bundles,verbs=create;get;update;list;watch

func (r *ManagedOSImageReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&elementalv1.ManagedOSImage{}).
		Owns(&fleetv1.Bundle{}).
		Complete(r)
}

func (r *ManagedOSImageReconciler) Reconcile(ctx context.Context, req reconcile.Request) (ctrl.Result, error) { //nolint:dupl
	logger := ctrl.LoggerFrom(ctx)

	managedOSImage := &elementalv1.ManagedOSImage{}
	err := r.Get(ctx, req.NamespacedName, managedOSImage)
	if err != nil {
		if apierrors.IsNotFound(err) {
			logger.V(log.DebugDepth).Info("Object was not found, not an error")
			return reconcile.Result{}, nil
		}
		return reconcile.Result{}, fmt.Errorf("failed to get managed OS image object: %w", err)
	}

	// Ensure we patch the latest version otherwise we could erratically overlap with other controllers (e.g. backup and restore)
	patchBase := client.MergeFromWithOptions(managedOSImage.DeepCopy(), client.MergeFromWithOptimisticLock{})

	// We have to sanitize the conditions because old API definitions didn't have proper validation.
	managedOSImage.Status.Conditions = util.RemoveInvalidConditions(managedOSImage.Status.Conditions)

	// Collect errors as an aggregate to return together after all patches have been performed.
	var errs []error

	result, err := r.reconcile(ctx, managedOSImage)
	if err != nil {
		errs = append(errs, fmt.Errorf("error reconciling managed OS image object: %w", err))
	}

	managedosimageStatusCopy := managedOSImage.Status.DeepCopy() // Patch call will erase the status

	if err := r.Patch(ctx, managedOSImage, patchBase); err != nil && !apierrors.IsNotFound(err) {
		errs = append(errs, fmt.Errorf("failed to patch managed OS image object: %w", err))
	}

	managedOSImage.Status = *managedosimageStatusCopy

	if err := r.Status().Patch(ctx, managedOSImage, patchBase); err != nil && !apierrors.IsNotFound(err) {
		errs = append(errs, fmt.Errorf("failed to patch status for managed OS image object: %w", err))
	}

	if len(errs) > 0 {
		return ctrl.Result{}, errorutils.NewAggregate(errs)
	}

	return result, nil
}

func (r *ManagedOSImageReconciler) reconcile(ctx context.Context, managedOSImage *elementalv1.ManagedOSImage) (ctrl.Result, error) {
	logger := ctrl.LoggerFrom(ctx)

	logger.Info("Reconciling managed OS image object")

	if managedOSImage.Namespace == fleetLocalNamespace && len(managedOSImage.Spec.Targets) > 0 { // TODO: this should be a part of validation webhook
		return ctrl.Result{}, errors.New("spec.targets should be empty if in the fleet-local namespace")
	}

	bundleResources, err := r.newFleetBundleResources(ctx, managedOSImage)
	if err != nil {
		return ctrl.Result{}, fmt.Errorf("failed to create fleet bundle resources: %w", err)
	}

	// Create a new Fleet bundle if we didn't do it before. Otherwise update it.
	if meta.IsStatusConditionTrue(managedOSImage.Status.Conditions, elementalv1.FleetBundleCreation) {
		logger.Info("Fleet bundle already exists")
		if err := r.updateFleetBundle(ctx, managedOSImage, bundleResources); err != nil {
			return ctrl.Result{}, fmt.Errorf("updating Bundle from ManagedOSImage: %w", err)
		}
	} else {
		if err := r.createFleetBundle(ctx, managedOSImage, bundleResources); err != nil {
			meta.SetStatusCondition(&managedOSImage.Status.Conditions, metav1.Condition{
				Type:    elementalv1.FleetBundleCreation,
				Reason:  elementalv1.FleetBundleCreateFailureReason,
				Status:  metav1.ConditionFalse,
				Message: err.Error(),
			})
			return ctrl.Result{}, fmt.Errorf("creating Bundle from ManagedOSImage: %w", err)
		}
	}

	if err := r.updateManagedOSImageStatus(ctx, managedOSImage); err != nil {
		return ctrl.Result{}, fmt.Errorf("failed to update managed OS image status: %w", err)
	}

	return ctrl.Result{}, nil
}

func (r *ManagedOSImageReconciler) newFleetBundleResources(ctx context.Context, managedOSImage *elementalv1.ManagedOSImage) ([]fleetv1.BundleResource, error) {
	cloudConfig, err := getCloudConfig(managedOSImage)
	if err != nil {
		return nil, err
	}

	concurrency := int64(1)
	if managedOSImage.Spec.Concurrency != nil { // TODO: this should be a part of defaulting webhook
		concurrency = *managedOSImage.Spec.Concurrency
	}

	cordon := true // TODO: we probably should not default boolean to true
	if managedOSImage.Spec.Cordon != nil {
		cordon = *managedOSImage.Spec.Cordon
	}

	var managedOSVersion *elementalv1.ManagedOSVersion

	m := make(map[string]runtime.RawExtension)

	// if a managedOS version was specified, we fetch it for later use and store the metadata
	if managedOSImage.Spec.ManagedOSVersionName != "" {
		managedOSVersion = &elementalv1.ManagedOSVersion{}
		if err := r.Get(ctx, types.NamespacedName{
			Namespace: managedOSImage.Namespace,
			Name:      managedOSImage.Spec.ManagedOSVersionName,
		}, managedOSVersion); err != nil {
			return nil, fmt.Errorf("failed to get managedOSVersion: %w", err)
		}
		m = managedOSVersion.Spec.Metadata

		// Add a label that can be used to List all ManagedOSImages referencing a certain ManagedOSVersion.
		if managedOSImage.ObjectMeta.Labels == nil {
			managedOSImage.ObjectMeta.Labels = map[string]string{}
		}
		managedOSImage.ObjectMeta.Labels[elementalv1.ElementalManagedOSImageVersionNameLabel] = managedOSImage.Spec.ManagedOSVersionName
	}

	// Entire logic from below is carried from the old code.
	// XXX Issues currently standing:
	// - minVersion is not respected:
	//	 gate minVersion that are not passing validation checks with the version reported
	// - Monitoring upgrade status from the fleet bundles (reconcile to update the status to report what is the current version )
	// - Enforce a ManagedOSImage "version" that is applied to a one node only. Or check out if either fleet is already doing that

	image, version, err := getImageVersion(managedOSImage, managedOSVersion)
	if err != nil {
		return nil, err
	}

	selector := managedOSImage.Spec.NodeSelector
	if selector == nil {
		selector = &metav1.LabelSelector{}
	}

	upgradeContainerSpec := managedOSImage.Spec.UpgradeContainer
	if managedOSVersion != nil && upgradeContainerSpec == nil {
		upgradeContainerSpec = managedOSVersion.Spec.UpgradeContainer
	}

	if upgradeContainerSpec == nil {
		upgradeContainerSpec = &upgradev1.ContainerSpec{}
	}

	if upgradeContainerSpec.Image == "" {
		upgradeContainerSpec.Image = prefixPrivateRegistry(image, r.DefaultRegistry)
	}

	if len(upgradeContainerSpec.Command) == 0 {
		upgradeContainerSpec.Command = []string{"/usr/sbin/suc-upgrade"}
	}

	// Encode metadata from the spec as environment in the upgrade spec pod
	metadataEnv := metadataEnv(m)

	// metadata envs overwrites any other specified
	keys := map[string]interface{}{}
	for _, e := range metadataEnv {
		keys[e.Name] = nil
	}

	for _, e := range upgradeContainerSpec.Env {
		if _, ok := keys[e.Name]; !ok {
			metadataEnv = append(metadataEnv, e)
		}
	}

	sort.Slice(metadataEnv, func(i, j int) bool {
		dat := []string{metadataEnv[i].Name, metadataEnv[j].Name}
		sort.Strings(dat)
		return dat[0] == metadataEnv[i].Name
	})

	upgradeContainerSpec.Env = metadataEnv

	uniqueName := name.SafeConcatName("os-upgrader", managedOSImage.Name)

	// The system-upgrade-controller will use the Plan name to eventually create a Volume.
	// To keep things smooth and not bother the end user with excessive name validation,
	// we just do a safe name conversion here.
	uniqueName = toDNSLabel(uniqueName)

	objs := []runtime.Object{
		&rbacv1.ClusterRole{
			ObjectMeta: metav1.ObjectMeta{
				Name: uniqueName,
			},
			Rules: []rbacv1.PolicyRule{
				{
					Verbs:     []string{"update", "get", "list", "watch", "patch"},
					APIGroups: []string{""},
					Resources: []string{"nodes"},
				},
				{
					Verbs:     []string{"get", "list"},
					APIGroups: []string{""},
					Resources: []string{"pods"},
				},
				{
					Verbs:     []string{"get"},
					APIGroups: []string{"apps"},
					Resources: []string{"daemonsets"},
				},
				{
					Verbs:     []string{"create"},
					APIGroups: []string{""},
					Resources: []string{"pods/eviction"},
				},
			},
		},
		&rbacv1.ClusterRoleBinding{
			ObjectMeta: metav1.ObjectMeta{
				Name: uniqueName,
			},
			Subjects: []rbacv1.Subject{{
				Kind:      "ServiceAccount",
				Name:      uniqueName,
				Namespace: rancherSystemNamespace,
			}},
			RoleRef: rbacv1.RoleRef{
				APIGroup: rbacv1.GroupName,
				Kind:     "ClusterRole",
				Name:     uniqueName,
			},
		},
		&corev1.ServiceAccount{
			ObjectMeta: metav1.ObjectMeta{
				Name:      uniqueName,
				Namespace: rancherSystemNamespace,
			},
		},
		&corev1.Secret{
			ObjectMeta: metav1.ObjectMeta{
				Name:      uniqueName,
				Namespace: rancherSystemNamespace,
			},
			Data: map[string][]byte{
				"cloud-config": cloudConfig,
			},
		},
		&upgradev1.Plan{
			TypeMeta: metav1.TypeMeta{
				Kind:       "Plan",
				APIVersion: "upgrade.cattle.io/v1",
			},
			ObjectMeta: metav1.ObjectMeta{
				Name:      uniqueName,
				Namespace: rancherSystemNamespace,
			},
			Spec: upgradev1.PlanSpec{
				Concurrency: concurrency,
				Version:     version,
				Tolerations: []corev1.Toleration{{
					Operator: corev1.TolerationOpExists,
				}},
				ServiceAccountName: uniqueName,
				NodeSelector:       selector,
				Cordon:             cordon,
				Drain:              managedOSImage.Spec.Drain,
				Prepare:            managedOSImage.Spec.Prepare,
				Secrets: []upgradev1.SecretSpec{{
					Name: uniqueName,
					Path: "/run/data",
				}},
				Upgrade: upgradeContainerSpec,
			},
		},
	}

	return r.objToFleetBundleResources(objs)
}

func (r *ManagedOSImageReconciler) createFleetBundle(ctx context.Context, managedOSImage *elementalv1.ManagedOSImage, bundleResources []fleetv1.BundleResource) error {
	logger := ctrl.LoggerFrom(ctx)
	logger.Info("Creating new fleet bundle")

	bundle := &fleetv1.Bundle{}
	r.mapImageToBundle(*managedOSImage, bundleResources, bundle)

	if managedOSImage.Namespace == fleetLocalNamespace {
		bundle.Spec.Targets = []fleetv1.BundleTarget{{ClusterName: "local"}}
	}

	if err := r.Create(ctx, bundle); err != nil && !apierrors.IsAlreadyExists(err) {
		return fmt.Errorf("failed to create fleet bundle: %w", err)
	}

	meta.SetStatusCondition(&managedOSImage.Status.Conditions, metav1.Condition{
		Type:   elementalv1.FleetBundleCreation,
		Reason: elementalv1.FleetBundleCreateSuccessReason,
		Status: metav1.ConditionTrue,
	})

	return nil
}

func (r *ManagedOSImageReconciler) updateFleetBundle(ctx context.Context, managedOSImage *elementalv1.ManagedOSImage, bundleResources []fleetv1.BundleResource) error {
	logger := ctrl.LoggerFrom(ctx)
	logger.Info("Updating existing bundle")

	bundleName := r.formatBundleName(*managedOSImage)
	bundleNamespace := managedOSImage.Namespace

	bundle := &fleetv1.Bundle{}
	if err := r.Get(ctx, types.NamespacedName{
		Namespace: bundleNamespace,
		Name:      bundleName,
	}, bundle); err != nil {
		logger.Error(err, "Could not get expected Bundle")
		return fmt.Errorf("getting bundle '%s/%s': %w", bundleNamespace, bundleName, err)
	}
	r.mapImageToBundle(*managedOSImage, bundleResources, bundle)

	if managedOSImage.Namespace == fleetLocalNamespace {
		bundle.Spec.Targets = []fleetv1.BundleTarget{{ClusterName: "local"}}
	}

	if err := r.Update(ctx, bundle); err != nil {
		logger.Error(err, "Could not update Bundle")
		return fmt.Errorf("updating bundle: %w", err)
	}

	return nil
}

func (r *ManagedOSImageReconciler) formatBundleName(managedOSImage elementalv1.ManagedOSImage) string {
	return name.SafeConcatName("mos", managedOSImage.Name)
}

func (r *ManagedOSImageReconciler) mapImageToBundle(managedOSImage elementalv1.ManagedOSImage, bundleResources []fleetv1.BundleResource, bundle *fleetv1.Bundle) {
	bundle.ObjectMeta.Name = r.formatBundleName(managedOSImage)
	bundle.ObjectMeta.Namespace = managedOSImage.Namespace
	bundle.ObjectMeta.OwnerReferences = []metav1.OwnerReference{
		{
			APIVersion: elementalv1.GroupVersion.String(),
			Kind:       "ManagedOSImage",
			Name:       managedOSImage.Name,
			UID:        managedOSImage.UID,
			Controller: ptr.To(true),
		},
	}

	bundle.Spec.Resources = bundleResources
	bundle.Spec.RolloutStrategy = managedOSImage.Spec.ClusterRolloutStrategy

	if managedOSImage.Namespace == fleetLocalNamespace {
		bundle.Spec.Targets = []fleetv1.BundleTarget{{ClusterName: "local"}}
	} else {
		bundle.Spec.Targets = managedOSImage.Spec.Targets
	}
}

func (r *ManagedOSImageReconciler) updateManagedOSImageStatus(ctx context.Context, managedOSImage *elementalv1.ManagedOSImage) error {
	bundle := &fleetv1.Bundle{}
	if err := r.Get(ctx, types.NamespacedName{
		Namespace: managedOSImage.Namespace,
		Name:      name.SafeConcatName("mos", managedOSImage.Name),
	}, bundle); err != nil {
		return err
	}

	// Convert Bundle status conditions to ManagedOSImage status conditions
	for _, cond := range bundle.Status.Conditions {
		newCondition := metav1.Condition{
			Status:  metav1.ConditionStatus(cond.Status),
			Message: cond.Message,
		}

		if cond.Type == "" {
			newCondition.Type = "UnknownType"
		} else {
			newCondition.Type = cond.Type
		}

		if cond.Reason == "" {
			newCondition.Reason = "UnknownReason"
		} else {
			newCondition.Reason = cond.Reason
		}

		if cond.Status == "" {
			newCondition.Status = metav1.ConditionUnknown
		} else {
			newCondition.Status = metav1.ConditionStatus(cond.Status)
		}

		meta.SetStatusCondition(&managedOSImage.Status.Conditions, newCondition)
	}

	return nil
}

func (r *ManagedOSImageReconciler) objToFleetBundleResources(objs []runtime.Object) ([]fleetv1.BundleResource, error) {
	result := []fleetv1.BundleResource{}
	for _, obj := range objs {
		obj = obj.DeepCopyObject()

		gvks, _, err := r.Scheme.ObjectKinds(obj)
		if err != nil {
			return nil, err
		}

		if len(gvks) == 0 {
			return nil, nil
		}

		kind := obj.GetObjectKind()
		kind.SetGroupVersionKind(gvks[0])

		gvk := obj.GetObjectKind().GroupVersionKind()
		if gvk.Kind == "" {
			return nil, errors.New("can't set object GVK")
		}

		typeMeta, err := meta.TypeAccessor(obj)
		if err != nil {
			return nil, err
		}

		meta, err := meta.Accessor(obj)
		if err != nil {
			return nil, err
		}

		data, err := json.Marshal(obj)
		if err != nil {
			return nil, err
		}

		digest := sha256.Sum256(data)
		filename := name.SafeConcatName(typeMeta.GetKind(), meta.GetNamespace(), meta.GetName(), hex.EncodeToString(digest[:])[:12]) + ".yaml"
		result = append(result, fleetv1.BundleResource{
			Name:    filename,
			Content: string(data),
		})
	}
	return result, nil
}

func getCloudConfig(managedOSImage *elementalv1.ManagedOSImage) ([]byte, error) {
	if len(managedOSImage.Spec.CloudConfig) == 0 {
		return []byte{}, nil
	}

	data, err := util.MarshalCloudConfig(managedOSImage.Spec.CloudConfig)
	if err != nil {
		return nil, fmt.Errorf("mashalling cloud config: %w", err)
	}

	return data, nil
}

func getImageVersion(managedOSImage *elementalv1.ManagedOSImage, managedOSVersion *elementalv1.ManagedOSVersion) (string, string, error) {
	baseImage := managedOSImage.Spec.OSImage
	if baseImage == "" && managedOSVersion != nil {
		osImg, err := managedOSVersion.ContainerImage()
		if err != nil {
			return "", "", err
		}
		baseImage = osImg.ImageURI
	}

	// Get the registry prefix
	parts := strings.Split(baseImage, "/")
	registry := ""
	if len(parts) > 1 {
		registry = parts[0]
		parts = parts[1:]
	}

	// Now get the version
	remainder := strings.Join(parts, "/")
	imageParts := strings.SplitN(remainder, ":", 2)
	version := "latest"
	if len(imageParts) == 2 {
		version = imageParts[1]
	}
	image := imageParts[0]

	// Add the registry back if needed
	if len(registry) > 0 {
		image = fmt.Sprintf("%s/%s", registry, imageParts[0])
	}

	return image, version, nil
}

func prefixPrivateRegistry(image, prefix string) string {
	if prefix == "" {
		return image
	}
	return prefix + "/" + image
}

func metadataEnv(m map[string]runtime.RawExtension) []corev1.EnvVar {
	// Encode metadata as environment in a slice of envVar
	envs := []corev1.EnvVar{}
	for k, v := range m {
		value := strings.Trim(string(v.Raw), "\"")
		envs = append(envs, corev1.EnvVar{Name: strings.ToUpper(fmt.Sprintf("METADATA_%s", k)), Value: value})
	}
	return envs
}

// This converts any string to RFC 1123 DNS label standard by replacing invalid characters with "-"
func toDNSLabel(input string) string {
	output := dnsLabelRegex.ReplaceAllString(input, "-")
	output = strings.TrimPrefix(output, "-")
	output = strings.TrimSuffix(output, "-")
	return output
}
