/*
Copyright AppsCode Inc. and 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 v1alpha1

import (
	"context"
	"fmt"
	"strings"

	catalog "kubedb.dev/apimachinery/apis/catalog/v1alpha1"
	dbapi "kubedb.dev/apimachinery/apis/kubedb/v1"
	opsapi "kubedb.dev/apimachinery/apis/ops/v1alpha1"
	opsutil "kubedb.dev/apimachinery/pkg/webhooks/ops"

	"github.com/pkg/errors"
	"gomodules.xyz/x/arrays"
	storagev1 "k8s.io/api/storage/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/apimachinery/pkg/util/mergepatch"
	"k8s.io/apimachinery/pkg/util/sets"
	"k8s.io/apimachinery/pkg/util/validation/field"
	meta_util "kmodules.xyz/client-go/meta"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	logf "sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/webhook"
	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

// SetupPerconaXtraDBOpsRequestWebhookWithManager registers the webhook for PerconaXtraDBOpsRequest in the manager.
func SetupPerconaXtraDBOpsRequestWebhookWithManager(mgr ctrl.Manager) error {
	return ctrl.NewWebhookManagedBy(mgr).For(&opsapi.PerconaXtraDBOpsRequest{}).
		WithValidator(&PerconaXtraDBOpsRequestCustomWebhook{mgr.GetClient()}).
		Complete()
}

type PerconaXtraDBOpsRequestCustomWebhook struct {
	DefaultClient client.Client
}

// log is for logging in this package.
var pxLog = logf.Log.WithName("percona-opsrequest")

var _ webhook.CustomValidator = &PerconaXtraDBOpsRequestCustomWebhook{}

// ValidateCreate implements webhooin.Validator so a webhook will be registered for the type
func (w *PerconaXtraDBOpsRequestCustomWebhook) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
	ops, ok := obj.(*opsapi.PerconaXtraDBOpsRequest)
	if !ok {
		return nil, fmt.Errorf("expected an PerconaXtraDBOpsRequest object but got %T", obj)
	}
	pxLog.Info("validate create", "name", ops.Name)
	return nil, w.validateCreateOrUpdate(ops)
}

// ValidateUpdate implements webhooin.Validator so a webhook will be registered for the type
func (w *PerconaXtraDBOpsRequestCustomWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
	ops, ok := newObj.(*opsapi.PerconaXtraDBOpsRequest)
	if !ok {
		return nil, fmt.Errorf("expected an PerconaXtraDBOpsRequest object but got %T", newObj)
	}
	pxLog.Info("validate update", "name", ops.Name)

	oldOps, ok := oldObj.(*opsapi.PerconaXtraDBOpsRequest)
	if !ok {
		return nil, fmt.Errorf("expected an PerconaXtraDBOpsRequest object but got %T", oldObj)
	}

	if err := w.validatePerconaXtraDBOpsRequest(ops, oldOps); err != nil {
		return nil, err
	}
	if err := w.validateCreateOrUpdate(ops); err != nil {
		return nil, err
	}

	if isOpsReqCompleted(ops.Status.Phase) && !isOpsReqCompleted(oldOps.Status.Phase) { // just completed
		var db dbapi.PerconaXtraDB
		err := w.DefaultClient.Get(context.TODO(), types.NamespacedName{Name: ops.Spec.DatabaseRef.Name, Namespace: ops.Namespace}, &db)
		if err != nil {
			return nil, err
		}
		return nil, resumeDatabase(w.DefaultClient, &db)
	}
	return nil, nil
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
	return nil, nil
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) validateCreateOrUpdate(req *opsapi.PerconaXtraDBOpsRequest) error {
	if validType, _ := arrays.Contains(opsapi.PerconaXtraDBOpsRequestTypeNames(), string(req.Spec.Type)); !validType {
		return field.Invalid(field.NewPath("spec").Child("type"), req.Name,
			fmt.Sprintf("defined OpsRequestType %s is not supported, supported types for PerconaXtraDB are %s", req.Spec.Type, strings.Join(opsapi.PerconaXtraDBOpsRequestTypeNames(), ", ")))
	}
	db, err := w.hasDatabaseRef(req)
	if err != nil {
		return err
	}
	var allErr field.ErrorList
	switch opsapi.PerconaXtraDBOpsRequestType(req.GetRequestType()) {
	case opsapi.PerconaXtraDBOpsRequestTypeRestart:
	case opsapi.PerconaXtraDBOpsRequestTypeVerticalScaling:
		if err := w.validatePerconaXtraDBScalingOpsRequest(req); err != nil {
			allErr = append(allErr, field.Invalid(field.NewPath("spec").Child("verticalScaling"),
				req.Name,
				err.Error()))
		}
	case opsapi.PerconaXtraDBOpsRequestTypeHorizontalScaling:
		if err := w.validatePerconaXtraDBScalingOpsRequest(req); err != nil {
			allErr = append(allErr, field.Invalid(field.NewPath("spec").Child("horizontalScaling"),
				req.Name,
				err.Error()))
		}
	case opsapi.PerconaXtraDBOpsRequestTypeReconfigure:
		if err := w.validatePerconaXtraDBReconfigurationOpsRequest(req); err != nil {
			allErr = append(allErr, field.Invalid(field.NewPath("spec").Child("configuration"),
				req.Name,
				err.Error()))
		}
	case opsapi.PerconaXtraDBOpsRequestTypeUpdateVersion:
		if err := w.validatePerconaXtraDBUpgradeOpsRequest(db, req); err != nil {
			allErr = append(allErr, field.Invalid(field.NewPath("spec").Child("updateVersion"),
				req.Name,
				err.Error()))
		}
	case opsapi.PerconaXtraDBOpsRequestTypeReconfigureTLS:
		if err := w.validatePerconaXtraDBReconfigurationTLSOpsRequest(req); err != nil {
			allErr = append(allErr, field.Invalid(field.NewPath("spec").Child("tls"),
				req.Name,
				err.Error()))
		}
	case opsapi.PerconaXtraDBOpsRequestTypeVolumeExpansion:
		if err := w.validatePerconaXtraDBVolumeExpansionOpsRequest(db, req); err != nil {
			allErr = append(allErr, field.Invalid(field.NewPath("spec").Child("volumeExpansion"),
				req.Name,
				err.Error()))
		}
	case opsapi.PerconaXtraDBOpsRequestTypeStorageMigration:
		if err := w.validatePerconaXtraDBStorageMigrationOpsRequest(req); err != nil {
			allErr = append(allErr, field.Invalid(field.NewPath("spec").Child("migration"),
				req.Name,
				err.Error()))
		}
	}

	if len(allErr) == 0 {
		return nil
	}
	return apierrors.NewInvalid(schema.GroupKind{Group: "PerconaXtraDBopsrequests.kubedb.com", Kind: "PerconaXtraDBOpsRequest"}, req.Name, allErr)
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) hasDatabaseRef(req *opsapi.PerconaXtraDBOpsRequest) (*dbapi.PerconaXtraDB, error) {
	px := &dbapi.PerconaXtraDB{}
	if err := w.DefaultClient.Get(context.TODO(), types.NamespacedName{
		Name:      req.GetDBRefName(),
		Namespace: req.GetNamespace(),
	}, px); err != nil {
		return nil, errors.New(fmt.Sprintf("spec.databaseRef %s/%s, is invalid or not found", req.GetNamespace(), req.GetDBRefName()))
	}
	return px, nil
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) validatePerconaXtraDBOpsRequest(obj, oldObj runtime.Object) error {
	preconditions := meta_util.PreConditionSet{Set: sets.New[string]("spec")}
	_, err := meta_util.CreateStrategicPatch(oldObj, obj, preconditions.PreconditionFunc()...)
	if err != nil {
		if mergepatch.IsPreconditionFailed(err) {
			return fmt.Errorf("%v.%v", err, preconditions.Error())
		}
		return err
	}
	return nil
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) validatePerconaXtraDBUpgradeOpsRequest(db *dbapi.PerconaXtraDB, req *opsapi.PerconaXtraDBOpsRequest) error {
	updateVersionSpec := req.Spec.UpdateVersion
	if updateVersionSpec == nil {
		return errors.New("spec.Upgrade & spec.UpdateVersion both nil not supported")
	}
	yes, err := IsUpgradable(w.DefaultClient, catalog.ResourceKindPerconaXtraDBVersion, db.Spec.Version, updateVersionSpec.TargetVersion)
	if err != nil {
		return err
	}
	if !yes {
		return fmt.Errorf("upgrade from version %v to %v is not supported", db.Spec.Version, req.Spec.UpdateVersion.TargetVersion)
	}

	err = w.DefaultClient.Get(context.TODO(), types.NamespacedName{Name: req.GetDBRefName(), Namespace: req.GetNamespace()}, db)
	if err != nil {
		return errors.Wrap(err, fmt.Sprintf("failed to get percona-xtradbdb: %s/%s", req.Namespace, req.Spec.DatabaseRef.Name))
	}
	pxNextVersion := &catalog.PerconaXtraDBVersion{}
	err = w.DefaultClient.Get(context.TODO(), types.NamespacedName{
		Name: updateVersionSpec.TargetVersion,
	}, pxNextVersion)
	if err != nil {
		return errors.Wrap(err, fmt.Sprintf("failed to get percona-xtradbdbVersion: %s", updateVersionSpec.TargetVersion))
	}
	// check if pxNextVersion is deprecated.if deprecated, return error
	if pxNextVersion.Spec.Deprecated {
		return fmt.Errorf("percona-xtradbdb target version %s/%s is deprecated. Skipped processing", db.Namespace, pxNextVersion.Name)
	}
	return nil
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) validatePerconaXtraDBScalingOpsRequest(req *opsapi.PerconaXtraDBOpsRequest) error {
	if req.Spec.Type == opsapi.PerconaXtraDBOpsRequestTypeHorizontalScaling {
		if req.Spec.HorizontalScaling == nil {
			return errors.New("`spec.Scale.HorizontalScaling` field is nil")
		}

		if *req.Spec.HorizontalScaling.Member <= int32(2) || *req.Spec.HorizontalScaling.Member > int32(9) {
			return errors.New("Group size can not be less than 3 or greater than 9, range: [3,9]")
		}
		return nil
	}

	if req.Spec.VerticalScaling == nil {
		return errors.New("`spec.Scale.Vertical` field is empty")
	}

	return nil
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) validatePerconaXtraDBVolumeExpansionOpsRequest(db *dbapi.PerconaXtraDB, req *opsapi.PerconaXtraDBOpsRequest) error {
	if req.Spec.VolumeExpansion == nil || req.Spec.VolumeExpansion.PerconaXtraDB == nil {
		return errors.New("`.Spec.VolumeExpansion` field is nil")
	}

	if err := opsutil.ValidateStorageExpansion(db.Spec.Storage, req.Spec.VolumeExpansion.PerconaXtraDB, req.Status.Phase, "PerconaXtraDB"); err != nil {
		return err
	}
	return nil
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) validatePerconaXtraDBReconfigurationOpsRequest(req *opsapi.PerconaXtraDBOpsRequest) error {
	db := &dbapi.PerconaXtraDB{}
	err := w.DefaultClient.Get(context.TODO(), types.NamespacedName{Name: req.GetDBRefName(), Namespace: req.GetNamespace()}, db)
	if err != nil {
		return errors.Wrap(err, fmt.Sprintf("failed to get percona-xtradb: %s/%s", req.Namespace, req.Spec.DatabaseRef.Name))
	}

	if req.Spec.Configuration == nil {
		return errors.New("`.Spec.Configuration` field is nil")
	}

	if !req.Spec.Configuration.RemoveCustomConfig && len(req.Spec.Configuration.ApplyConfig) == 0 && req.Spec.Configuration.ConfigSecret == nil {
		return errors.New("at least one of `RemoveCustomConfig`, `ConfigSecret`, or `ApplyConfig` must be specified")
	}

	return nil
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) validatePerconaXtraDBReconfigurationTLSOpsRequest(req *opsapi.PerconaXtraDBOpsRequest) error {
	if req.Spec.TLS == nil {
		return errors.New("TLS Spec is empty")
	}
	configCount := 0
	if req.Spec.TLS.Remove {
		configCount++
	}
	if req.Spec.TLS.RotateCertificates {
		configCount++
	}
	if req.Spec.TLS.IssuerRef != nil || req.Spec.TLS.Certificates != nil || req.Spec.TLS.RequireSSL != nil {
		configCount++
	}

	if configCount == 0 {
		return errors.New("no of incomplete reconfiguration is provided in TLS spec")
	}

	if configCount > 1 {
		return errors.New("more than 1 field have assigned to reconfigureTLS to your database but at a time you you are allowed to run one operation")
	}
	return nil
}

func (w *PerconaXtraDBOpsRequestCustomWebhook) validatePerconaXtraDBStorageMigrationOpsRequest(req *opsapi.PerconaXtraDBOpsRequest) error {
	db := &dbapi.PerconaXtraDB{}
	err := w.DefaultClient.Get(context.TODO(), types.NamespacedName{Name: req.GetDBRefName(), Namespace: req.GetNamespace()}, db)
	if err != nil {
		return errors.Wrap(err, fmt.Sprintf("failed to get percona-xtradb: %s/%s", req.Namespace, req.Spec.DatabaseRef.Name))
	}

	if req.Spec.Migration.StorageClassName == nil {
		return errors.New("spec.migration.storageClassName is required")
	}
	if req.Spec.Timeout == nil {
		// timeout is required for Storage Migration ops request because it's a long-running operation
		// default timeout is len(pods) * 5 minute
		return errors.New("spec.timeout is required for Storage Migration ops request,adjust timeout according to the size of your database")
	}
	// check new storageClass
	var newstorage, oldstorage storagev1.StorageClass
	err = w.DefaultClient.Get(context.TODO(), types.NamespacedName{
		Name: *req.Spec.Migration.StorageClassName,
	}, &newstorage)
	if err != nil {
		if apierrors.IsNotFound(err) {
			return errors.Wrap(err, fmt.Sprintf("storage class %s not found", *req.Spec.Migration.StorageClassName))
		}
		return err
	}

	err = w.DefaultClient.Get(context.TODO(), types.NamespacedName{
		Name: *db.Spec.Storage.StorageClassName,
	}, &oldstorage)
	if err != nil {
		return err
	}

	if *oldstorage.VolumeBindingMode == storagev1.VolumeBindingWaitForFirstConsumer {
		if *newstorage.VolumeBindingMode != storagev1.VolumeBindingWaitForFirstConsumer {
			return errors.New(fmt.Sprintf("volume binding mode should be WaitForFirstConsumer for %s storageClass", newstorage.Name))
		}
	}

	return nil
}
