// Command capacity-planner is the entry point for the Dataplane
// Capacity Planner.
package main

import (
	"crypto/tls"
	"flag"
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/go-logr/logr"
	"go.uber.org/zap/zapcore"
	corev1 "k8s.io/api/core/v1"
	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
	"k8s.io/apimachinery/pkg/runtime"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	metricsclient "k8s.io/metrics/pkg/client/clientset/versioned"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/healthz"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"
	runtimemetricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"

	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/api/v1alpha1"
	appconfig "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/config"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/controller"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/executor"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/merger"
	metricsdiscovery "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/prom/discovery"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/prom/httpquery"
	querymetrics "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/prom/query"
	metricrules "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/prom/rules"
	metricsscraper "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/prom/scraper"
	metricsserverscraper "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/prom/scraper/metricsserver"
	podmetricsscraper "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/prom/scraper/podmetrics"
	podwatcherscraper "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/prom/scraper/podwatcher"
	metricsstore "github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/prom/store"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/recorder"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/tier"
	"github.com/tidbcloud/cluster-service-ng/internal/capacity-planner/pkg/version"
)

var (
	scheme   = runtime.NewScheme()
	setupLog = ctrl.Log.WithName("setup")
)

type realClock struct{}

func (realClock) Now() time.Time {
	return time.Now()
}

func init() {
	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
	utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
	utilruntime.Must(v1alpha1.AddToScheme(scheme))
}

func main() {
	var runtimeConfigPath string
	var tierTemplatesConfig string
	flag.StringVar(&runtimeConfigPath, "config", "",
		"Path to the capacity-planner runtime YAML config. An omitted path uses safe standalone defaults.")
	flag.StringVar(&tierTemplatesConfig, "tier-templates-config", "",
		"Path to the tier templates YAML config. AUTO and DRY_RUN Autoscalers require loaded templates.")
	flag.Parse()

	runtimeConfig := appconfig.Default()
	if runtimeConfigPath != "" {
		var err error
		runtimeConfig, err = appconfig.LoadFile(runtimeConfigPath)
		if err != nil {
			_, _ = fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		}
	}
	ctrl.SetLogger(configuredLogger(&runtimeConfig.Logging))
	setupLog.Info(version.Info())
	setupLog.Info("loaded runtime config", "path", runtimeConfigPath,
		"scrapeInterval", runtimeConfig.Scrapers.Interval.Duration,
		"recordingRules", len(runtimeConfig.Rules.Records))

	var tierTemplates *v1alpha1.TierTemplates
	if tierTemplatesConfig != "" {
		var err error
		tierTemplates, err = tier.LoadTemplatesFile(tierTemplatesConfig)
		if err != nil {
			setupLog.Error(err, "unable to load tier templates config")
			os.Exit(1)
		}
		setupLog.Info("loaded tier templates config", "path", tierTemplatesConfig, "templates", len(tierTemplates.Templates))
	}
	// TLS options are empty by default; secure serving (mTLS + authn/authz)
	// must be configured explicitly when the metrics/webhook endpoints are enabled.
	var tlsOpts []func(*tls.Config)
	cfg := ctrl.GetConfigOrDie()
	mgr, err := ctrl.NewManager(cfg, ctrl.Options{
		Scheme:                 scheme,
		Metrics:                runtimemetricsserver.Options{BindAddress: runtimeConfig.Metrics.BindAddress, TLSOpts: tlsOpts},
		HealthProbeBindAddress: runtimeConfig.Server.Health.BindAddress,
		LeaderElection:         runtimeConfig.Controller.LeaderElection,
		LeaderElectionID:       "capacity-planner.tidbcloud.com",
	})
	if err != nil {
		setupLog.Error(err, "unable to start manager")
		os.Exit(1)
	}
	// PodSpec.nodeSelector is atomic in the TiDB Group CRDs, but changing the
	// node size must preserve orthogonal selectors such as the node-pool
	// Kubernetes version. Override that path only; every unspecified path keeps
	// the merge behavior declared by the CRD installed in this dataplane.
	targetMerger, err := merger.New(
		merger.NewCRDSchemaResolver(mgr.GetAPIReader(), mgr.GetRESTMapper()),
		merger.Rule{
			Group:    "core.pingcap.com",
			Path:     "/spec/template/spec/overlay/pod/spec/nodeSelector",
			Strategy: merger.StrategyMergeMap,
		},
	)
	if err != nil {
		setupLog.Error(err, "unable to create target merger")
		os.Exit(1)
	}

	metricsClient, err := metricsclient.NewForConfig(cfg)
	if err != nil {
		setupLog.Error(err, "unable to create metrics client")
		os.Exit(1)
	}
	metricsDB, err := metricsstore.Open(&metricsstore.Config{
		Path:      runtimeConfig.Storage.Path,
		Retention: runtimeConfig.Storage.Retention.Duration,
	})
	if err != nil {
		setupLog.Error(err, "unable to open embedded metrics TSDB")
		os.Exit(1)
	}
	defer func() {
		if err := metricsDB.Close(); err != nil {
			setupLog.Error(err, "unable to close embedded metrics TSDB")
		}
	}()
	targetDiscovery, err := metricsdiscovery.New(&metricsdiscovery.Config{Informers: mgr.GetCache()})
	if err != nil {
		setupLog.Error(err, "unable to create metrics target discovery")
		os.Exit(1)
	}
	if err := mgr.Add(targetDiscovery); err != nil {
		setupLog.Error(err, "unable to register metrics target discovery")
		os.Exit(1)
	}

	metricsServerScraper, err := metricsserverscraper.New(&metricsserverscraper.Config{
		MetricsClient: metricsClient,
		Discovery:     targetDiscovery,
		Reader:        mgr.GetCache(),
		TargetKind:    metricsdiscovery.AllKinds,
		Appendable:    metricsDB,
		Clock:         realClock{},
		Resources:     configuredResources(runtimeConfig.Scrapers.MetricsServer.Resources),
		MaxAge:        runtimeConfig.Scrapers.MetricsServer.MaxAge.Duration,
	})
	if err != nil {
		setupLog.Error(err, "unable to create metrics-server scraper")
		os.Exit(1)
	}
	podStatusScraper, err := podwatcherscraper.New(&podwatcherscraper.Config{
		Discovery:  targetDiscovery,
		Reader:     mgr.GetCache(),
		TargetKind: metricsdiscovery.AllKinds,
		Appendable: metricsDB,
		Clock:      realClock{},
		Metrics:    configuredPodWatcherMetrics(runtimeConfig.Scrapers.PodWatcher.Metrics),
	})
	if err != nil {
		setupLog.Error(err, "unable to create Pod status scraper")
		os.Exit(1)
	}
	podMetricsScraper, err := podmetricsscraper.New(&podmetricsscraper.Config{
		HTTPClient:  &http.Client{},
		Discovery:   targetDiscovery,
		Reader:      mgr.GetCache(),
		TargetKind:  metricsdiscovery.AllKinds,
		Appendable:  metricsDB,
		Clock:       realClock{},
		Metrics:     runtimeConfig.Scrapers.PodMetrics.Metrics,
		Timeout:     runtimeConfig.Scrapers.PodMetrics.Timeout.Duration,
		MaxBodySize: int64(runtimeConfig.Scrapers.PodMetrics.MaxBodySize),
		Concurrency: runtimeConfig.Scrapers.PodMetrics.Concurrency,
	})
	if err != nil {
		setupLog.Error(err, "unable to create direct Pod metrics scraper")
		os.Exit(1)
	}
	scraperManager, err := metricsscraper.NewManager(&metricsscraper.ManagerConfig{
		Interval: runtimeConfig.Scrapers.Interval.Duration,
	}, metricsServerScraper, podStatusScraper, podMetricsScraper)
	if err != nil {
		setupLog.Error(err, "unable to create scraper manager")
		os.Exit(1)
	}
	if err := mgr.Add(scraperManager); err != nil {
		setupLog.Error(err, "unable to register scraper manager")
		os.Exit(1)
	}
	if len(runtimeConfig.Rules.Records) > 0 {
		ruleEvaluator, err := metricrules.New(&metricrules.Config{
			Interval:      runtimeConfig.Rules.Interval.Duration,
			QueryTimeout:  runtimeConfig.Rules.Engine.Timeout.Duration,
			MaxSamples:    runtimeConfig.Rules.Engine.MaxSamples,
			LookbackDelta: runtimeConfig.Rules.Engine.LookbackDelta.Duration,
			Records:       configuredRecordingRules(runtimeConfig.Rules.Records),
			Queryable:     metricsDB,
			Appendable:    metricsDB,
		})
		if err != nil {
			setupLog.Error(err, "unable to create recording-rule evaluator")
			os.Exit(1)
		}
		if err := mgr.Add(ruleEvaluator); err != nil {
			setupLog.Error(err, "unable to register recording-rule evaluator")
			os.Exit(1)
		}
	}
	metricsProvider, err := querymetrics.New(&querymetrics.Config{
		Queryable: metricsDB,
		Clock:     realClock{},
	})
	if err != nil {
		setupLog.Error(err, "unable to create PromQL metrics provider")
		os.Exit(1)
	}
	if runtimeConfig.Query.BindAddress != "0" {
		queryServer, err := httpquery.New(&httpquery.Config{
			Address:      runtimeConfig.Query.BindAddress,
			Queryable:    metricsDB,
			QueryTimeout: runtimeConfig.Query.Timeout.Duration,
			MaxSamples:   runtimeConfig.Query.MaxSamples,
			MaxPoints:    runtimeConfig.Query.MaxPoints,
		})
		if err != nil {
			setupLog.Error(err, "unable to create HTTP query server")
			os.Exit(1)
		}
		if err := mgr.Add(queryServer); err != nil {
			setupLog.Error(err, "unable to register HTTP query server")
			os.Exit(1)
		}
	}

	var decisionRecorder recorder.Recorder
	if runtimeConfig.Records.Dir != "" {
		writer, err := recorder.NewJSONLRecorder(runtimeConfig.Records.Dir)
		if err != nil {
			setupLog.Error(err, "unable to create decision recorder", "dir", runtimeConfig.Records.Dir)
			os.Exit(1)
		}
		decisionRecorder = writer
		setupLog.Info("recording decisions", "dir", runtimeConfig.Records.Dir)
	}

	if err := (&controller.Reconciler{
		Client:          mgr.GetClient(),
		Scheme:          mgr.GetScheme(),
		TierTemplates:   tierTemplates,
		MetricsProvider: metricsProvider,
		Executor: executor.NewKubernetesExecutor(
			mgr.GetAPIReader(),
			mgr.GetClient(),
			targetMerger,
		),
		Recorder:   decisionRecorder,
		SyncPeriod: runtimeConfig.Controller.SyncPeriod.Duration,
	}).SetupWithManager(mgr); err != nil {
		setupLog.Error(err, "unable to create controller", "controller", "Autoscaler")
		os.Exit(1)
	}

	if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
		setupLog.Error(err, "unable to set up health check")
		os.Exit(1)
	}
	if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
		setupLog.Error(err, "unable to set up ready check")
		os.Exit(1)
	}

	setupLog.Info("starting manager")
	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		setupLog.Error(err, "problem running manager")
		os.Exit(1)
	}
}

func configuredResources(configured map[string]string) map[string]corev1.ResourceName {
	resources := make(map[string]corev1.ResourceName, len(configured))
	for metricName, resourceName := range configured {
		resources[metricName] = corev1.ResourceName(resourceName)
	}
	return resources
}

func configuredPodWatcherMetrics(configured []appconfig.PodWatcherMetric) []podwatcherscraper.MetricConfig {
	metrics := make([]podwatcherscraper.MetricConfig, len(configured))
	for i := range configured {
		metrics[i] = podwatcherscraper.MetricConfig{
			Name:       configured[i].Name,
			JSONPath:   configured[i].JSONPath,
			MatchValue: configured[i].MatchValue,
		}
	}
	return metrics
}

func configuredRecordingRules(configured []appconfig.RecordingRule) []metricrules.Record {
	records := make([]metricrules.Record, len(configured))
	for i := range configured {
		records[i] = metricrules.Record{
			Record: configured[i].Record,
			Expr:   configured[i].Expr,
			Labels: configured[i].Labels,
		}
	}
	return records
}

func configuredLogger(configured *appconfig.LoggingConfig) logr.Logger {
	var level zapcore.Level
	_ = level.UnmarshalText([]byte(configured.Level))
	var stacktraceLevel zapcore.Level
	_ = stacktraceLevel.UnmarshalText([]byte(configured.StacktraceLevel))
	var timeEncoder zapcore.TimeEncoder
	_ = timeEncoder.UnmarshalText([]byte(configured.TimeEncoding))
	opts := &zap.Options{
		Development:     configured.Development,
		Level:           level,
		StacktraceLevel: stacktraceLevel,
		TimeEncoder:     timeEncoder,
	}
	encoderOption := func(encoder *zapcore.EncoderConfig) {
		encoder.EncodeTime = timeEncoder
	}
	zapOptions := []zap.Opts{zap.UseFlagOptions(opts)}
	if configured.Encoder == "json" {
		zapOptions = append(zapOptions, zap.JSONEncoder(encoderOption))
	} else {
		zapOptions = append(zapOptions, zap.ConsoleEncoder(encoderOption))
	}
	return zap.New(zapOptions...)
}
