/*
Copyright the Velero contributors.

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 install

import (
	"context"
	"fmt"
	"io"
	"strings"
	"time"

	"github.com/pkg/errors"
	appsv1api "k8s.io/api/apps/v1"
	corev1api "k8s.io/api/core/v1"
	apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
	apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/util/wait"
	kbclient "sigs.k8s.io/controller-runtime/pkg/client"

	"github.com/vmware-tanzu/velero/pkg/client"
	"github.com/vmware-tanzu/velero/pkg/util/kube"
)

// kindToResource translates a Kind (mixed case, singular) to a Resource (lowercase, plural) string.
// This is to accommodate the dynamic client's need for an APIResource, as the Unstructured objects do not have easy helpers for this information.
var kindToResource = map[string]string{
	"CustomResourceDefinition": "customresourcedefinitions",
	"Namespace":                "namespaces",
	"ClusterRoleBinding":       "clusterrolebindings",
	"ServiceAccount":           "serviceaccounts",
	"Deployment":               "deployments",
	"DaemonSet":                "daemonsets",
	"Secret":                   "secrets",
	"ConfigMap":                "configmaps",
	"BackupStorageLocation":    "backupstoragelocations",
	"VolumeSnapshotLocation":   "volumesnapshotlocations",
}

// ResourceGroup represents a collection of Kubernetes objects with a common ready condition
type ResourceGroup struct {
	CRDResources   []*unstructured.Unstructured
	OtherResources []*unstructured.Unstructured
}

// crdV1Beta1ReadinessFn returns a function that can be used for polling to check
// if the provided unstructured v1beta1 CRDs are ready for use in the cluster.
func crdV1Beta1ReadinessFn(kbClient kbclient.Client, unstructuredCrds []*unstructured.Unstructured) func(context.Context) (bool, error) {
	// Track all the CRDs that have been found and in ready state.
	// len should be equal to len(unstructuredCrds) in the happy path.
	return func(ctx context.Context) (bool, error) {
		foundCRDs := make([]*apiextv1beta1.CustomResourceDefinition, 0)
		for _, unstructuredCrd := range unstructuredCrds {
			crd := &apiextv1beta1.CustomResourceDefinition{}
			key := kbclient.ObjectKey{Name: unstructuredCrd.GetName()}
			err := kbClient.Get(ctx, key, crd)
			if apierrors.IsNotFound(err) {
				return false, nil
			} else if err != nil {
				return false, errors.Wrapf(err, "error waiting for %s to be ready", crd.GetName())
			}
			foundCRDs = append(foundCRDs, crd)
		}

		if len(foundCRDs) != len(unstructuredCrds) {
			return false, nil
		}

		for _, crd := range foundCRDs {
			ready := kube.IsV1Beta1CRDReady(crd)
			if !ready {
				return false, nil
			}
		}
		return true, nil
	}
}

// crdV1ReadinessFn returns a function that can be used for polling to check
// if the provided unstructured v1 CRDs are ready for use in the cluster.
func crdV1ReadinessFn(kbClient kbclient.Client, unstructuredCrds []*unstructured.Unstructured) func(context.Context) (bool, error) {
	return func(ctx context.Context) (bool, error) {
		foundCRDs := make([]*apiextv1.CustomResourceDefinition, 0)
		for _, unstructuredCrd := range unstructuredCrds {
			crd := &apiextv1.CustomResourceDefinition{}
			key := kbclient.ObjectKey{Name: unstructuredCrd.GetName()}
			err := kbClient.Get(ctx, key, crd)
			if apierrors.IsNotFound(err) {
				return false, nil
			} else if err != nil {
				return false, errors.Wrapf(err, "error waiting for %s to be ready", crd.GetName())
			}
			foundCRDs = append(foundCRDs, crd)
		}

		if len(foundCRDs) != len(unstructuredCrds) {
			return false, nil
		}

		for _, crd := range foundCRDs {
			ready := kube.IsV1CRDReady(crd)
			if !ready {
				return false, nil
			}
		}
		return true, nil
	}
}

// crdsAreReady polls the API server to see if the Velero CRDs are ready to create objects.
func crdsAreReady(kbClient kbclient.Client, crds []*unstructured.Unstructured) (bool, error) {
	if len(crds) == 0 {
		// no CRDs to check so return
		return true, nil
	}

	// We assume that all Velero CRDs have the same GVK so we can use the GVK of the
	// first CRD to determine whether to use the v1beta1 or v1 API during polling.
	gvk := crds[0].GroupVersionKind()

	var crdReadinessFn func(context.Context) (bool, error)
	if gvk.Version == "v1beta1" {
		crdReadinessFn = crdV1Beta1ReadinessFn(kbClient, crds)
	} else if gvk.Version == "v1" {
		crdReadinessFn = crdV1ReadinessFn(kbClient, crds)
	} else {
		return false, fmt.Errorf("unsupported CRD version %q", gvk.Version)
	}

	err := wait.PollUntilContextTimeout(context.Background(), time.Second, time.Minute, true, crdReadinessFn)
	if err != nil {
		return false, errors.Wrap(err, "Error polling for CRDs")
	}
	return true, nil
}

func isAvailable(c appsv1api.DeploymentCondition) bool {
	// Make sure that the deployment has been available for at least 10 seconds.
	// This is because the deployment can show as Ready momentarily before the pods fall into a CrashLoopBackOff.
	// See podutils.IsPodAvailable upstream for similar logic with pods
	if c.Type == appsv1api.DeploymentAvailable && c.Status == corev1api.ConditionTrue {
		if !c.LastTransitionTime.IsZero() && c.LastTransitionTime.Add(10*time.Second).Before(time.Now()) {
			return true
		}
	}
	return false
}

// DeploymentIsReady will poll the Kubernetes API server to see if the velero deployment is ready to service user requests.
func DeploymentIsReady(factory client.DynamicFactory, namespace string) (bool, error) {
	gvk := schema.FromAPIVersionAndKind(appsv1api.SchemeGroupVersion.String(), "Deployment")
	apiResource := metav1.APIResource{
		Name:       "deployments",
		Namespaced: true,
	}
	c, err := factory.ClientForGroupVersionResource(gvk.GroupVersion(), apiResource, namespace)
	if err != nil {
		return false, errors.Wrapf(err, "Error creating client for deployment polling")
	}
	// declare this variable out of scope so we can return it
	var isReady bool
	var readyObservations int32
	err = wait.PollUntilContextTimeout(context.Background(), time.Second, 3*time.Minute, true, func(ctx context.Context) (bool, error) {
		unstructuredDeployment, err := c.Get("velero", metav1.GetOptions{})
		if apierrors.IsNotFound(err) {
			return false, nil
		} else if err != nil {
			return false, errors.Wrap(err, "error waiting for deployment to be ready")
		}

		deploy := new(appsv1api.Deployment)
		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredDeployment.Object, deploy); err != nil {
			return false, errors.Wrap(err, "error converting deployment from unstructured")
		}

		for _, cond := range deploy.Status.Conditions {
			if isAvailable(cond) {
				readyObservations++
			}
		}
		// Make sure we query the deployment enough times to see the state change, provided there is one.
		if readyObservations > 4 {
			isReady = true
			return true, nil
		}
		return false, nil
	})
	return isReady, err
}

// NodeAgentIsReady will poll the Kubernetes API server to ensure the node-agent daemonset is ready, i.e. that
// pods are scheduled and available on all of the desired nodes.
func NodeAgentIsReady(factory client.DynamicFactory, namespace string) (bool, error) {
	return daemonSetIsReady(factory, namespace, "node-agent")
}

// NodeAgentWindowsIsReady will poll the Kubernetes API server to ensure the node-agent-windows daemonset is ready, i.e. that
// pods are scheduled and available on all of the desired nodes.
func NodeAgentWindowsIsReady(factory client.DynamicFactory, namespace string) (bool, error) {
	return daemonSetIsReady(factory, namespace, "node-agent-windows")
}

func daemonSetIsReady(factory client.DynamicFactory, namespace string, name string) (bool, error) {
	gvk := schema.FromAPIVersionAndKind(appsv1api.SchemeGroupVersion.String(), "DaemonSet")
	apiResource := metav1.APIResource{
		Name:       "daemonsets",
		Namespaced: true,
	}

	c, err := factory.ClientForGroupVersionResource(gvk.GroupVersion(), apiResource, namespace)
	if err != nil {
		return false, errors.Wrapf(err, "Error creating client for daemonset polling")
	}

	// declare this variable out of scope so we can return it
	var isReady bool
	var readyObservations int32

	err = wait.PollUntilContextTimeout(context.Background(), time.Second, time.Minute, true, func(ctx context.Context) (bool, error) {
		unstructuredDaemonSet, err := c.Get(name, metav1.GetOptions{})
		if apierrors.IsNotFound(err) {
			return false, nil
		} else if err != nil {
			return false, errors.Wrap(err, "error waiting for daemonset to be ready")
		}

		daemonSet := new(appsv1api.DaemonSet)
		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredDaemonSet.Object, daemonSet); err != nil {
			return false, errors.Wrap(err, "error converting daemonset from unstructured")
		}

		if daemonSet.Status.NumberAvailable == daemonSet.Status.DesiredNumberScheduled {
			readyObservations++
		}

		// Wait for 5 observations of the daemonset being "ready" to be consistent with our check for
		// the deployment being ready.
		if readyObservations > 4 {
			isReady = true
			return true, nil
		}
		return false, nil
	})
	return isReady, err
}

// GroupResources groups resources based on whether the resources are CustomResourceDefinitions or other types of Kubernetes objects
// This is useful to wait for readiness before creating CRD objects
func GroupResources(resources *unstructured.UnstructuredList) *ResourceGroup {
	rg := new(ResourceGroup)

	for i, r := range resources.Items {
		if r.GetKind() == "CustomResourceDefinition" {
			rg.CRDResources = append(rg.CRDResources, &resources.Items[i])
			continue
		}
		rg.OtherResources = append(rg.OtherResources, &resources.Items[i])
	}

	return rg
}

// createOrApplyResource attempts to create or apply a resource in the cluster.
// If apply is true, it uses server-side apply to update existing resources.
// If apply is false and the resource already exists in the cluster, it's merely logged.
func createOrApplyResource(r *unstructured.Unstructured, factory client.DynamicFactory, w io.Writer, apply bool) error {
	id := fmt.Sprintf("%s/%s", r.GetKind(), r.GetName())

	// Helper to reduce boilerplate message about the same object
	log := func(f string) {
		fmt.Fprintf(w, "%s: %s\n", id, f)
	}

	c, err := CreateClient(r, factory, w)
	if err != nil {
		return err
	}

	if apply {
		log("attempting to apply resource")
		// Set field manager for server-side apply and force to override conflicts
		applyOpts := metav1.ApplyOptions{
			FieldManager: "velero-cli",
			Force:        true,
		}

		if _, err := c.Apply(r.GetName(), r, applyOpts); err != nil {
			return errors.Wrapf(err, "Error applying resource %s", id)
		}
		log("applied")
	} else {
		log("attempting to create resource")
		if _, err := c.Create(r); apierrors.IsAlreadyExists(err) {
			log("already exists, proceeding")
		} else if err != nil {
			return errors.Wrapf(err, "Error creating resource %s", id)
		} else {
			log("created")
		}
	}

	return nil
}

// CreateClient creates a client for an unstructured resource
func CreateClient(r *unstructured.Unstructured, factory client.DynamicFactory, w io.Writer) (client.Dynamic, error) {
	id := fmt.Sprintf("%s/%s", r.GetKind(), r.GetName())

	// Helper to reduce boilerplate message about the same object
	log := func(f string, a ...any) {
		format := strings.Join([]string{id, ": ", f, "\n"}, "")
		fmt.Fprintf(w, format, a...)
	}
	log("attempting to create resource client")

	gvk := schema.FromAPIVersionAndKind(r.GetAPIVersion(), r.GetKind())

	apiResource := metav1.APIResource{
		Name:       kindToResource[r.GetKind()],
		Namespaced: (r.GetNamespace() != ""),
	}

	c, err := factory.ClientForGroupVersionResource(gvk.GroupVersion(), apiResource, r.GetNamespace())
	if err != nil {
		return nil, errors.Wrapf(err, "Error creating client for resource %s", id)
	}

	return c, nil
}

// Install creates resources on the Kubernetes cluster.
// An unstructured list of resources is sent, one at a time, to the server. These are assumed to be in the preferred order already.
// Resources will be sorted into CustomResourceDefinitions and any other resource type, and the function will wait up to 1 minute
// for CRDs to be ready before proceeding.
// If apply is true, it uses server-side apply to update existing resources.
// An io.Writer can be used to output to a log or the console.
func Install(dynamicFactory client.DynamicFactory, kbClient kbclient.Client, resources *unstructured.UnstructuredList, w io.Writer, apply bool) error {
	rg := GroupResources(resources)

	//Install CRDs first
	for _, r := range rg.CRDResources {
		if err := createOrApplyResource(r, dynamicFactory, w, apply); err != nil {
			return err
		}
	}

	// Wait for CRDs to be ready before proceeding
	fmt.Fprint(w, "Waiting for resources to be ready in cluster...\n")
	_, err := crdsAreReady(kbClient, rg.CRDResources)
	if wait.Interrupted(err) {
		return errors.Errorf("timeout reached, CRDs not ready")
	} else if err != nil {
		return err
	}

	// Install all other resources
	for _, r := range rg.OtherResources {
		if err = createOrApplyResource(r, dynamicFactory, w, apply); err != nil {
			return err
		}
	}

	return nil
}
