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

package validation

import (
	"context"
	"fmt"
	"slices"
	"strings"

	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
	"github.com/projectcapsule/capsule/internal/cache"
	apirules "github.com/projectcapsule/capsule/pkg/api/rules"
	"github.com/projectcapsule/capsule/pkg/api/runtime"
	"github.com/projectcapsule/capsule/pkg/ruleengine"
	ad "github.com/projectcapsule/capsule/pkg/runtime/admission"
	"github.com/projectcapsule/capsule/pkg/runtime/events"
	"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)

type ingressRules struct {
	regexCache *cache.RegexCache
}

func IngressRules(regexCache *cache.RegexCache) handlers.TypedHandlerWithTenantWithRuleset[*unstructured.Unstructured] {
	if regexCache == nil {
		regexCache = cache.NewRegexCache()
	}

	return &ingressRules{regexCache: regexCache}
}

func (h *ingressRules) OnCreate(
	_ client.Client,
	_ client.Reader,
	obj *unstructured.Unstructured,
	_ admission.Decoder,
	recorder events.EventRecorder,
	tnt *capsulev1beta2.Tenant,
	bodies []*apirules.NamespaceRuleBodyNamespace,
) handlers.Func {
	return h.validate(obj, recorder, tnt, bodies)
}

func (h *ingressRules) OnUpdate(
	_ client.Client,
	_ client.Reader,
	_ *unstructured.Unstructured,
	obj *unstructured.Unstructured,
	_ admission.Decoder,
	recorder events.EventRecorder,
	tnt *capsulev1beta2.Tenant,
	bodies []*apirules.NamespaceRuleBodyNamespace,
) handlers.Func {
	return h.validate(obj, recorder, tnt, bodies)
}

func (*ingressRules) OnDelete(
	client.Client,
	client.Reader,
	*unstructured.Unstructured,
	admission.Decoder,
	events.EventRecorder,
	*capsulev1beta2.Tenant,
	[]*apirules.NamespaceRuleBodyNamespace,
) handlers.Func {
	return func(context.Context, admission.Request) *admission.Response { return nil }
}

func (h *ingressRules) validate(
	obj *unstructured.Unstructured,
	recorder events.EventRecorder,
	tnt *capsulev1beta2.Tenant,
	bodies []*apirules.NamespaceRuleBodyNamespace,
) handlers.Func {
	return func(ctx context.Context, req admission.Request) *admission.Response {
		gvk := schema.GroupVersionKind{Group: req.Kind.Group, Version: req.Kind.Version, Kind: req.Kind.Kind}

		resourceType, supported := ingressTypeForGVK(gvk)
		if !supported {
			return nil
		}

		enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies)

		evaluation, err := h.evaluate(obj, resourceType, enforceBodies)
		if err != nil {
			return ad.Deny(err.Error())
		}

		if evaluation == nil {
			return nil
		}

		for _, audit := range evaluation.Audits {
			recorder.LabeledEvent(
				obj,
				corev1.EventTypeNormal,
				events.ReasonNamespaceRuleAudit,
				events.ActionRuleAudit,
				audit.Message,
			).
				WithRelated(tnt).
				WithTenantLabel(tnt).
				WithRequestAnnotations(req).
				Emit(ctx)
		}

		if err := evaluation.BlockingError(); err != nil {
			recorder.LabeledEvent(
				obj,
				corev1.EventTypeWarning,
				events.ReasonForbiddenIngressHostname,
				events.ActionValidationDenied,
				err.Error(),
			).
				WithRelated(tnt).
				WithTenantLabel(tnt).
				WithRequestAnnotations(req).
				Emit(ctx)

			return ad.Deny(err.Error())
		}

		return nil
	}
}

func (h *ingressRules) evaluate(
	obj *unstructured.Unstructured,
	resourceType apirules.IngressType,
	enforceBodies []*apirules.NamespaceRuleEnforceBody,
) (*ruleengine.Evaluation, error) {
	if obj == nil || !hasIngressHostnameRules(resourceType, enforceBodies) {
		return nil, nil
	}

	values, err := ingressHostnameValues(obj, resourceType)
	if err != nil {
		return nil, err
	}

	if len(values) == 0 {
		values = []ruleengine.Value{{Path: hostnameRootPath(resourceType)}}
	}

	evaluation := &ruleengine.Evaluation{}
	hasAuditRules := hasIngressHostnameRulesForAction(resourceType, enforceBodies, apirules.ActionTypeAudit)
	hasEnforcingRules := hasEnforcingIngressHostnameRules(resourceType, enforceBodies)

	for _, value := range values {
		if strings.TrimSpace(value.Value) != "" {
			continue
		}

		if hasAuditRules {
			evaluation.Audits = append(evaluation.Audits, missingHostnameAuditDecision(value, resourceType))
		}

		if hasEnforcingRules {
			evaluation.Blocking = missingHostnameDecision(value, resourceType)

			return evaluation, nil
		}
	}

	hostnameEvaluation, err := ruleengine.EvaluateEnforce(
		obj,
		enforceBodies,
		ruleengine.Set[runtime.ExpressionMatch, *unstructured.Unstructured]{
			Name:        "ingress hostname",
			EventReason: events.ReasonForbiddenIngressHostname,
			Values: func(*unstructured.Unstructured) []ruleengine.Value {
				return values
			},
			Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch {
				if enforce == nil || !containsIngressType(enforce.Ingress.Types, resourceType) {
					return nil
				}

				return enforce.Ingress.Hostnames
			},
			Matches: func(match runtime.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) {
				matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value)
				if err != nil {
					return ruleengine.Match{}, err
				}

				return ruleengine.Match{Matched: matched, MatchedValue: runtime.DescribeExpressionMatch(match)}, nil
			},
			RuleDescription:    runtime.DescribeExpressionMatch,
			AllowedDescription: "Allowed hostnames",
		},
	)
	if err != nil {
		return nil, err
	}

	evaluation.Append(hostnameEvaluation)

	return evaluation, nil
}

//nolint:exhaustive
func ingressTypeForGVK(gvk schema.GroupVersionKind) (apirules.IngressType, bool) {
	switch {
	case gvk.Group == "networking.k8s.io" && gvk.Version == "v1" && gvk.Kind == string(apirules.IngressTypeIngress):
		return apirules.IngressTypeIngress, true
	case gvk.Group == "route.openshift.io" && gvk.Version == "v1" && gvk.Kind == string(apirules.IngressTypeRoute):
		return apirules.IngressTypeRoute, true
	case gvk.Group == "gateway.networking.k8s.io" && gvk.Version == "v1":
		resourceType := apirules.IngressType(gvk.Kind)
		switch resourceType {
		case apirules.IngressTypeListenerSet,
			apirules.IngressTypeHTTPRoute,
			apirules.IngressTypeGateway,
			apirules.IngressTypeTLSRoute,
			apirules.IngressTypeGRPCRoute:
			return resourceType, true
		}
	}

	return "", false
}

func hasIngressHostnameRules(resourceType apirules.IngressType, bodies []*apirules.NamespaceRuleEnforceBody) bool {
	for _, body := range bodies {
		if body != nil && containsIngressType(body.Ingress.Types, resourceType) && len(body.Ingress.Hostnames) > 0 {
			return true
		}
	}

	return false
}

func hasIngressHostnameRulesForAction(
	resourceType apirules.IngressType,
	bodies []*apirules.NamespaceRuleEnforceBody,
	action apirules.ActionType,
) bool {
	for _, body := range bodies {
		if body != nil &&
			body.Action.OrDefault() == action &&
			containsIngressType(body.Ingress.Types, resourceType) &&
			len(body.Ingress.Hostnames) > 0 {
			return true
		}
	}

	return false
}

func hasEnforcingIngressHostnameRules(
	resourceType apirules.IngressType,
	bodies []*apirules.NamespaceRuleEnforceBody,
) bool {
	for _, body := range bodies {
		if body != nil &&
			body.Action.OrDefault() != apirules.ActionTypeAudit &&
			containsIngressType(body.Ingress.Types, resourceType) &&
			len(body.Ingress.Hostnames) > 0 {
			return true
		}
	}

	return false
}

func containsIngressType(types []apirules.IngressType, expected apirules.IngressType) bool {
	return slices.Contains(types, expected)
}

func missingHostnameDecision(value ruleengine.Value, resourceType apirules.IngressType) *ruleengine.Decision {
	return &ruleengine.Decision{
		SetName:     "ingress hostname",
		EventReason: events.ReasonForbiddenIngressHostname,
		Action:      apirules.ActionTypeDeny,
		Value:       value,
		Message: fmt.Sprintf(
			"hostname is required at %s because hostname rules target %s",
			value.Path,
			resourceType,
		),
	}
}

func missingHostnameAuditDecision(value ruleengine.Value, resourceType apirules.IngressType) *ruleengine.Decision {
	return &ruleengine.Decision{
		SetName:     "ingress hostname",
		EventReason: events.ReasonNamespaceRuleAudit,
		Action:      apirules.ActionTypeAudit,
		Value:       value,
		Message: fmt.Sprintf(
			"empty hostname detected at %s for %s by audit namespace rule",
			value.Path,
			resourceType,
		),
	}
}

//nolint:exhaustive
func hostnameRootPath(resourceType apirules.IngressType) string {
	switch resourceType {
	case apirules.IngressTypeIngress:
		return "spec.rules[].host"
	case apirules.IngressTypeRoute:
		return "spec.host"
	case apirules.IngressTypeGateway, apirules.IngressTypeListenerSet:
		return "spec.listeners[].hostname"
	default:
		return "spec.hostnames[]"
	}
}

func ingressHostnameValues(obj *unstructured.Unstructured, resourceType apirules.IngressType) ([]ruleengine.Value, error) {
	switch resourceType {
	case apirules.IngressTypeIngress:
		return ingressValues(obj)
	case apirules.IngressTypeRoute:
		return routeObjectValues(obj)
	case apirules.IngressTypeGateway, apirules.IngressTypeListenerSet:
		return listenerValues(obj)
	case apirules.IngressTypeHTTPRoute, apirules.IngressTypeTLSRoute, apirules.IngressTypeGRPCRoute:
		return routeValues(obj)
	default:
		return nil, nil
	}
}

func routeObjectValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) {
	host, _, err := unstructured.NestedString(obj.Object, "spec", "host")
	if err != nil {
		return nil, fmt.Errorf("read spec.host: %w", err)
	}

	return []ruleengine.Value{{Value: strings.TrimSpace(host), Path: "spec.host"}}, nil
}

func ingressValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) {
	values := make([]ruleengine.Value, 0)

	rules, found, err := unstructured.NestedSlice(obj.Object, "spec", "rules")
	if err != nil {
		return nil, fmt.Errorf("read spec.rules: %w", err)
	}

	if found {
		for i, item := range rules {
			rule, ok := item.(map[string]any)
			if !ok {
				return nil, fmt.Errorf("spec.rules[%d] is not an object", i)
			}

			host, _, err := unstructured.NestedString(rule, "host")
			if err != nil {
				return nil, fmt.Errorf("read spec.rules[%d].host: %w", i, err)
			}

			values = append(values, ruleengine.Value{Value: strings.TrimSpace(host), Path: fmt.Sprintf("spec.rules[%d].host", i)})
		}
	}

	tls, found, err := unstructured.NestedSlice(obj.Object, "spec", "tls")
	if err != nil {
		return nil, fmt.Errorf("read spec.tls: %w", err)
	}

	if found {
		for i, item := range tls {
			entry, ok := item.(map[string]any)
			if !ok {
				return nil, fmt.Errorf("spec.tls[%d] is not an object", i)
			}

			hosts, hostsFound, err := unstructured.NestedStringSlice(entry, "hosts")
			if err != nil {
				return nil, fmt.Errorf("read spec.tls[%d].hosts: %w", i, err)
			}

			if !hostsFound || len(hosts) == 0 {
				values = append(values, ruleengine.Value{Path: fmt.Sprintf("spec.tls[%d].hosts", i)})

				continue
			}

			for j, host := range hosts {
				values = append(values, ruleengine.Value{Value: strings.TrimSpace(host), Path: fmt.Sprintf("spec.tls[%d].hosts[%d]", i, j)})
			}
		}
	}

	return values, nil
}

func listenerValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) {
	listeners, found, err := unstructured.NestedSlice(obj.Object, "spec", "listeners")
	if err != nil {
		return nil, fmt.Errorf("read spec.listeners: %w", err)
	}

	if !found {
		return nil, nil
	}

	values := make([]ruleengine.Value, 0, len(listeners))

	for i, item := range listeners {
		listener, ok := item.(map[string]any)
		if !ok {
			return nil, fmt.Errorf("spec.listeners[%d] is not an object", i)
		}

		hostname, _, err := unstructured.NestedString(listener, "hostname")
		if err != nil {
			return nil, fmt.Errorf("read spec.listeners[%d].hostname: %w", i, err)
		}

		values = append(values, ruleengine.Value{Value: strings.TrimSpace(hostname), Path: fmt.Sprintf("spec.listeners[%d].hostname", i)})
	}

	return values, nil
}

func routeValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) {
	hostnames, found, err := unstructured.NestedStringSlice(obj.Object, "spec", "hostnames")
	if err != nil {
		return nil, fmt.Errorf("read spec.hostnames: %w", err)
	}

	if !found {
		return nil, nil
	}

	values := make([]ruleengine.Value, 0, len(hostnames))
	for i, hostname := range hostnames {
		values = append(values, ruleengine.Value{Value: strings.TrimSpace(hostname), Path: fmt.Sprintf("spec.hostnames[%d]", i)})
	}

	return values, nil
}
