/*
Copyright 2023.

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 controller

import (
	"context"
	"crypto/md5"
	"fmt"
	"net"
	"net/http"
	"sort"
	"strings"
	"time"

	cmapiutil "github.com/cert-manager/cert-manager/pkg/api/util"
	certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
	cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1"
	"github.com/miekg/dns"
	"github.com/prometheus/client_golang/prometheus"
	networkingv1 "k8s.io/api/networking/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/fields"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/handler"
	"sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/metrics"
	"sigs.k8s.io/controller-runtime/pkg/reconcile"
	"sigs.k8s.io/controller-runtime/pkg/source"

	customdomainv1 "github.com/tidbcloud/api-gateway/operator/api/v1"
	"github.com/tidbcloud/api-gateway/operator/internal/util"
	"github.com/tidbcloud/tiinf/transport/crossregion"
)

const (
	namespace                 = "apigateway_operator"
	certAndSecertKey          = ".spec.secretName"
	customDomainMd5Annotation = "apigateway.tidbcloud.com/domain.md5"
)

var (
	staticDnsCheckInterval  = time.Minute
	expireDuration          = 24 * time.Hour
	customDomainStatusGauge = prometheus.NewGaugeVec(
		prometheus.GaugeOpts{
			Namespace: namespace,
			Name:      "custom_domain_status",
		},
		[]string{"name", "namespace", "cond_type", "cond_status"},
	)
)

func init() {
	metrics.Registry.MustRegister(customDomainStatusGauge)
}

// CustomDomainReconciler reconciles a CustomDomain object
type CustomDomainReconciler struct {
	client.Client
	Scheme            *runtime.Scheme
	IssuerName        string
	DnsConfig         *dns.ClientConfig
	CrossRegionClient *crossregion.CrossRegionClient
}

//+kubebuilder:rbac:groups=customdomain.apigateway.tidbcloud.com,resources=customdomains,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=customdomain.apigateway.tidbcloud.com,resources=customdomains/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=customdomain.apigateway.tidbcloud.com,resources=customdomains/finalizers,verbs=update
//+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=get;list;watch

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the CustomDomain object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.0/pkg/reconcile
func (r *CustomDomainReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := log.FromContext(ctx)
	log.Info("CustomDomainReconciler reconcile started")

	var domain customdomainv1.CustomDomain
	if err := r.Get(ctx, req.NamespacedName, &domain); err != nil {
		log.Error(err, "unable to fetch CustomDomain")
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	defer func() {
		customDomainStatusGauge.DeletePartialMatch(prometheus.Labels{"name": domain.Name, "namespace": domain.Namespace})
		for _, cond := range domain.Status.Conditions {
			customDomainStatusGauge.With(prometheus.Labels{
				"name":        domain.Name,
				"namespace":   domain.Namespace,
				"cond_type":   string(cond.Type),
				"cond_status": string(cond.Status),
			}).Set(1)
		}
	}()

	// Check if DNS has been configured properly
	ok, ttl, err := r.isDnsConfigured(ctx, domain.Spec.Host, domain.Spec.Cname)
	log.Info("isDnsConfigured result", "ok", ok, "ttl", ttl, "host", domain.Spec.Host)
	if err != nil {
		log.Error(err, "dns check failed", "host", domain.Spec.Host)
		return ctrl.Result{}, err
	} else if !ok {
		cond := util.GetCustomDomainCondition(&domain, customdomainv1.CustomDomainConditionWaitingDNS)
		if cond == nil || cond.Status == customdomainv1.ConditionFalse {
			util.SetExclusiveCustomDomainCondition(&domain, customdomainv1.CustomDomainConditionWaitingDNS, customdomainv1.ConditionTrue)
			log.Info("dns not configured, waiting until dns configured", "ttl", ttl, "host", domain.Spec.Host)
			if err := r.Status().Update(ctx, &domain); err != nil {
				log.Error(err, "failed to update domain status", "host", domain.Spec.Host)
				return ctrl.Result{}, err
			}
			return ctrl.Result{RequeueAfter: staticDnsCheckInterval}, nil
		} else {
			log.Info("dns not configured, waiting until dns configured", "ttl", ttl, "LastTransitionTime", cond.LastTransitionTime, "host", domain.Spec.Host)
			// if DNS pending too long, notify dataservice the record has expired.
			// And dataservice will eventually delete this record in k8s
			// If http request to dataservice failed, will retry next reconcile
			if time.Since(cond.LastTransitionTime.Time) > expireDuration {
				log.Info("dns pending too long, notify expired", "LastTransitionTime", cond.LastTransitionTime.Time, "host", domain.Spec.Host)
				if err := r.notifyCustomDomainExpired(ctx, &domain); err != nil {
					log.Error(err, "notify expired failed", "host", domain.Spec.Host)
				}
			}
			return ctrl.Result{RequeueAfter: staticDnsCheckInterval}, nil
		}
	}

	// Handle child ingresses
	var childIngressList networkingv1.IngressList
	if err := r.List(ctx, &childIngressList, client.InNamespace(req.Namespace), client.MatchingFields{resouceOwnerKey: req.Name}); err != nil {
		log.Error(err, "unable to list child Ingresses", "host", domain.Spec.Host)
		return ctrl.Result{}, err
	}
	var childIngress *networkingv1.Ingress
	for _, ing := range childIngressList.Items {
		if r.isIngressMatchWithCustomDomain(&domain, &ing) {
			childIngress = ing.DeepCopy()
		} else {
			if err := r.Delete(ctx, &ing); err != nil {
				log.Error(err, "failed to delete ingress", "ingress.Name", ing.Name, "host", domain.Spec.Host)
				return ctrl.Result{}, err
			}
		}
	}
	if childIngress == nil {
		childIngress, err := r.constructIngressForCustomDomain(&domain)
		if err != nil {
			log.Error(err, "failed to constructIngressForCustomDomain", "host", domain.Spec.Host)
			return ctrl.Result{}, err
		}
		if err := r.Create(ctx, childIngress); err != nil {
			log.Error(err, "unable to create Ingress for CustomDomain", "host", domain.Spec.Host)
			return ctrl.Result{}, err
		}
	}

	// Check certificate status and vision
	var certificate certmanagerv1.Certificate
	var certificateRevision = 0
	if err := r.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: secretAndCertNameForCustomDomain(&domain)}, &certificate); err != nil {
		log.Error(err, "unable to fetch certificate", "host", domain.Spec.Host)
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}
	// if certificate revision was updated, it indicates certificate request was success
	if !cmapiutil.CertificateHasCondition(&certificate, certmanagerv1.CertificateCondition{
		Type:   certmanagerv1.CertificateConditionReady,
		Status: cmmeta.ConditionTrue,
	}) {
		log.Info("certificate not ready, wait until certificate ready", "host", domain.Spec.Host)
		util.SetExclusiveCustomDomainCondition(&domain, customdomainv1.CustomDomainConditionIssuing, customdomainv1.ConditionTrue)
		if err := r.Status().Update(ctx, &domain); err != nil {
			log.Error(err, "failed to update domain status", "host", domain.Spec.Host)
			return ctrl.Result{}, err
		}
		return ctrl.Result{}, nil
	}
	if certificate.Status.Revision != nil {
		certificateRevision = *certificate.Status.Revision
	}
	// if certificate.status.revision == 0, it indicates that the certificate content stored in secret has not been updated
	// in this case, we can reuse the certificate, so we just keep domain.status.certificateRevision
	if certificateRevision != 0 && certificateRevision != domain.Status.CertificateRevision {
		log.Info("deploying custom domain", "certificate.revision", certificateRevision, "customdomain.certificateRevision", domain.Status.CertificateRevision, "host", domain.Spec.Host)
		domain.Status.CertificateRevision = certificateRevision
		util.SetExclusiveCustomDomainCondition(&domain, customdomainv1.CustomDomainConditionDeploying, customdomainv1.ConditionTrue)
		if err := r.Status().Update(ctx, &domain); err != nil {
			log.Error(err, "failed to update domain status", "host", domain.Spec.Host)
			return ctrl.Result{}, err
		}
	}

	// Handle child CustomDomainDeployment, create new one for actual deployment action. then delete extra children
	var childDeploys customdomainv1.CustomDomainDeploymentList
	if err := r.List(ctx, &childDeploys, client.InNamespace(req.Namespace), client.MatchingFields{resouceOwnerKey: req.Name}); err != nil {
		log.Error(err, "unalbe to list child customdomaindeployment", "host", domain.Spec.Host)
		return ctrl.Result{}, err
	}
	var deploysToBeDelete []*customdomainv1.CustomDomainDeployment
	var deploysExpired []*customdomainv1.CustomDomainDeployment
	var matchedDeploy *customdomainv1.CustomDomainDeployment
	for _, deploy := range childDeploys.Items {
		if deploy.Spec.Host != domain.Spec.Host {
			deploysToBeDelete = append(deploysToBeDelete, deploy.DeepCopy())
		} else if deploy.Spec.CertificateRevision == domain.Status.CertificateRevision {
			matchedDeploy = deploy.DeepCopy()
			if deploy.Status.AlbRevision == domain.Status.CertificateRevision && util.CustomDomainDeploymentHasCondition(&deploy, customdomainv1.CustomDomainDeploymentCondition{
				Type:   customdomainv1.CustomDomainDeploymentConditionReady,
				Status: customdomainv1.ConditionTrue,
			}) {
				log.Info("custom domain has been ready", "customdomain.certificateRevision", domain.Status.CertificateRevision, "host", domain.Spec.Host)
				domain.Status.AlbRevision = deploy.Status.AlbRevision
				util.SetExclusiveCustomDomainCondition(&domain, customdomainv1.CustomDomainConditionReady, customdomainv1.ConditionTrue)
				if err := r.Status().Update(ctx, &domain); err != nil {
					log.Error(err, "failed to update domain status", "host", domain.Spec.Host)
					return ctrl.Result{}, err
				}
			}
		} else if deploy.Status.AlbRevision < domain.Status.CertificateRevision && util.CustomDomainDeploymentHasCondition(&deploy, customdomainv1.CustomDomainDeploymentCondition{
			Type:   customdomainv1.CustomDomainDeploymentConditionRetired,
			Status: customdomainv1.ConditionTrue,
		}) {
			deploysExpired = append(deploysExpired, deploy.DeepCopy())
		}
	}
	if matchedDeploy == nil {
		deploy, err := r.constructDeploymentForCustomDomain(&domain)
		if err != nil {
			log.Info("failed to construct deploy", "host", domain.Spec.Host)
			return ctrl.Result{}, err
		}
		if err := r.Create(ctx, deploy); err != nil {
			log.Error(err, "unable to create deploy for CustomDomain", "host", domain.Spec.Host)
			return ctrl.Result{}, err
		}
	}
	if domain.Spec.CustomDomainDeploymentHistoryLimit > 0 && len(deploysExpired) > domain.Spec.CustomDomainDeploymentHistoryLimit {
		sort.Slice(deploysExpired, func(i, j int) bool {
			return deploysExpired[i].Status.AlbRevision < deploysExpired[j].Status.AlbRevision
		})
		deploysToBeDelete = append(deploysToBeDelete, deploysExpired[:len(deploysExpired)-domain.Spec.CustomDomainDeploymentHistoryLimit]...)
	}
	if len(deploysToBeDelete) > 0 {
		for _, deploy := range deploysToBeDelete {
			log.Info("delete extra custom domain deployment", "deploy.name", deploy.Name, "host", domain.Spec.Host)
			if err := r.Delete(ctx, deploy); err != nil {
				log.Info("delete custom domain deployment failed", "err", err, "deploy.name", deploy.Name, "host", domain.Spec.Host)
			}
		}
	}

	if util.CustomDomainHasConditon(&domain, customdomainv1.CustomDomainCondition{
		Type:   customdomainv1.CustomDomainConditionReady,
		Status: customdomainv1.ConditionTrue,
	}) {
		log.Info("custom domain is succeed, will do next dns check after a while", "ttl", ttl, "host", domain.Spec.Host)
		// next dns check time is the larger one between 1min and 2*ttl
		requeueDuration := maxDuration(staticDnsCheckInterval, time.Duration(2*ttl)*time.Second)
		return ctrl.Result{RequeueAfter: requeueDuration}, nil
	}

	log.Info("CustomDomainReconciler reconcile finished", "host", domain.Spec.Host)
	return ctrl.Result{}, nil
}

func (r *CustomDomainReconciler) constructIngressForCustomDomain(domain *customdomainv1.CustomDomain) (*networkingv1.Ingress, error) {
	pathType := networkingv1.PathTypePrefix
	ingress := networkingv1.Ingress{
		ObjectMeta: metav1.ObjectMeta{
			Name:      secretAndCertNameForCustomDomain(domain),
			Namespace: domain.Namespace,
			Annotations: map[string]string{
				"cert-manager.io/issuer":      r.IssuerName,
				"kubernetes.io/ingress.class": "kong",
				"konghq.com/plugins":          "dataapi-plugin-force-https,dataapi-plugin-digest-auth,dataapi-plugin-rate-limit,dataapi-plugin-custom-basic-auth,common-plugin-custom-cors",
				customDomainMd5Annotation:     r.getCustomDomainMd5(domain),
			},
		},
		Spec: networkingv1.IngressSpec{
			TLS: []networkingv1.IngressTLS{{
				Hosts:      []string{domain.Spec.Host},
				SecretName: secretAndCertNameForCustomDomain(domain),
			}},
			Rules: []networkingv1.IngressRule{{
				Host: domain.Spec.Host,
				IngressRuleValue: networkingv1.IngressRuleValue{
					HTTP: &networkingv1.HTTPIngressRuleValue{
						Paths: []networkingv1.HTTPIngressPath{{
							Path:     "/",
							PathType: &pathType,
							Backend: networkingv1.IngressBackend{
								Service: &networkingv1.IngressServiceBackend{
									Name: domain.Spec.ServiceName,
									Port: networkingv1.ServiceBackendPort{
										Number: domain.Spec.ServicePort,
									},
								},
							},
						}},
					},
				},
			}},
		},
	}
	if err := ctrl.SetControllerReference(domain, &ingress, r.Scheme); err != nil {
		return nil, err
	}
	return &ingress, nil
}

func (r *CustomDomainReconciler) getCustomDomainMd5(domain *customdomainv1.CustomDomain) string {
	return fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf(
		"%s:%s:%s:%d",
		domain.Spec.Host,
		domain.Spec.Cname,
		domain.Spec.ServiceName,
		domain.Spec.ServicePort,
	))))
}

func (r *CustomDomainReconciler) isIngressMatchWithCustomDomain(domain *customdomainv1.CustomDomain, ingress *networkingv1.Ingress) bool {
	value := ingress.Annotations[customDomainMd5Annotation]
	return value == r.getCustomDomainMd5(domain)
}

func (r *CustomDomainReconciler) constructDeploymentForCustomDomain(domain *customdomainv1.CustomDomain) (*customdomainv1.CustomDomainDeployment, error) {
	deploy := customdomainv1.CustomDomainDeployment{
		ObjectMeta: metav1.ObjectMeta{
			Name:      fmt.Sprintf("%s-%d", domain.Name, domain.Status.CertificateRevision),
			Namespace: domain.Namespace,
		},
		Spec: customdomainv1.CustomDomainDeploymentSpec{
			Host:                domain.Spec.Host,
			SecretName:          secretAndCertNameForCustomDomain(domain),
			CertificateRevision: domain.Status.CertificateRevision,
		},
	}
	if err := ctrl.SetControllerReference(domain, &deploy, r.Scheme); err != nil {
		return nil, err
	}
	return &deploy, nil
}

func (r *CustomDomainReconciler) findObjectsByCertificate(certificate client.Object) []reconcile.Request {
	domains := &customdomainv1.CustomDomainList{}
	listOps := &client.ListOptions{
		FieldSelector: fields.OneTermEqualSelector(certAndSecertKey, certificate.GetName()),
		Namespace:     certificate.GetNamespace(),
	}
	err := r.List(context.TODO(), domains, listOps)
	if err != nil {
		return []reconcile.Request{}
	}

	requests := make([]reconcile.Request, len(domains.Items))
	for i, item := range domains.Items {
		requests[i] = reconcile.Request{
			NamespacedName: types.NamespacedName{
				Name:      item.GetName(),
				Namespace: item.GetNamespace(),
			},
		}
	}
	return requests
}

func (r *CustomDomainReconciler) notifyCustomDomainExpired(ctx context.Context, domain *customdomainv1.CustomDomain) error {
	body, httpCode, err := r.CrossRegionClient.PostGlobal(
		ctx,
		"/inner/dataservice/custom-domains/notify",
		map[string]string{"custom_domain_name": domain.Spec.Host, "default_domain_name": domain.Spec.Cname},
	)
	if err != nil {
		return err
	}
	if httpCode != http.StatusOK {
		return fmt.Errorf("got unsuccessful result, httpCode: %d, body: %s", httpCode, body)
	}
	return nil
}

func secretAndCertNameForCustomDomain(domain *customdomainv1.CustomDomain) string {
	return fmt.Sprintf("%s-cdgen", domain.Name)
}

func maxDuration(a, b time.Duration) time.Duration {
	if a < b {
		return b
	}
	return a
}

func (r *CustomDomainReconciler) isDnsConfigured(ctx context.Context, host, target string) (bool, uint32, error) {
	log := log.FromContext(ctx)
	m := new(dns.Msg)
	m.SetQuestion(dns.Fqdn(host), dns.TypeCNAME)
	c := new(dns.Client)
	for _, server := range r.DnsConfig.Servers {
		in, rtt, err := c.Exchange(m, net.JoinHostPort(server, r.DnsConfig.Port))
		if err != nil {
			if in == nil || in.Answer == nil || len(in.Answer) == 0 {
				log.Error(err, "dns failed - in or in.answer is nil", "host", host, "target", target, "rtt", rtt, "server", server)
			} else {
				log.Error(err, "dns failed", "host", host, "target", target, "answers", in.Answer, "rtt", rtt, "server", server)
			}
			continue
		}

		log.Info("dns result", "host", host, "target", target, "answers", in.Answer, "rtt", rtt, "server", server)
		if len(in.Answer) == 0 || !strings.Contains(in.Answer[0].String(), target) {
			continue
		}
		return true, in.Answer[0].Header().Ttl, nil
	}
	return false, 0, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *CustomDomainReconciler) SetupWithManager(mgr ctrl.Manager) error {
	// Index ingress owned by CustomDomain
	if err := mgr.GetFieldIndexer().IndexField(context.Background(), &networkingv1.Ingress{}, resouceOwnerKey, func(rawObj client.Object) []string {
		// grab the ingress object, extract the owner...
		ingress := rawObj.(*networkingv1.Ingress)
		owner := metav1.GetControllerOf(ingress)
		if owner == nil {
			return nil
		}
		// ...make sure it's a CustomDomain...
		if owner.APIVersion != apiGVStr || owner.Kind != "CustomDomain" {
			return nil
		}
		// ...and if so, return it
		return []string{owner.Name}
	}); err != nil {
		return err
	}
	// Index CustomDomain with cert and secret name
	if err := mgr.GetFieldIndexer().IndexField(context.Background(), &customdomainv1.CustomDomain{}, certAndSecertKey, func(rawObj client.Object) []string {
		domain := rawObj.(*customdomainv1.CustomDomain)
		return []string{secretAndCertNameForCustomDomain(domain)}
	}); err != nil {
		return err
	}
	// Index CustomDomainDeployment owned by CustomDomain
	if err := mgr.GetFieldIndexer().IndexField(context.Background(), &customdomainv1.CustomDomainDeployment{}, resouceOwnerKey, func(rawObj client.Object) []string {
		// grab the ingress object, extract the owner...
		domain := rawObj.(*customdomainv1.CustomDomainDeployment)
		owner := metav1.GetControllerOf(domain)
		if owner == nil {
			return nil
		}
		// ...make sure it's a CustomDomain...
		if owner.APIVersion != apiGVStr || owner.Kind != "CustomDomain" {
			return nil
		}
		// ...and if so, return it
		return []string{owner.Name}
	}); err != nil {
		return err
	}

	return ctrl.NewControllerManagedBy(mgr).
		For(&customdomainv1.CustomDomain{}).
		Owns(&networkingv1.Ingress{}).
		Owns(&customdomainv1.CustomDomainDeployment{}).
		Watches(
			&source.Kind{Type: &certmanagerv1.Certificate{}},
			handler.EnqueueRequestsFromMapFunc(r.findObjectsByCertificate),
		).
		Complete(r)
}
