/*
 * Copyright (C) 2022 Appvia Ltd <info@appvia.io>
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package configuration

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	pcache "github.com/patrickmn/go-cache"
	log "github.com/sirupsen/logrus"
	batchv1 "k8s.io/api/batch/v1"
	v1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/resource"
	"k8s.io/client-go/informers"
	"k8s.io/client-go/kubernetes"
	cache "k8s.io/client-go/tools/cache"
	"k8s.io/client-go/tools/record"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/builder"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller"
	"sigs.k8s.io/controller-runtime/pkg/event"
	"sigs.k8s.io/controller-runtime/pkg/handler"
	"sigs.k8s.io/controller-runtime/pkg/manager"
	"sigs.k8s.io/controller-runtime/pkg/predicate"
	"sigs.k8s.io/controller-runtime/pkg/reconcile"
	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

	terraformv1alpha1 "github.com/appvia/terranetes-controller/pkg/apis/terraform/v1alpha1"
	"github.com/appvia/terranetes-controller/pkg/handlers/configurations"
	ksutils "github.com/appvia/terranetes-controller/pkg/utils/kubernetes"
	"github.com/appvia/terranetes-controller/pkg/utils/policies"
)

const controllerName = "configuration.terraform.appvia.io"

// Controller handles the reconciliation of the configuration resource
type Controller struct {
	// cc is the kubernetes client to the cluster
	cc client.Client
	// kc is a client kubernetes - this is required as the controller runtime client
	// does not support subresources, check https://github.com/kubernetes-sigs/controller-runtime/pull/1922
	kc kubernetes.Interface
	// cache is a local cache of resources to make lookups faster
	cache *pcache.Cache
	// recorder is the kubernetes event recorder
	recorder record.EventRecorder
	// ConfigurationThreshold is the max number of configurations we are willing to run at the same time
	ConfigurationThreshold float64
	// ControllerNamespace is the namespace where the runner is running
	ControllerNamespace string
	// BackendTemplate is the name of the secret in the controller namespace which holds a
	// template used to generate the state backend
	BackendTemplate string
	// BackoffLimit is the amount of times we are allowing a job to failed before deeming
	// it a failure
	BackoffLimit int
	// BinaryPath is the name of the binary to use to run the terraform commands
	BinaryPath string
	// EnableContextInjection enables the injection of the context into the terraform configuration
	// variables. This means we shall inject an number of default variables into the configuration
	// such as namespace, name and labels
	EnableContextInjection bool
	// EnableInfracosts enables the cost analytics via infracost
	EnableInfracosts bool
	// EnableTerraformVersions enables the use of the configuration's Terraform version
	EnableTerraformVersions bool
	// EnableWatchers indicates we should create watcher jobs in the user namespace
	EnableWatchers bool
	// EnableWebhooks indicates if we should register the webhooks
	EnableWebhooks bool
	// ExecutorImage is the image to use for the executor
	ExecutorImage string
	// ExecutorSecrets is a collection of secrets which should be added to the
	// executors job everytime - these are configured by the platform team on the
	// cli options
	ExecutorSecrets []string
	// DefaultExecutorMemoryRequest is the default memory request for the executor container
	DefaultExecutorMemoryRequest string
	// DefaultExecutorMemoryLimit is the default memory limit for the executor container
	DefaultExecutorMemoryLimit string
	// DefaultExecutorCPURequest is the default CPU request for the executor container
	DefaultExecutorCPURequest string
	// DefaultExecutorCPULimit is the default CPU limit for the executor container
	DefaultExecutorCPULimit string
	// InfracostsImage is the image to use for all infracost jobs
	InfracostsImage string
	// InfracostsSecretName is the name of the secret containing the api and token
	InfracostsSecretName string
	// NamespaceFilters is a list of namespaces we are responsible for; all other namespaces are ignored
	NamespaceFilters []string
	// ControllerJobLabels is a collection of labels to add to the job
	ControllerJobLabels map[string]string
	// JobTemplate is a custom override for the template to use
	JobTemplate string
	// PolicyImage is the image to use for all policy / checkov jobs
	PolicyImage string
	// TerraformImage is the image to use for all terraform jobs
	TerraformImage string
}

// HasBackendTemplate returns true if the configuration has a backend template
func (c *Controller) HasBackendTemplate() bool {
	return c.BackendTemplate != ""
}

// createNamespaceWatch is responsible for creating a watcher job in the user namespace
func (c *Controller) createNamespaceWatch(ctx context.Context) error {
	factory := informers.NewSharedInformerFactory(c.kc, time.Minute*5)
	informer := factory.Core().V1().Namespaces().Informer()
	stopCh := make(chan struct{})

	registration, err := informer.AddEventHandler(&cache.ResourceEventHandlerFuncs{
		UpdateFunc: func(_, obj interface{}) {
			o, ok := obj.(*v1.Namespace)
			if ok {
				c.cache.SetDefault(o.GetName(), o)
			}
		},
		AddFunc: func(obj interface{}) {
			if o, ok := obj.(*v1.Namespace); ok {
				c.cache.SetDefault(o.GetName(), o)
			}
		},
		DeleteFunc: func(obj interface{}) {
			if o, ok := obj.(*v1.Namespace); ok {
				c.cache.Delete(o.GetName())
			}
		},
	})
	if err != nil {
		return err
	}
	log.Info("starting the namespace informer")

	// @step: we need to start the informer
	go informer.Run(stopCh)

	if !cache.WaitForCacheSync(ctx.Done(), registration.HasSynced) {
		return fmt.Errorf("failed to sync the namespace informer")
	}

	log.Info("namespace informer is synced")

	return nil
}

// Add is called to setup the manager for the controller
func (c *Controller) Add(mgr manager.Manager) error {
	log.WithFields(log.Fields{
		"additional_secrets": len(c.ExecutorSecrets),
		"backend":            c.BackendTemplate,
		"enable_costs":       c.EnableInfracosts,
		"enable_watchers":    c.EnableWatchers,
		"filters":            strings.Join(c.NamespaceFilters, ","),
		"namespace":          c.ControllerNamespace,
		"policy_image":       c.PolicyImage,
		"terraform_image":    c.TerraformImage,
	}).Info("adding the configuration controller")

	// @step: ensure the resource limits are valid
	for _, c := range []string{
		c.DefaultExecutorCPULimit,
		c.DefaultExecutorCPURequest,
		c.DefaultExecutorMemoryLimit,
		c.DefaultExecutorMemoryRequest,
	} {
		if c == "" {
			continue
		}
		if _, err := resource.ParseQuantity(c); err != nil {
			return fmt.Errorf("invalid resource quantity: %q, error: %w", c, err)
		}
	}

	switch {
	case c.ControllerNamespace == "":
		return errors.New("job namespace is required")
	case c.TerraformImage == "":
		return errors.New("terraform image is required")
	case c.PolicyImage == "":
		return errors.New("policy image is required")
	case c.EnableInfracosts && c.InfracostsImage == "":
		return errors.New("infracost image is required")
	case c.EnableInfracosts && c.InfracostsSecretName == "":
		return errors.New("infracost secret is required")
	}

	c.cc = mgr.GetClient()
	c.cache = pcache.New(24*time.Hour, 10*time.Minute)
	c.cache.OnEvicted(func(key string, _ interface{}) {
		// ensure we have some logging for when a namespace is evicted
		log.WithField("namespace", key).Warn("evicted namespace from cache")
	})

	c.recorder = mgr.GetEventRecorderFor(controllerName)

	kc, err := kubernetes.NewForConfig(mgr.GetConfig())
	if err != nil {
		return err
	}
	c.kc = kc

	// @step: create the namespace watcher
	if err := c.createNamespaceWatch(context.Background()); err != nil {
		log.WithError(err).Error("failed to create the namespace watcher")

		return err
	}

	if c.EnableWebhooks {
		mgr.GetWebhookServer().Register(
			fmt.Sprintf("/validate/%s/configurations", terraformv1alpha1.GroupName),
			admission.WithCustomValidator(mgr.GetScheme(), &terraformv1alpha1.Configuration{}, configurations.NewValidator(c.cc, c.EnableTerraformVersions)),
		)
		mgr.GetWebhookServer().Register(
			fmt.Sprintf("/mutate/%s/configurations", terraformv1alpha1.GroupName),
			admission.WithCustomDefaulter(mgr.GetScheme(), &terraformv1alpha1.Configuration{}, configurations.NewMutator(c.cc)),
		)
	}

	return ctrl.NewControllerManagedBy(mgr).
		For(&terraformv1alpha1.Configuration{}).
		Named(controllerName).
		WithOptions(controller.Options{MaxConcurrentReconciles: 10}).
		// @note: we will avoid reconciliation on any resource where the annotation is set
		WithEventFilter(predicate.Or(
			&predicate.AnnotationChangedPredicate{},
			&predicate.GenerationChangedPredicate{},
		)).
		Watches(
			&batchv1.Job{},
			// allows us to requeue the resource when the job has updated
			handler.EnqueueRequestsFromMapFunc(func(_ context.Context, a client.Object) []reconcile.Request {
				return []ctrl.Request{
					{NamespacedName: client.ObjectKey{
						Namespace: a.GetNamespace(),
						Name:      a.GetLabels()["job-name"],
					}},
				}
			}),
			// we only care about jobs in our namespace
			builder.WithPredicates(predicate.And(
				predicate.Funcs{
					GenericFunc: func(e event.GenericEvent) bool {
						return e.Object.GetNamespace() == c.ControllerNamespace
					},
					CreateFunc: func(e event.CreateEvent) bool {
						return e.Object.GetNamespace() == c.ControllerNamespace
					},
					UpdateFunc: func(e event.UpdateEvent) bool {
						return e.ObjectNew.GetNamespace() == c.ControllerNamespace
					},
					DeleteFunc: func(e event.DeleteEvent) bool {
						return false
					},
				},
				&predicate.ResourceVersionChangedPredicate{},
			))).
		Complete(c)
}

// findMatchingPolicies is used to find a matching policy for the configuration. Note, ONLY one policy
// can be returned - we weight multiple policy least to most specific - i.e. no selector (i.e match all, weight=1),
// namespace labels=10, resource labels=20 per label. If multiple policies equal the same weight we throw
// an error.
func (c *Controller) findMatchingPolicy(
	ctx context.Context,
	configuration *terraformv1alpha1.Configuration,
	list *terraformv1alpha1.PolicyList,
) (*terraformv1alpha1.PolicyConstraint, error) {
	if len(list.Items) == 0 {
		return nil, nil
	}

	// @step: check the cache for the result
	entry, found := c.cache.Get(configuration.Namespace)
	if found {
		return policies.FindMatchingPolicy(ctx, configuration, entry.(client.Object), list)
	}

	namespace := &v1.Namespace{}
	namespace.Name = configuration.Namespace

	if found, err := ksutils.GetIfExists(ctx, c.cc, namespace); err != nil {
		return nil, err
	} else if !found {
		return nil, fmt.Errorf("namespace: %q was not found in the cache or api", configuration.Namespace)
	}

	return policies.FindMatchingPolicy(ctx, configuration, namespace, list)
}
