// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// Package fakekubeapi contains a *very* simple httptest.Server that can be used to stand in for
// a real Kube API server in tests.
//
// Usage:
//   func TestSomething(t *testing.T) {
//     resources := map[string]kubeclient.Object{
//       // store preexisting resources here
//       "/api/v1/namespaces/default/pods/some-pod-name": &corev1.Pod{...},
//     }
//     server, restConfig := fakekubeapi.Start(t, resources)
//     defer server.Close()
//     client := kubeclient.New(kubeclient.WithConfig(restConfig))
//     // do stuff with client...
//   }
package fakekubeapi

import (
	"encoding/pem"
	"fmt"
	"io/ioutil"
	"mime"
	"net/http"
	"net/http/httptest"
	"path"
	"strings"
	"testing"

	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/util/errors"
	kubescheme "k8s.io/client-go/kubernetes/scheme"
	restclient "k8s.io/client-go/rest"
	aggregatorclientscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme"

	pinnipedconciergeclientsetscheme "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned/scheme"
	pinnipedsupervisorclientsetscheme "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/scheme"
	"go.pinniped.dev/internal/httputil/httperr"
)

// Start starts an httptest.Server (with TLS) that pretends to be a Kube API server.
//
// The server uses the provided resources map to store API Object's. The map should be from API path
// to Object (e.g., /api/v1/namespaces/default/pods/some-pod-name => &corev1.Pod{}).
//
// Start returns an already started httptest.Server and a restclient.Config that can be used to talk
// to the server.
//
// Note! Only these following verbs are (partially) supported: create, get, update, delete.
func Start(t *testing.T, resources map[string]runtime.Object) (*httptest.Server, *restclient.Config) {
	if resources == nil {
		resources = make(map[string]runtime.Object)
	}

	server := httptest.NewTLSServer(httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (err error) {
		obj, err := decodeObj(r)
		if err != nil {
			return err
		}

		obj, err = handleObj(r, obj, resources)
		if err != nil {
			return err
		}

		if obj == nil {
			obj = newNotFoundStatus(r.URL.Path)
		}

		if err := encodeObj(w, r, obj); err != nil {
			return err
		}

		return nil
	}))
	restConfig := &restclient.Config{
		Host: server.URL,
		TLSClientConfig: restclient.TLSClientConfig{
			CAData: pem.EncodeToMemory(&pem.Block{Bytes: server.Certificate().Raw, Type: "CERTIFICATE"}),
		},
	}
	return server, restConfig
}

func decodeObj(r *http.Request) (runtime.Object, error) {
	switch r.Method {
	case http.MethodPut, http.MethodPost:
	default:
		return nil, nil
	}

	contentType := r.Header.Get("Content-Type")
	if len(contentType) == 0 {
		return nil, httperr.New(http.StatusUnsupportedMediaType, "empty content-type header is not allowed")
	}

	mediaType, _, err := mime.ParseMediaType(contentType)
	if err != nil {
		return nil, httperr.Wrap(http.StatusUnsupportedMediaType, "could not parse mime type from content-type header", err)
	}

	body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		return nil, httperr.Wrap(http.StatusInternalServerError, "read body", err)
	}

	var obj runtime.Object
	var errs []error //nolint: prealloc
	codecsThatWeUseInOurCode := []runtime.NegotiatedSerializer{
		kubescheme.Codecs,
		aggregatorclientscheme.Codecs,
		pinnipedconciergeclientsetscheme.Codecs,
		pinnipedsupervisorclientsetscheme.Codecs,
	}
	for _, codec := range codecsThatWeUseInOurCode {
		obj, err = tryDecodeObj(mediaType, body, codec)
		if err == nil {
			return obj, nil
		}
		errs = append(errs, err)
	}
	return nil, errors.NewAggregate(errs)
}

func tryDecodeObj(
	mediaType string,
	body []byte,
	negotiatedSerializer runtime.NegotiatedSerializer,
) (runtime.Object, error) {
	serializerInfo, ok := runtime.SerializerInfoForMediaType(negotiatedSerializer.SupportedMediaTypes(), mediaType)
	if !ok {
		return nil, httperr.Newf(http.StatusInternalServerError, "unable to find serialier with content-type %s", mediaType)
	}

	obj, err := runtime.Decode(serializerInfo.Serializer, body)
	if err != nil {
		return nil, httperr.Wrap(http.StatusInternalServerError, "decode obj", err)
	}

	return obj, nil
}

func handleObj(r *http.Request, obj runtime.Object, resources map[string]runtime.Object) (runtime.Object, error) {
	switch r.Method {
	case http.MethodGet:
		obj = resources[r.URL.Path]
	case http.MethodPost, http.MethodPut:
		resources[path.Join(r.URL.Path, obj.(metav1.Object).GetName())] = obj
	case http.MethodDelete:
		obj = resources[r.URL.Path]
		delete(resources, r.URL.Path)
	default:
		return nil, httperr.New(http.StatusMethodNotAllowed, "check source code for methods supported")
	}

	return obj, nil
}

func newNotFoundStatus(path string) runtime.Object {
	status := &metav1.Status{
		Status:  metav1.StatusFailure,
		Message: fmt.Sprintf("couldn't find object for path %q", path),
		Reason:  metav1.StatusReasonNotFound,
		Code:    http.StatusNotFound,
	}
	status.APIVersion, status.Kind = metav1.SchemeGroupVersion.WithKind("Status").ToAPIVersionAndKind()
	return status
}

func encodeObj(w http.ResponseWriter, r *http.Request, obj runtime.Object) error {
	if r.Method == http.MethodDelete {
		return nil
	}

	accepts := strings.Split(r.Header.Get("Accept"), ",")
	contentType := findGoodContentType(accepts)
	if len(contentType) == 0 {
		return httperr.Newf(http.StatusUnsupportedMediaType, "can't find good content type in %s", accepts)
	}

	mediaType, _, err := mime.ParseMediaType(contentType)
	if err != nil {
		return httperr.Wrap(http.StatusUnsupportedMediaType, "could not parse mime type from accept header", err)
	}

	serializerInfo, ok := runtime.SerializerInfoForMediaType(kubescheme.Codecs.SupportedMediaTypes(), mediaType)
	if !ok {
		return httperr.Newf(http.StatusInternalServerError, "unable to find serialier with content-type %s", mediaType)
	}

	data, err := runtime.Encode(serializerInfo.Serializer, obj.(runtime.Object))
	if err != nil {
		return httperr.Wrap(http.StatusInternalServerError, "decode obj", err)
	}

	w.Header().Set("Content-Type", contentType)
	if _, err := w.Write(data); err != nil {
		return httperr.Wrap(http.StatusInternalServerError, "write response", err)
	}

	return nil
}

func findGoodContentType(contentTypes []string) string {
	for _, contentType := range contentTypes {
		if strings.Contains(contentType, "json") || strings.Contains(contentType, "yaml") || strings.Contains(contentType, "protobuf") {
			return contentType
		}
	}
	return ""
}