package k8s

import (
	"context"
	"fmt"
	"strings"

	// debug modified contents, discomment it when you need
	// "github.com/google/go-cmp/cmp"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/serializer/json"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/apimachinery/pkg/util/jsonmergepatch"
	"k8s.io/apimachinery/pkg/util/mergepatch"
	"k8s.io/apimachinery/pkg/util/validation"
	"sigs.k8s.io/controller-runtime/pkg/client"

	"github.com/tidbcloud/infra-provider/regional-server/pkg/consts"
	"github.com/tidbcloud/infra-provider/regional-server/pkg/scheme"
	"github.com/tidbcloud/infra-provider/regional-server/pkg/util"
)

var (
	serializer = json.NewSerializerWithOptions(
		json.DefaultMetaFactory,
		scheme.Scheme,
		scheme.Scheme,
		json.SerializerOptions{
			Yaml:   false,
			Pretty: false,
			Strict: true,
		})
)

func GetOriginalConfiguration(obj client.Object) []byte {
	annots := obj.GetAnnotations()

	original, ok := annots[consts.LastAppliedConfigAnnotation]
	if !ok {
		return nil
	}

	return []byte(original)
}

// GetModifiedConfiguration returns the modified configuration of the object.
// It first serializes the object without the last applied annotation to prevent recursion,
// then add the annotation back after serializing.
//
// Arg `annotate` means whether the modified config contains the last applied annotation.
// If annotate is true, the last applied config will be record in annotation, and the return value
// is used for generating three way json merge patch.
// If annotate is false, return value is the expected last applied config
func GetModifiedConfiguration(obj client.Object, gv runtime.GroupVersioner, annotate bool) ([]byte, error) {
	encoder := scheme.Codecs.EncoderForVersion(serializer, gv)
	annots := obj.GetAnnotations()
	if annots == nil {
		annots = map[string]string{}
	}

	original := annots[consts.LastAppliedConfigAnnotation]
	delete(annots, consts.LastAppliedConfigAnnotation)
	obj.SetAnnotations(annots)
	// del resourceVersion from modified
	resourceVersion := obj.GetResourceVersion()
	obj.SetResourceVersion("")

	modified, err := runtime.Encode(encoder, obj)
	if err != nil {
		return nil, err
	}

	if annotate {
		annots[consts.LastAppliedConfigAnnotation] = string(modified)
		obj.SetAnnotations(annots)
		// restore resourceVersion into modified
		obj.SetResourceVersion(resourceVersion)

		modified, err = runtime.Encode(encoder, obj)
		if err != nil {
			return nil, err
		}
	}

	// Restore the object to its original condition.
	annots[consts.LastAppliedConfigAnnotation] = original
	obj.SetAnnotations(annots)

	return modified, nil
}

type ApplyResult string

const (
	ApplyResultCreated   ApplyResult = "created"
	ApplyResultUnchanged ApplyResult = "unchanged"
	ApplyResultPatched   ApplyResult = "patched"
	ApplyResultError     ApplyResult = "error"
)

// ApplyWithResult creates or updates the given object via client-side apply, just like `kubectl apply`.
// It first attempts to retrieve the object from the cluster. If the object does not exist,
// it will set the last applied annotation and create it. If the object does exist, it
// compares the modification with the last applied annotation. If the modification is the same,
// it does nothing. Otherwise, it will patch the object and update the last applied annotation.
//
// Note:
//   - Make sure the order of list fields is stable, otherwise it will be considered as modified.
func ApplyWithResult(ctx context.Context, c client.Client, obj client.Object) (ApplyResult, error) {
	return ApplyWithResultUsingReader(ctx, c, c, obj)
}

// ApplyWithResultUsingReader behaves like ApplyWithResult, but lets callers
// provide a dedicated reader for the "current object" lookup. This is useful
// when writes should still go through a cache-backed client, while the compare
// step must read the latest object directly from the apiserver.
func ApplyWithResultUsingReader(ctx context.Context, r client.Reader, c client.Client, obj client.Object) (ApplyResult, error) {
	sanitizeObjectLabels(obj)
	if err := checkObject(obj); err != nil {
		return ApplyResultError, fmt.Errorf("the given obj is invalid: %w", err)
	}

	gvks, _, err := scheme.Scheme.ObjectKinds(obj)
	if err != nil {
		return ApplyResultError, fmt.Errorf("can't get gvk of object %T: %w", obj, err)
	}

	expectedObj := obj.DeepCopyObject()

	expected, ok := expectedObj.(client.Object)
	if !ok {
		return ApplyResultError, fmt.Errorf("expected object %T doest not implement client.Object", expectedObj)
	}

	modified, err := GetModifiedConfiguration(expected, gvks[0].GroupVersion(), false)
	if err != nil {
		return ApplyResultError, fmt.Errorf("failed to get modified configuration: %w", err)
	}

	if err := r.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil {
		if !apierrors.IsNotFound(err) {
			return ApplyResultError, err
		}

		if len(modified) < 1 {
			return ApplyResultError, fmt.Errorf("the given obj is invalid, whose modification is empty")
		}

		util.SetAnnotation(obj, consts.LastAppliedConfigAnnotation, string(modified))

		if err := c.Create(ctx, obj); err != nil {
			return ApplyResultError, err
		}
		return ApplyResultCreated, nil
	}

	original := GetOriginalConfiguration(obj)

	// Compared with the last applied, the object is not modified.
	if string(original) == string(modified) {
		return ApplyResultUnchanged, nil
	}
	// debug modified contents, discomment it when you need
	// fmt.Printf("modified, original:%s,\nmodified:%s\n diff: %s", string(original), string(modified), cmp.Diff(string(original), string(modified)))

	return ApplyResultPatched, c.Patch(ctx, obj, &threeWayMergePatch{expected: expected, gv: gvks[0].GroupVersion()})
}

func Apply(ctx context.Context, c client.Client, obj client.Object) error {
	_, err := ApplyWithResult(ctx, c, obj)
	return err
}

type threeWayMergePatch struct {
	expected client.Object
	gv       runtime.GroupVersioner
}

func (p *threeWayMergePatch) Type() types.PatchType {
	return types.MergePatchType
}

func (p *threeWayMergePatch) Data(obj client.Object) ([]byte, error) {
	original := GetOriginalConfiguration(obj)

	// set resourceVersion of current in modified
	version := obj.GetResourceVersion()
	obj.SetResourceVersion("")
	p.expected.SetResourceVersion(version)

	modified, err := GetModifiedConfiguration(p.expected, p.gv, true)
	if err != nil {
		return nil, err
	}

	encoder := scheme.Codecs.EncoderForVersion(serializer, p.gv)
	current, err := runtime.Encode(encoder, obj)
	if err != nil {
		return nil, err
	}

	preconditions := []mergepatch.PreconditionFunc{mergepatch.RequireKeyUnchanged("apiVersion"),
		mergepatch.RequireKeyUnchanged("kind"), mergepatch.RequireMetadataKeyUnchanged("name")}
	patch, err := jsonmergepatch.CreateThreeWayJSONMergePatch(original, modified, current, preconditions...)
	if err != nil {
		if mergepatch.IsPreconditionFailed(err) {
			return nil, fmt.Errorf("%s", "At least one of apiVersion, kind and name was changed")
		}
		return nil, err
	}

	return patch, nil
}

// checkObject checks if the given object is valid.
func checkObject(obj client.Object) error {
	if obj == nil {
		return fmt.Errorf("the given obj is nil")
	}

	if obj.GetName() == "" {
		return fmt.Errorf("the given obj's name is empty")
	}

	return nil
}

func sanitizeObjectLabels(obj client.Object) {
	obj.SetLabels(SanitizeLabels(obj.GetLabels()))
}

func SanitizeLabels(labels map[string]string) map[string]string {
	if len(labels) == 0 {
		return labels
	}
	sanitized := make(map[string]string, len(labels))
	for key, value := range labels {
		if validation.IsQualifiedName(key) != nil {
			continue
		}
		sanitized[key] = SanitizeLabelValue(value)
	}
	return sanitized
}

func SanitizeLabelValue(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return "unknown"
	}
	var b strings.Builder
	b.Grow(len(value))
	for _, r := range value {
		if isASCIILabelChar(r) {
			b.WriteRune(r)
			continue
		}
		b.WriteByte('-')
	}
	out := strings.Trim(b.String(), "-_.")
	if out == "" {
		return "unknown"
	}
	if len(out) > validation.LabelValueMaxLength {
		out = strings.Trim(out[:validation.LabelValueMaxLength], "-_.")
		if out == "" {
			return "unknown"
		}
	}
	return out
}

func isASCIILabelChar(r rune) bool {
	return r >= 'a' && r <= 'z' ||
		r >= 'A' && r <= 'Z' ||
		r >= '0' && r <= '9' ||
		r == '-' || r == '_' || r == '.'
}
