/*
Copyright 2020 The Knative 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 validation

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"

	"go.uber.org/zap"
	admissionv1 "k8s.io/api/admission/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"knative.dev/pkg/apis"
	kubeclient "knative.dev/pkg/client/injection/kube/client"
	"knative.dev/pkg/logging"
	"knative.dev/pkg/webhook"
	"knative.dev/pkg/webhook/resourcesemantics"
)

var errMissingNewObject = errors.New("the new object may not be nil")

// Callback is a generic function to be called by a consumer of validation
type Callback struct {
	// function is the callback to be invoked
	function func(ctx context.Context, unstructured *unstructured.Unstructured) error

	// supportedVerbs are the verbs supported for the callback.
	// The function will only be called on these actions.
	supportedVerbs map[webhook.Operation]struct{}
}

// NewCallback creates a new callback function to be invoked on supported verbs.
func NewCallback(function func(context.Context, *unstructured.Unstructured) error, supportedVerbs ...webhook.Operation) Callback {
	m := make(map[webhook.Operation]struct{})
	for _, op := range supportedVerbs {
		if _, has := m[op]; has {
			panic("duplicate verbs not allowed")
		}
		m[op] = struct{}{}
	}
	return Callback{function: function, supportedVerbs: m}
}

var _ webhook.AdmissionController = (*reconciler)(nil)

// Admit implements AdmissionController
func (ac *reconciler) Admit(ctx context.Context, request *admissionv1.AdmissionRequest) *admissionv1.AdmissionResponse {
	if ac.withContext != nil {
		ctx = ac.withContext(ctx)
	}

	kind := request.Kind
	gvk := schema.GroupVersionKind{
		Group:   kind.Group,
		Version: kind.Version,
		Kind:    kind.Kind,
	}

	ctx, resource, err := ac.decodeRequestAndPrepareContext(ctx, request, gvk)
	if err != nil {
		return webhook.MakeErrorStatus("decoding request failed: %v", err)
	}

	if err := validate(ctx, resource, request); err != nil {
		return webhook.MakeErrorStatus("validation failed: %v", err)
	}

	if err := ac.callback(ctx, request, gvk); err != nil {
		return webhook.MakeErrorStatus("validation callback failed: %v", err)
	}

	return &admissionv1.AdmissionResponse{Allowed: true}
}

// decodeRequestAndPrepareContext deserializes the old and new GenericCrds from the incoming request and sets up the context.
// nil oldObj or newObj denote absence of `old` (create) or `new` (delete) objects.
func (ac *reconciler) decodeRequestAndPrepareContext(
	ctx context.Context,
	req *admissionv1.AdmissionRequest,
	gvk schema.GroupVersionKind) (context.Context, resourcesemantics.GenericCRD, error) {

	logger := logging.FromContext(ctx)
	handler, ok := ac.handlers[gvk]
	if !ok {
		logger.Errorf("Unhandled kind: %v", gvk)
		return ctx, nil, fmt.Errorf("unhandled kind: %v", gvk)
	}

	newBytes := req.Object.Raw
	oldBytes := req.OldObject.Raw

	// Decode json to a GenericCRD
	var newObj resourcesemantics.GenericCRD
	if len(newBytes) != 0 {
		newObj = handler.DeepCopyObject().(resourcesemantics.GenericCRD)
		newDecoder := json.NewDecoder(bytes.NewBuffer(newBytes))
		if ac.disallowUnknownFields {
			newDecoder.DisallowUnknownFields()
		}
		if err := newDecoder.Decode(&newObj); err != nil {
			return ctx, nil, fmt.Errorf("cannot decode incoming new object: %w", err)
		}
	}

	var oldObj resourcesemantics.GenericCRD
	if len(oldBytes) != 0 {
		oldObj = handler.DeepCopyObject().(resourcesemantics.GenericCRD)
		oldDecoder := json.NewDecoder(bytes.NewBuffer(oldBytes))
		if ac.disallowUnknownFields {
			oldDecoder.DisallowUnknownFields()
		}
		if err := oldDecoder.Decode(&oldObj); err != nil {
			return ctx, nil, fmt.Errorf("cannot decode incoming old object: %w", err)
		}
	}

	ctx = apis.WithUserInfo(ctx, &req.UserInfo)
	ctx = context.WithValue(ctx, kubeclient.Key{}, ac.client)
	if req.DryRun != nil && *req.DryRun {
		ctx = apis.WithDryRun(ctx)
	}

	if newObj != nil && oldObj != nil && req.SubResource == "" {
		ctx = apis.WithinSubResourceUpdate(ctx, oldObj, req.SubResource)
	}

	switch req.Operation {
	case admissionv1.Update:
		ctx = apis.WithinUpdate(ctx, oldObj)
	case admissionv1.Create:
		ctx = apis.WithinCreate(ctx)
	case admissionv1.Delete:
		ctx = apis.WithinDelete(ctx)
		return ctx, oldObj, nil
	}

	return ctx, newObj, nil
}

func validate(ctx context.Context, resource resourcesemantics.GenericCRD, req *admissionv1.AdmissionRequest) error {
	logger := logging.FromContext(ctx)

	// Only run validation for supported create and update validaiton.
	switch req.Operation {
	case admissionv1.Create, admissionv1.Update:
		// Supported verbs
	case admissionv1.Delete:
		return nil // Validation handled by optional Callback, but not validatable.
	default:
		logger.Infof("Unhandled webhook validation operation, letting it through %v", req.Operation)
		return nil
	}

	// None of the validators will accept a nil value for newObj.
	if resource == nil {
		return errMissingNewObject
	}

	if err := resource.Validate(ctx); err != nil {
		logger.Errorw("Failed the resource specific validation", zap.Error(err))
		// Return the error message as-is to give the validation callback
		// discretion over (our portion of) the message that the user sees.
		return err
	}

	return nil
}

// callback runs optional callbacks on admission
func (ac *reconciler) callback(ctx context.Context, req *admissionv1.AdmissionRequest, gvk schema.GroupVersionKind) error {
	var toDecode []byte
	if req.Operation == admissionv1.Delete {
		toDecode = req.OldObject.Raw
	} else {
		toDecode = req.Object.Raw
	}
	if toDecode == nil {
		logger := logging.FromContext(ctx)
		logger.Errorf("No incoming object found: %v for verb %v", gvk, req.Operation)
		return nil
	}

	// Generically callback if any are provided for the resource.
	if c, ok := ac.callbacks[gvk]; ok {
		if _, supported := c.supportedVerbs[req.Operation]; supported {
			unstruct := &unstructured.Unstructured{}
			newDecoder := json.NewDecoder(bytes.NewBuffer(toDecode))
			if err := newDecoder.Decode(&unstruct); err != nil {
				return fmt.Errorf("cannot decode incoming new object: %w", err)
			}

			return c.function(ctx, unstruct)
		}
	}

	return nil
}
