// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0

package customquotas

import (
	"context"
	"errors"
	"fmt"
	"sort"

	"github.com/go-logr/logr"
	k8smeta "k8s.io/apimachinery/pkg/api/meta"
	"k8s.io/apimachinery/pkg/api/resource"
	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/types"
	"sigs.k8s.io/controller-runtime/pkg/client"

	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
	"github.com/projectcapsule/capsule/internal/cache"
	"github.com/projectcapsule/capsule/pkg/api/meta"
	"github.com/projectcapsule/capsule/pkg/runtime/quota"
)

type quotaUsageReconcileInput struct {
	Log logr.Logger

	Client client.Client
	Mapper k8smeta.RESTMapper

	JSONPathCache *cache.JSONPathCache
	CELCache      *cache.CELCache

	Sources        []capsulev1beta2.CustomQuotaSpecSource
	ScopeSelectors []metav1.LabelSelector

	// For namespaced CustomQuota:
	//   []string{instance.Namespace}
	//
	// For GlobalCustomQuota:
	//   resolved namespaces or []string{"*"}
	Namespaces []string

	// Namespaced CustomQuota should only accept namespaced targets.
	RequireNamespacedTargets bool

	// Used for compiled target cache.
	CacheKey     string
	TargetsCache *cache.CompiledTargetsCache[string]
}

type quotaUsageReconcileResult struct {
	Targets []capsulev1beta2.CustomQuotaStatusTarget
	Usage   capsulev1beta2.CustomQuotaStatusUsage
	Claims  []capsulev1beta2.CustomQuotaClaimItem
}

type quotaClaimKey struct {
	UID       types.UID
	Group     string
	Version   string
	Kind      string
	Namespace string
	Name      string
}

func reconcileQuotaUsage(
	ctx context.Context,
	in quotaUsageReconcileInput,
	limit resource.Quantity,
) (quotaUsageReconcileResult, error) {
	out := quotaUsageReconcileResult{
		Targets: []capsulev1beta2.CustomQuotaStatusTarget{},
		Usage:   capsulev1beta2.CustomQuotaStatusUsage{},
		Claims:  nil,
	}

	for _, src := range in.Sources {
		kind := src.GroupVersionKind()

		mapping, err := in.Mapper.RESTMapping(kind.GroupKind(), kind.Version)
		if err != nil {
			return out, fmt.Errorf("failed to resolve REST mapping for %s: %w", kind.String(), err)
		}

		if in.RequireNamespacedTargets && mapping.Scope.Name() != k8smeta.RESTScopeNameNamespace {
			return out, fmt.Errorf("GVK %s is not namespaced", kind.String())
		}

		out.Targets = append(out.Targets, capsulev1beta2.CustomQuotaStatusTarget{
			GroupVersionKind:            metav1.GroupVersionKind(kind),
			CustomQuotaSpecSourceConfig: src.CustomQuotaSpecSourceConfig,
			Scope:                       mapping.Scope.Name(),
		})
	}

	targets, err := CompileTargets(in.JSONPathCache, in.CELCache, out.Targets)
	if err != nil {
		return out, err
	}

	if in.TargetsCache != nil && in.CacheKey != "" {
		in.TargetsCache.Set(in.CacheKey, targets)
	}

	var errs []error

	itemsByGVK := make(map[schema.GroupVersionKind][]unstructured.Unstructured, len(out.Targets))
	claimsByKey := make(map[quotaClaimKey]capsulev1beta2.CustomQuotaClaimItem)

	for _, target := range targets {
		gvk := schema.GroupVersionKind{
			Group:   target.Group,
			Version: target.Version,
			Kind:    target.Kind,
		}

		items, ok := itemsByGVK[gvk]
		if !ok {
			items, err = getResourcesByGVK(
				ctx,
				gvk,
				in.Client,
				target.Scope == k8smeta.RESTScopeNameNamespace,
				in.ScopeSelectors,
				in.Namespaces...,
			)
			if err != nil {
				errs = append(errs, fmt.Errorf("list resources for %s: %w", gvk.String(), err))

				continue
			}

			itemsByGVK[gvk] = items
		}

		in.Log.V(5).Info("listed resources for target",
			"gvk", gvk.String(),
			"count", len(items),
			"namespaces", in.Namespaces,
			"scopeSelectors", in.ScopeSelectors,
		)

		for _, item := range items {
			matches, err := MatchesCompiledSelectorsWithFields(ctx, item, target.CompiledSelectors)
			if err != nil {
				errs = append(errs, fmt.Errorf(
					"evaluate selectors for %s/%s (%s): %w",
					item.GetNamespace(),
					item.GetName(),
					item.GetObjectKind().GroupVersionKind().String(),
					err,
				))

				continue
			}

			if !matches {
				continue
			}

			rawUsage, err := usageForTarget(ctx, item, target)
			if err != nil {
				errs = append(errs, err)

				continue
			}

			accountingUsage := rawUsage.DeepCopy()

			switch target.Operation {
			case quota.OpSub:
				accountingUsage.Neg()
				out.Usage.Used.Add(accountingUsage)
				quota.ClampQuantityToZero(&out.Usage.Used)

			case quota.OpAdd, quota.OpCount:
				out.Usage.Used.Add(accountingUsage)

			default:
				errs = append(errs, fmt.Errorf(
					"unsupported operation %q for %s/%s (%s)",
					target.Operation,
					item.GetNamespace(),
					item.GetName(),
					item.GetObjectKind().GroupVersionKind().String(),
				))

				continue
			}

			key := quotaClaimKey{
				UID:       item.GetUID(),
				Group:     target.Group,
				Version:   target.Version,
				Kind:      target.Kind,
				Namespace: item.GetNamespace(),
				Name:      item.GetName(),
			}

			claim, exists := claimsByKey[key]
			if !exists {
				claim = capsulev1beta2.CustomQuotaClaimItem{
					GroupVersionKind: metav1.GroupVersionKind{
						Group:   target.Group,
						Version: target.Version,
						Kind:    target.Kind,
					},
					NamespacedObjectWithUIDReference: meta.NamespacedObjectWithUIDReference{
						Name:      item.GetName(),
						Namespace: meta.RFC1123SubdomainName(item.GetNamespace()),
						UID:       item.GetUID(),
					},
					Usage: resource.MustParse("0"),
				}
			}

			// Claims must mirror the same net per-object contribution used by admission reservations.
			// This is required so reservationMaterializedLedger(res, claims) can clear reservations.
			//
			// For pure subtraction sources, the claim remains present but clamps to zero.
			// Example:
			//   claim usage = max(0 - 2Gi, 0) = 0
			//
			// For mixed sources, e.g. count + sub cpu:
			//   claim usage = max(1 - 500m, 0) = 500m
			claim.Usage.Add(accountingUsage)
			quota.ClampQuantityToZero(&claim.Usage)

			claimsByKey[key] = claim
		}
	}

	quota.ClampQuantityToZero(&out.Usage.Used)

	out.Usage.Available = limit.DeepCopy()
	out.Usage.Available.Sub(out.Usage.Used)
	quota.ClampQuantityToZero(&out.Usage.Available)

	out.Claims = make([]capsulev1beta2.CustomQuotaClaimItem, 0, len(claimsByKey))
	for _, claim := range claimsByKey {
		out.Claims = append(out.Claims, claim)
	}

	sort.SliceStable(out.Claims, func(i, j int) bool {
		if out.Claims[i].Namespace != out.Claims[j].Namespace {
			return out.Claims[i].Namespace < out.Claims[j].Namespace
		}

		if out.Claims[i].Kind != out.Claims[j].Kind {
			return out.Claims[i].Kind < out.Claims[j].Kind
		}

		return out.Claims[i].Name < out.Claims[j].Name
	})

	if len(errs) > 0 {
		return out, errors.Join(errs...)
	}

	return out, nil
}

func usageForTarget(
	ctx context.Context,
	item unstructured.Unstructured,
	target cache.CompiledTarget,
) (resource.Quantity, error) {
	switch target.Operation {
	case quota.OpCount:
		return *resource.NewQuantity(1, resource.DecimalSI), nil

	case quota.OpAdd, quota.OpSub:
		var (
			usage resource.Quantity
			err   error
		)

		switch {
		case target.CompiledCEL != nil:
			usage, err = target.CompiledCEL.EvaluateQuantity(ctx, item)
		case target.CompiledPath != nil:
			usage, err = quota.ParseQuantityFromUnstructured(item, target.CompiledPath)
		default:
			err = fmt.Errorf("compiled usage expression is missing")
		}

		if err != nil {
			return resource.Quantity{}, fmt.Errorf(
				"get usage from %s/%s (%s) path %q cel %q op %q: %w",
				item.GetNamespace(),
				item.GetName(),
				item.GetObjectKind().GroupVersionKind().String(),
				target.Path,
				target.CEL,
				target.Operation,
				err,
			)
		}

		return usage, nil

	default:
		return resource.Quantity{}, fmt.Errorf(
			"unsupported operation %q for %s/%s (%s)",
			target.Operation,
			item.GetNamespace(),
			item.GetName(),
			item.GetObjectKind().GroupVersionKind().String(),
		)
	}
}
