package managed_resources //nolint:staticcheck

import (
	"context"
	"fmt"
	"slices"
	"sync"

	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/api"
	k8serrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/util/sets"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/client-go/dynamic"
	"k8s.io/utils/ptr"
)

var (
	nsGVK = schema.GroupVersionKind{
		Group:   "",
		Version: "v1",
		Kind:    "Namespace",
	}
)

type Object struct {
	GVK       schema.GroupVersionKind
	Namespace string
	Name      string
}

type ObjectError struct {
	Object
	Error error
}

type DeleteOpts struct {
	AgentKey        api.AgentKey
	ProjectID       int64
	EnvironmentSlug string
	Objects         []Object
}

type ObjectManager struct {
	Client       dynamic.Interface
	Mapper       meta.RESTMapper
	Renderer     *Renderer
	FieldManager string
}

func (m *ObjectManager) Apply(ctx context.Context, objs []*unstructured.Unstructured) ([]*unstructured.Unstructured, []ObjectError) {
	clusterScoped, namespaced := splitNamespacedAndClusterScoped(objs)

	// Must dry run and then apply Namespace, etc so that namespaced resources pass their dry run (namespace has to exist)
	clusterScoped, errs := m.dryApply(ctx, clusterScoped)
	if len(errs) > 0 {
		return clusterScoped, errs
	}

	namespaced, errs = m.dryApply(ctx, namespaced)
	if len(errs) > 0 {
		return append(clusterScoped, namespaced...), errs
	}

	return append(clusterScoped, namespaced...), nil
}

func (m *ObjectManager) dryApply(ctx context.Context, objs []*unstructured.Unstructured) ([]*unstructured.Unstructured, []ObjectError) {
	_, errs := m.apply(ctx, objs, true)
	if len(errs) > 0 {
		return nil, errs // do not return anything from a dry run
	}

	return m.apply(ctx, objs, false)
}

func (m *ObjectManager) apply(ctx context.Context, objs []*unstructured.Unstructured, dryRun bool) ([]*unstructured.Unstructured, []ObjectError) {
	var dryRunObjs []string

	if dryRun {
		dryRunObjs = []string{metav1.DryRunAll}
	}

	applied := make(chan *unstructured.Unstructured)
	errs := make(chan ObjectError)

	for _, obj := range objs {
		go func() {
			gvk := obj.GetObjectKind().GroupVersionKind()
			ns := obj.GetNamespace()
			name := obj.GetName()
			mapping, err := m.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
			if err != nil {
				errs <- ObjectError{
					Object: Object{
						GVK:       gvk,
						Namespace: ns,
						Name:      name,
					},
					Error: fmt.Errorf("mapper: %w", err),
				}
				return
			}
			resp, err := m.Client.
				Resource(mapping.Resource).
				Namespace(ns). // it's ok if this is an empty string
				Apply(ctx, name, obj, metav1.ApplyOptions{
					Force:        true,
					FieldManager: m.FieldManager,
					DryRun:       dryRunObjs,
				})
			if err != nil {
				errs <- ObjectError{
					Object: Object{
						GVK:       gvk,
						Namespace: ns,
						Name:      name,
					},
					Error: fmt.Errorf("apply: %w", err),
				}
				return
			}
			applied <- resp
		}()
	}

	var retErrs []ObjectError
	retObjs := make([]*unstructured.Unstructured, 0, len(objs))
	for len(retObjs)+len(retErrs) < len(objs) {
		select {
		case obj := <-applied:
			retObjs = append(retObjs, obj)
		case e := <-errs:
			if dryRun {
				e.Error = fmt.Errorf("dry run: %w", e.Error)
			}
			retErrs = append(retErrs, e)
		}
	}
	return retObjs, retErrs
}

func (m *ObjectManager) Delete(ctx context.Context, opts DeleteOpts) ([]Object, []ObjectError) {
	var mu sync.Mutex
	var inProgress []Object
	var errs []ObjectError
	var wg wait.Group

	var notNamespace []Object
	namespaces := make(sets.Set[string]) // Set of namespaces to be deleted. Not going to bother deleting objects in these.

	deleteObjectAsync := func(obj Object) {
		wg.Start(func() {
			isInProgress, err := m.maybeDeleteObject(ctx, obj, opts.AgentKey, opts.ProjectID, opts.EnvironmentSlug)
			mu.Lock()
			defer mu.Unlock()
			if err != nil {
				errs = append(errs, ObjectError{
					Object: obj,
					Error:  fmt.Errorf("delete: %w", err),
				})
				return
			}
			if isInProgress {
				inProgress = append(inProgress, obj)
			}
		})
	}

	// 1. Find, check, and delete Namespace objects
	for _, obj := range opts.Objects {
		if obj.GVK != nsGVK {
			notNamespace = append(notNamespace, obj)
			continue
		}
		namespaces.Insert(obj.Name)
		deleteObjectAsync(obj)
	}

	// 2. Check and delete not Namespace objects.
	for _, obj := range notNamespace {
		if namespaces.Has(obj.Namespace) { // NS has been marked for deletion, no point in deleting individual objects in it.
			continue
		}
		deleteObjectAsync(obj)
	}
	wg.Wait() // wait for all goroutines to finish

	return inProgress, errs
}

func (m *ObjectManager) maybeDeleteObject(ctx context.Context, obj Object, agentKey api.AgentKey, projectID int64, environmentSlug string) (bool /* is in progress */, error) { //nolint: gocritic
	// 1. GVK -> GVR
	mapping, err := m.Mapper.RESTMapping(obj.GVK.GroupKind(), obj.GVK.Version)
	if err != nil {
		return false, fmt.Errorf("mapper: %w", err)
	}
	res := m.Client.
		Resource(mapping.Resource).
		Namespace(obj.Namespace) // it's ok if this is an empty string
	for {
		// 2. Get the object
		kObj, err := res.Get(ctx, obj.Name, metav1.GetOptions{})
		if err != nil {
			if k8serrors.IsNotFound(err) {
				return false, nil // not found
			}
			return false, fmt.Errorf("get: %w", err)
		}
		if kObj.GetDeletionTimestamp() != nil {
			// the object is already marked for deletion, nothing to do.
			// return true since the object is still there.
			return true, nil
		}
		// 3. Get the environments this object belongs to.
		envInfos, err := m.Renderer.ExtractEnvironmentInfosFromObject(kObj)
		if err != nil {
			return false, fmt.Errorf("extract environment info from object: %w", err)
		}
		origLen := len(envInfos)
		envInfos = slices.DeleteFunc(envInfos, func(env *EnvironmentInfo) bool {
			return env.AgentId == agentKey.ID && env.ProjectId == projectID && env.EnvironmentSlug == environmentSlug
		})
		switch len(envInfos) {
		case origLen: // doesn't belong to the environment being deleted (anymore), do nothing.
			return false, nil
		case 0: // it was the last environment the object belonged to, need to delete it.
			// 4. Delete the object.
			err = res.Delete(ctx, obj.Name, metav1.DeleteOptions{
				Preconditions: &metav1.Preconditions{
					UID:             ptr.To(kObj.GetUID()),
					ResourceVersion: ptr.To(kObj.GetResourceVersion()),
				},
				PropagationPolicy: ptr.To(metav1.DeletePropagationForeground),
			})
			switch {
			case err == nil:
				return true, nil // marked for deletion, should be revisited later (might have finalizers, etc)
			case k8serrors.IsNotFound(err):
				return false, nil
			case k8serrors.IsConflict(err): // some precondition is not met
				continue // try again
			default:
				return false, fmt.Errorf("delete: %w", err)
			}
		default: // still belongs to other environments, need to update it.
			// 4. Remove and set environment info on the object.
			m.Renderer.RemoveEnvironmentInfosOnObject(kObj)
			err = m.Renderer.SetEnvironmentInfosOnObject(kObj, envInfos)
			if err != nil {
				return false, fmt.Errorf("set environment infos: %w", err)
			}
			// 5. Update the object.
			_, err = res.Update(ctx, kObj, metav1.UpdateOptions{})
			switch {
			case err == nil:
				return false, nil
			case k8serrors.IsNotFound(err): // it was deleted by something
				return false, nil
			case k8serrors.IsConflict(err): // resource version mismatch
				continue // try again
			default:
				return false, fmt.Errorf("env info update: %w", err)
			}
		}
	}
}

func splitNamespacedAndClusterScoped(objs []*unstructured.Unstructured) ([]*unstructured.Unstructured, []*unstructured.Unstructured) {
	var clusterScoped []*unstructured.Unstructured
	var namespaced []*unstructured.Unstructured
	for _, obj := range objs {
		if obj.GetNamespace() == "" {
			clusterScoped = append(clusterScoped, obj)
		} else {
			namespaced = append(namespaced, obj)
		}
	}
	return clusterScoped, namespaced
}
