// Copyright (c) 2025-2026 Tigera, Inc. All rights reserved.

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package istio

import (
	"context"
	"fmt"
	"regexp"
	"strconv"

	"github.com/elastic/cloud-on-k8s/v2/pkg/utils/stringsutil"
	"github.com/go-logr/logr"
	v3 "github.com/projectcalico/api/pkg/apis/projectcalico/v3"
	"github.com/projectcalico/api/pkg/lib/numorstring"
	"k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"sigs.k8s.io/controller-runtime/pkg/client"
	ctrl "sigs.k8s.io/controller-runtime/pkg/controller"
	"sigs.k8s.io/controller-runtime/pkg/handler"
	logf "sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/manager"
	"sigs.k8s.io/controller-runtime/pkg/reconcile"

	operatorv1 "github.com/projectcalico/calico/operator/api/v1"
	"github.com/projectcalico/calico/operator/pkg/controller"
	"github.com/projectcalico/calico/operator/pkg/controller/options"
	"github.com/projectcalico/calico/operator/pkg/controller/status"
	"github.com/projectcalico/calico/operator/pkg/controller/utils"
	"github.com/projectcalico/calico/operator/pkg/controller/utils/imageset"
	"github.com/projectcalico/calico/operator/pkg/ctrlruntime"
	"github.com/projectcalico/calico/operator/pkg/extensions"
	"github.com/projectcalico/calico/operator/pkg/render"
	"github.com/projectcalico/calico/operator/pkg/render/gatewayapi"
	"github.com/projectcalico/calico/operator/pkg/render/istio"
)

const IstioName = "istio"

// fieldOwner names this controller's FelixConfiguration writes in managed fields.
const fieldOwner = "operator.tigera.io/istio"

var (
	// blank assignment to verify that ReconcileIstio implements reconcile.Reconciler
	_ reconcile.Reconciler = &ReconcileIstio{}

	log = logf.Log.WithName("controller_istio")
)

// Add creates a new Istio Controller and adds it to the Manager. The Manager will set fields on the Controller
// and Start it when the Manager is Started.
//
// Start Watches within the Add function for any resources that this controller creates or monitors. This will trigger
// calls to Reconcile() when an instance of one of the watched resources is modified.
func Add(mgr manager.Manager, opts options.ControllerOptions) error {
	r := newReconciler(mgr, opts)

	c, err := ctrlruntime.NewController("istio-controller", mgr, ctrl.Options{Reconciler: r})
	if err != nil {
		return fmt.Errorf("failed to create istio-controller: %w", err)
	}

	// Watch for changes to TigeraStatus.
	if err = utils.AddTigeraStatusWatch(c, IstioName); err != nil {
		return fmt.Errorf("istio-controller failed to watch calico Tigerastatus: %w", err)
	}

	// Watch for changes to primary resource Istio
	err = c.WatchObject(&operatorv1.Istio{}, &handler.EnqueueRequestForObject{})
	if err != nil {
		return fmt.Errorf("istio-controller failed to watch primary resource: %v", err)
	}

	if err = utils.AddInstallationWatch(c); err != nil {
		return fmt.Errorf("istio-controller failed to watch Installation resource: %v", err)
	}

	err = c.WatchObject(&v3.FelixConfiguration{}, &handler.EnqueueRequestForObject{})
	if err != nil {
		return fmt.Errorf("istio-controller failed to watch FelixConfiguration resource: %w", err)
	}

	err = c.WatchObject(&v3.ClusterInformation{}, &handler.EnqueueRequestForObject{})
	if err != nil {
		return fmt.Errorf("istio-controller failed to watch ClusterInformation resource: %w", err)
	}

	if err = utils.AddPeriodicReconcile(c, utils.PeriodicReconcileTime, &handler.EnqueueRequestForObject{}); err != nil {
		return fmt.Errorf("istio-controller failed to create periodic reconcile watch: %w", err)
	}

	return nil
}

// newReconciler returns a new reconcile.Reconciler
func newReconciler(mgr manager.Manager, opts options.ControllerOptions) *ReconcileIstio {
	r := NewReconciler(ReconcilerOptions{
		Client:   mgr.GetClient(),
		Scheme:   mgr.GetScheme(),
		Status:   status.New(mgr.GetClient(), "istio", opts.KubernetesVersion),
		Provider: opts.DetectedProvider,
		Ext:      opts.Extensions,
	})

	r.status.Run(opts.ShutdownContext)
	return r
}

// ReconcilerOptions is what the Istio reconciler needs to run.
type ReconcilerOptions struct {
	Client   client.Client
	Scheme   *runtime.Scheme
	Status   status.StatusManager
	Provider operatorv1.Provider
	Ext      extensions.Extensions
}

// NewReconciler returns an Istio reconciler a caller can drive without a manager.
func NewReconciler(o ReconcilerOptions) *ReconcileIstio {
	return &ReconcileIstio{
		Client:   o.Client,
		scheme:   o.Scheme,
		status:   o.Status,
		provider: o.Provider,
		ext:      o.Ext,
	}
}

// ReconcileIstio reconciles a Istio object
type ReconcileIstio struct {
	client.Client
	scheme   *runtime.Scheme
	status   status.StatusManager
	provider operatorv1.Provider
	ext      extensions.Extensions
}

func (r *ReconcileIstio) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) {
	reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name)
	reqLogger.V(1).Info("Reconciling Istio")

	// Get the Istio CR.
	instance := &operatorv1.Istio{}
	err := r.Get(ctx, utils.DefaultInstanceKey, instance)
	if err != nil {
		if errors.IsNotFound(err) {
			reqLogger.V(1).Info("Istio object not found")
			r.status.OnCRNotFound()
			return reconcile.Result{}, nil
		}
		r.status.SetDegraded(operatorv1.ResourceReadError,
			fmt.Sprintf("Error querying for Istio CR: failed to get Istio %q", utils.DefaultInstanceKey),
			err,
			reqLogger,
		)
		return reconcile.Result{}, err
	}

	// Before the finalizer handling, so the status of a deleting CR is reported
	// after an operator restart too.
	r.status.OnCRFound()

	if res, err, finished := r.maintainFinalizer(ctx, instance, reqLogger); err != nil || finished {
		return res, err
	}

	// SetMetaData in the TigeraStatus such as observedGenerations.
	defer r.status.SetMetaData(&instance.ObjectMeta)

	// Changes for updating Istio status conditions.
	if request.Name == IstioName && request.Namespace == "" {
		ts := &operatorv1.TigeraStatus{}
		err := r.Get(ctx, types.NamespacedName{Name: IstioName}, ts)
		if err != nil {
			return reconcile.Result{}, err
		}
		instance.Status.Conditions = status.UpdateStatusCondition(instance.Status.Conditions, ts.Status.Conditions)
		if err := r.Status().Update(ctx, instance); err != nil {
			log.WithValues("reason", err).Info("Failed to update Istio status conditions.")
			return reconcile.Result{}, err
		}
	}

	// The CRD schema defaults these fields. Defaulting in memory as well covers a cluster
	// whose CRDs the operator doesn't manage.
	updateDefaults(instance)

	// Get the Installation, for k8s provider info.
	installationSpec, err := utils.GetComputedInstallationSpec(ctx, r)
	if err != nil {
		if errors.IsNotFound(err) {
			r.status.SetDegraded(operatorv1.ResourceNotFound, "Installation not found", err, reqLogger)
			return reconcile.Result{}, nil
		}
		r.status.SetDegraded(operatorv1.ResourceReadError, "Error querying installation", err, reqLogger)
		return reconcile.Result{}, err
	}

	pullSecrets, err := utils.GetInstallationPullSecrets(installationSpec, r)
	if err != nil {
		r.status.SetDegraded(operatorv1.ResourceReadError, "Error retrieving pull secrets", err, reqLogger)
		return reconcile.Result{}, err
	}

	clusterInfo, err := utils.FetchClusterInformation(ctx, r.Client)
	if err != nil {
		if errors.IsNotFound(err) {
			r.status.SetDegraded(operatorv1.ResourceNotFound,
				fmt.Sprintf("ClusterInformation %q not found; its clusterGUID is the Istio cluster ID", utils.DefaultInstanceKey.Name),
				err, reqLogger)
			return reconcile.Result{RequeueAfter: utils.StandardRetry}, nil
		}
		r.status.SetDegraded(operatorv1.ResourceReadError, "Error querying ClusterInformation", err, reqLogger)
		return reconcile.Result{}, err
	}
	clusterID := clusterInfo.Spec.ClusterGUID
	if err := validateClusterGUID(clusterID); err != nil {
		r.status.SetDegraded(operatorv1.ResourceValidationError,
			fmt.Sprintf("ClusterInformation %q cannot be used to set this cluster name in Istio: %v", utils.DefaultInstanceKey.Name, err),
			nil, reqLogger)
		return reconcile.Result{}, nil
	}

	// Get the Kubernetes Gateway API CRDs.
	essentialCRDs, optionalCRDs, err := gatewayapi.K8SGatewayAPICRDs(installationSpec.KubernetesProvider, r.scheme)
	if err != nil {
		r.status.SetDegraded(operatorv1.ResourceReadError, "Error rendering gateway API CRDs", err, log)
		return reconcile.Result{}, err
	}

	// Check CRDs are present and only create it if not
	handler := utils.NewComponentHandler(log, r, r.scheme, nil)
	handler.SetCreateOnly()
	err = handler.CreateOrUpdateOrDelete(ctx, render.NewCreationPassthrough(essentialCRDs...), nil)
	if err != nil && !errors.IsAlreadyExists(err) {
		r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating gateway API CRDs", err, log)
		return reconcile.Result{}, err
	}
	err = handler.CreateOrUpdateOrDelete(ctx, render.NewCreationPassthrough(optionalCRDs...), nil)
	if err != nil && !errors.IsAlreadyExists(err) {
		reqLogger.Info("Could not render all optional gateway API CRDs", "err", err)
	}

	// Render resources for Istio support
	istioCfg := &istio.Configuration{
		Installation:   installationSpec,
		PullSecrets:    pullSecrets,
		Istio:          instance,
		IstioNamespace: istio.IstioNamespace,
		Scheme:         r.scheme,
		ClusterID:      clusterID,
	}
	istioComponentCRDs, istioComponent, err := istio.Istio(istioCfg)
	if err != nil {
		r.status.SetDegraded(operatorv1.ResourceReadError, "Error initializing Istio components", err, reqLogger)
		return reconcile.Result{}, err
	}

	ci := controller.Inputs{
		RenderInputs: render.Inputs{Installation: installationSpec},
		Client:       r.Client,
	}
	ci, err = r.ext.Istio().ExtendInputs(ctx, ci)
	if err != nil {
		if reason, ok := extensions.DegradedReason(err); ok {
			r.status.SetDegraded(reason, err.Error(), nil, reqLogger)
			return reconcile.Result{}, err
		}
		r.status.SetDegraded(operatorv1.ResourceCreateError, "Error preparing Istio extension", err, reqLogger)
		return reconcile.Result{}, err
	}

	// Apply the image set
	if err = imageset.ApplyImageSet(ctx, r.Client, installationSpec.Variant, istioComponent); err != nil {
		r.status.SetDegraded(operatorv1.ResourceReadError, "Error with ImageSet", err, reqLogger)
		return reconcile.Result{}, err
	}

	// Unlike the Gateway API CRDs, which other implementations may share, the
	// Istio CRDs belong to the Istio the operator runs. Istio requires them to be
	// upgraded before istiod, so they are updated, not only created.
	err = utils.NewComponentHandler(log, r, r.scheme, nil).CreateOrUpdateOrDelete(ctx, istioComponentCRDs, nil)
	if err != nil {
		r.status.SetDegraded(operatorv1.ResourceCreateError, "Error rendering Calico Istio CRDs", err, log)
		return reconcile.Result{}, err
	}

	// Deploy Istio components, passing the Istio CR for the owner this time.
	modifier := utils.WithModifier(func(c render.Component) render.Component {
		return r.ext.Istio().Modify(c, ci.RenderInputs)
	})
	err = utils.NewComponentHandler(log, r, r.scheme, instance, modifier).CreateOrUpdateOrDelete(ctx, istioComponent, r.status)
	if err != nil {
		r.status.SetDegraded(operatorv1.ResourceCreateError, "Error rendering Calico Istio resources", err, log)
		return reconcile.Result{}, err
	}

	_, err = utils.PatchFelixConfiguration(ctx, r.Client, fieldOwner, func(fc *v3.FelixConfiguration) (bool, error) {
		return r.setIstioFelixConfiguration(ctx, instance, fc, false)
	})
	if err != nil {
		r.status.SetDegraded(operatorv1.ResourceCreateError, "Error patching felix configuration with Istio settings", err, log)
		return reconcile.Result{}, err
	}

	outdated, err := outdatedGatewayAPICRDs(ctx, r.Client)
	if err != nil {
		r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading Gateway API CRDs", err, reqLogger)
		return reconcile.Result{}, err
	}
	if len(outdated) > 0 {
		r.status.SetWarning(gatewayAPIWarningKey, gatewayAPIWarning(outdated))
	} else {
		r.status.ClearWarning(gatewayAPIWarningKey)
	}

	// Clear the degraded bit if we've reached this far.
	r.status.ClearDegraded()

	return reconcile.Result{}, nil
}

func updateDefaults(istio *operatorv1.Istio) {
	if istio.Spec.DSCPMark == nil {
		dscpMark := numorstring.DSCPFromInt(23)
		istio.Spec.DSCPMark = &dscpMark
	}
}

const clusterGUIDMaxLength = 233

// clusterGUIDPattern is what Istio accepts for a cluster ID.
var clusterGUIDPattern = regexp.MustCompile(`^[a-z0-9]([-.a-z0-9]*[a-z0-9])?$`)

func validateClusterGUID(guid string) error {
	if guid == "" {
		return fmt.Errorf("clusterGUID is empty")
	}
	if len(guid) > clusterGUIDMaxLength {
		return fmt.Errorf("clusterGUID is %d characters long, the maximum is %d", len(guid), clusterGUIDMaxLength)
	}
	if !clusterGUIDPattern.MatchString(guid) {
		return fmt.Errorf("clusterGUID %q must match %s", guid, clusterGUIDPattern)
	}
	return nil
}

func (r *ReconcileIstio) setIstioFelixConfiguration(ctx context.Context, instance *operatorv1.Istio, fc *v3.FelixConfiguration, remove bool) (bool, error) {
	ambientChanged, err := r.configureIstioAmbientMode(fc, remove)
	if err != nil {
		return false, err
	}
	dscpChanged, err := r.configureIstioDSCPMark(instance, fc, remove)
	if err != nil {
		return false, err
	}
	policySyncChanged, err := r.configurePolicySyncPathPrefix(ctx, instance, fc, remove)
	if err != nil {
		return false, err
	}
	return ambientChanged || dscpChanged || policySyncChanged, nil
}

func (r *ReconcileIstio) configureIstioAmbientMode(fc *v3.FelixConfiguration, remove bool) (bool, error) {
	var annotationMode *string
	if fc.Annotations[istio.IstioOperatorAnnotationMode] != "" {
		value := fc.Annotations[istio.IstioOperatorAnnotationMode]
		annotationMode = &value
	}

	// If the annotation does not match the spec value (ignoring both nil), it indicates a misconfiguration.
	match := annotationMode == nil && fc.Spec.IstioAmbientMode == nil ||
		annotationMode != nil && fc.Spec.IstioAmbientMode != nil && *annotationMode == string(*fc.Spec.IstioAmbientMode)

	if !match {
		return false, fmt.Errorf("felixconfig IstioAmbientMode modified by user")
	}

	if remove {
		if annotationMode == nil && fc.Spec.IstioAmbientMode == nil {
			return false, nil
		}
		delete(fc.Annotations, istio.IstioOperatorAnnotationMode)
		fc.Spec.IstioAmbientMode = nil
		return true, nil
	}

	istioModeDesired := v3.IstioAmbientModeEnabled
	if fc.Spec.IstioAmbientMode != nil && *fc.Spec.IstioAmbientMode == istioModeDesired &&
		annotationMode != nil && *annotationMode == string(istioModeDesired) {
		return false, nil
	}
	fc.Spec.IstioAmbientMode = &istioModeDesired
	if fc.Annotations == nil {
		fc.Annotations = make(map[string]string)
	}
	fc.Annotations[istio.IstioOperatorAnnotationMode] = string(istioModeDesired)
	return true, nil
}

func (r *ReconcileIstio) configureIstioDSCPMark(instance *operatorv1.Istio, fc *v3.FelixConfiguration, remove bool) (bool, error) {
	var annotationDSCP *numorstring.DSCP
	if fc.Annotations[istio.IstioOperatorAnnotationDSCP] != "" {
		value, err := strconv.ParseUint(fc.Annotations[istio.IstioOperatorAnnotationDSCP], 10, 6)
		if err != nil {
			return false, err
		}
		dscp := numorstring.DSCPFromInt(uint8(value))
		annotationDSCP = &dscp
	}

	// Return an error if it appears that FelixConfiguration has been modified out of band.
	match := annotationDSCP == nil && fc.Spec.IstioDSCPMark == nil ||
		annotationDSCP != nil && fc.Spec.IstioDSCPMark != nil && annotationDSCP.ToUint8() == fc.Spec.IstioDSCPMark.ToUint8()

	if !match {
		return false, fmt.Errorf("felixconfig IstioDSCPMark modified by user")
	}

	if remove || instance.Spec.DSCPMark == nil {
		if annotationDSCP == nil && fc.Spec.IstioDSCPMark == nil {
			return false, nil
		}
		delete(fc.Annotations, istio.IstioOperatorAnnotationDSCP)
		fc.Spec.IstioDSCPMark = nil
		return true, nil
	}

	istioDSCPMarkDesired := *instance.Spec.DSCPMark
	if fc.Spec.IstioDSCPMark != nil && annotationDSCP != nil &&
		fc.Spec.IstioDSCPMark.ToUint8() == istioDSCPMarkDesired.ToUint8() &&
		annotationDSCP.ToUint8() == istioDSCPMarkDesired.ToUint8() {
		return false, nil
	}
	fc.Spec.IstioDSCPMark = &istioDSCPMarkDesired
	if fc.Annotations == nil {
		fc.Annotations = make(map[string]string)
	}
	fc.Annotations[istio.IstioOperatorAnnotationDSCP] = strconv.FormatUint(uint64(istioDSCPMarkDesired.ToUint8()), 10)
	return true, nil
}

// configurePolicySyncPathPrefix reconciles FelixConfiguration.policySyncPathPrefix,
// which the variant extension shares with this controller.
func (r *ReconcileIstio) configurePolicySyncPathPrefix(ctx context.Context, instance *operatorv1.Istio, fc *v3.FelixConfiguration, remove bool) (bool, error) {
	var istioNeeds bool
	if !remove {
		// Mirror the renderer gate at pkg/render/istio/istio.go, which reads the same
		// computed variant, so the policy-sync field tracks the renderer's decision to
		// ship the L7 waypoint sidecar.
		installationSpec, err := utils.GetComputedInstallationSpec(ctx, r.Client)
		if err != nil && !errors.IsNotFound(err) {
			return false, err
		}
		var variant operatorv1.ProductVariant
		if installationSpec != nil {
			variant = installationSpec.Variant
		}
		istioNeeds = utils.IstioRequiresPolicySync(instance, variant)
	}

	alNeeds, err := r.ext.Istio().PolicySyncRequired(ctx, r.Client)
	if err != nil {
		return false, err
	}

	desired := utils.DesiredPolicySyncPathPrefix(fc.Spec.PolicySyncPathPrefix, alNeeds, istioNeeds)
	if fc.Spec.PolicySyncPathPrefix == desired {
		return false, nil
	}
	fc.Spec.PolicySyncPathPrefix = desired
	return true, nil
}

func (r *ReconcileIstio) maintainFinalizer(ctx context.Context, instance *operatorv1.Istio, reqLogger logr.Logger) (res reconcile.Result, err error, finalized bool) {
	// Executing clean up on finalizing
	if !instance.DeletionTimestamp.IsZero() {
		if _, err = utils.PatchFelixConfiguration(ctx, r.Client, fieldOwner, func(fc *v3.FelixConfiguration) (bool, error) {
			return r.setIstioFelixConfiguration(ctx, instance, fc, true)
		}); err != nil {
			r.status.SetDegraded(operatorv1.ResourceReadError, "Error cleaning up felix configuration", err, reqLogger)
			return
		}
		patchFrom := client.MergeFrom(instance.DeepCopy())
		instance.Finalizers = stringsutil.RemoveStringInSlice(istio.IstioFinalizer, instance.Finalizers)
		if err = r.Patch(ctx, instance, patchFrom); err != nil {
			r.status.SetDegraded(operatorv1.ResourceReadError, "Error removing finalizer on Istio", err, reqLogger)
			return
		}

		r.status.ClearDegraded()
		return res, nil, true
	}

	if !stringsutil.StringInSlice(istio.IstioFinalizer, instance.Finalizers) {
		patchFrom := client.MergeFrom(instance.DeepCopy())
		instance.Finalizers = append(instance.Finalizers, istio.IstioFinalizer)
		if err = r.Patch(ctx, instance, patchFrom); err != nil {
			r.status.SetDegraded(operatorv1.ResourceReadError, "Error setting finalizer on Istio", err, reqLogger)
			return
		}
	}

	return
}
