// Licensed to Apache Software Foundation (ASF) under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Apache Software Foundation (ASF) licenses this file to you 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 cmd is an internal package defining cli commands for fodc.
package cmd

import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"runtime"
	"strings"
	"syscall"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/rs/zerolog"
	"github.com/spf13/cobra"

	"github.com/apache/skywalking-banyandb/fodc/agent/internal/cluster"
	"github.com/apache/skywalking-banyandb/fodc/agent/internal/crashcollector"
	"github.com/apache/skywalking-banyandb/fodc/agent/internal/exporter"
	"github.com/apache/skywalking-banyandb/fodc/agent/internal/flightrecorder"
	"github.com/apache/skywalking-banyandb/fodc/agent/internal/ktm"
	"github.com/apache/skywalking-banyandb/fodc/agent/internal/lifecycle"
	fodcmetrics "github.com/apache/skywalking-banyandb/fodc/agent/internal/metrics"
	"github.com/apache/skywalking-banyandb/fodc/agent/internal/pressureprofiler"
	"github.com/apache/skywalking-banyandb/fodc/agent/internal/proxy"
	"github.com/apache/skywalking-banyandb/fodc/agent/internal/server"
	"github.com/apache/skywalking-banyandb/fodc/agent/internal/watchdog"
	"github.com/apache/skywalking-banyandb/pkg/cgroups"
	"github.com/apache/skywalking-banyandb/pkg/logger"
	"github.com/apache/skywalking-banyandb/pkg/meter"
	"github.com/apache/skywalking-banyandb/pkg/meter/prom"
	"github.com/apache/skywalking-banyandb/pkg/panicdiag"
	"github.com/apache/skywalking-banyandb/pkg/version"
)

const (
	defaultMetricsPollInterval            = 10 * time.Second
	defaultPollMetricsPorts               = "2121"
	defaultMaxMetricsMemoryUsagePercent   = 10
	defaultPrometheusListenAddr           = ":9090"
	defaultDiagnosisListenAddr            = ":9091"
	defaultProxyAddr                      = "localhost:17900"
	defaultHeartbeatInterval              = 10 * time.Second
	defaultReconnectInterval              = 5 * time.Second
	defaultClusterStatePollInterval       = 30 * time.Second
	defaultMaxDiagnosisMemoryUsagePercent = 5

	defaultPressureTriggerPercent = 75
	defaultPressurePprofPort      = "6060"
	defaultPressureCooldown       = 5 * time.Minute
	defaultPressureDir            = "/tmp/pressure-profiles"
	defaultPressureMaxArtifacts   = 16
	defaultPressureMaxDiskBytes   = 512 * 1024 * 1024
)

var (
	metricsPollInterval                time.Duration
	pollMetricsPorts                   []string
	maxMetricsMemoryUsagePercent       int
	prometheusListenAddr               string
	diagnosisListenAddr                string
	ktmEnabled                         bool
	ktmInterval                        time.Duration
	proxyAddr                          string
	podName                            string
	containerNames                     []string
	heartbeatInterval                  time.Duration
	reconnectInterval                  time.Duration
	clusterStatePorts                  []string
	clusterStatePollInterval           time.Duration
	crashSourceDir                     string
	crashOutputCfg                     = panicdiag.NewCrashOutputConfig()
	maxFODCDiagnosisMemoryUsagePercent int
	lifecyclePort                      int
	schemaRegistryPort                 int
	lifecycleReportDir                 string
	lifecycleCacheTTL                  time.Duration
	pressureProfilerEnabled            bool
	pressureTriggerPercent             int
	pressurePprofPort                  string
	pressureCooldown                   time.Duration
	pressureDir                        string
	pressureMaxArtifacts               int
	pressureMaxDiskBytes               int64
	rootCmd                            = &cobra.Command{
		Use:     "fodc",
		Short:   "First Occurrence Data Collection (FODC) agent",
		Version: version.Build(),
		Long: `FODC (First Occurrence Data Collection) is an observability and diagnostics subsystem for BanyanDB.
It continuously collects runtime parameters, performance indicators, node states, and configuration data.`,
		RunE: runFODC,
	}
)

// Execute executes the root command.
func Execute() error {
	return rootCmd.Execute()
}

func init() {
	rootCmd.Flags().DurationVar(&metricsPollInterval, "poll-metrics-interval", defaultMetricsPollInterval,
		"Interval at which the Watchdog polls metrics from the BanyanDB container")
	rootCmd.Flags().StringSliceVar(&pollMetricsPorts, "poll-metrics-ports", []string{defaultPollMetricsPorts},
		"Ports of the BanyanDB metrics endpoints to poll from (can be specified multiple times or comma-separated)")
	rootCmd.Flags().IntVar(&maxMetricsMemoryUsagePercent, "max-metrics-memory-usage-percentage",
		defaultMaxMetricsMemoryUsagePercent,
		"Maximum percentage of available memory (based on cgroup memory limit) that can be used for storing metrics in the Flight Recorder. Valid range: 0-100.")
	rootCmd.Flags().StringVar(&prometheusListenAddr, "prometheus-listen-addr", defaultPrometheusListenAddr,
		"Address on which to expose Prometheus metrics endpoint (e.g., :9090)")
	rootCmd.Flags().StringVar(&diagnosisListenAddr, "diagnosis-listen-addr", defaultDiagnosisListenAddr,
		"Address on which to expose diagnosis collection endpoint (e.g., :9091)")
	rootCmd.Flags().StringVar(&proxyAddr, "proxy-addr", defaultProxyAddr,
		"FODC Proxy gRPC address")
	rootCmd.Flags().StringVar(&podName, "pod-name", "",
		"Name of the Kubernetes pod. Used as part of AgentIdentity for agent identification.")
	rootCmd.Flags().StringSliceVar(&containerNames, "container-names", []string{},
		"Names of the containers corresponding to each poll-metrics-port. Must have one-to-one correspondence with poll-metrics-ports.")
	rootCmd.Flags().DurationVar(&heartbeatInterval, "heartbeat-interval", defaultHeartbeatInterval,
		"Interval for sending heartbeats to Proxy. Note: The Proxy may override this value in RegisterAgentResponse.")
	rootCmd.Flags().DurationVar(&reconnectInterval, "reconnect-interval", defaultReconnectInterval,
		"Interval for reconnection attempts when connection to Proxy is lost")
	rootCmd.Flags().BoolVar(&ktmEnabled, "ktm-enabled", false, "Enable Kernel Trace Module (eBPF)")
	rootCmd.Flags().DurationVar(&ktmInterval, "ktm-interval", 10*time.Second, "Interval for KTM metrics collection")
	rootCmd.Flags().StringSliceVar(&clusterStatePorts, "cluster-state-ports", []string{},
		"Ports of the BanyanDB node's gRPC endpoints to poll cluster state from. If empty, cluster state polling is disabled.")
	rootCmd.Flags().DurationVar(&clusterStatePollInterval, "cluster-state-poll-interval", defaultClusterStatePollInterval,
		"Interval at which to poll cluster state from the BanyanDB nodes")
	rootCmd.Flags().StringVar(&crashSourceDir, "crash-source-dir", "",
		"Shared volume directory to watch for BanyanDB crash artifacts via filesystem notifications")
	crashOutputCfg.RegisterFlags(rootCmd.Flags())
	rootCmd.Flags().IntVar(&maxFODCDiagnosisMemoryUsagePercent, "max-fodc-diagnosis-memory-usage-percentage",
		defaultMaxDiagnosisMemoryUsagePercent,
		"Maximum percentage of available memory (based on cgroup memory limit) that can be used for storing diagnosis data in the FODC ring buffer. Valid range: 0-100.")
	rootCmd.Flags().IntVar(&lifecyclePort, "lifecycle-port", 18912,
		"gRPC port for lifecycle InspectAll service. Set to 0 to disable lifecycle collection")
	rootCmd.Flags().IntVar(&schemaRegistryPort, "schema-registry-port", 17912,
		"Local liaison client gRPC port serving the authoritative registry, read only when the proxy selects this agent. Set to 0 to disable")
	rootCmd.Flags().StringVar(&lifecycleReportDir, "lifecycle-report-dir", lifecycle.DefaultReportDir,
		"Directory where lifecycle sidecar writes report files")
	rootCmd.Flags().DurationVar(&lifecycleCacheTTL, "lifecycle-cache-ttl", 10*time.Minute,
		"TTL for cached lifecycle data. After expiry, the next collection call refreshes the cache")
	rootCmd.Flags().BoolVar(&pressureProfilerEnabled, "pressure-profiler-enabled", true,
		"Enable automatic heap+goroutine pprof capture when the monitored container's RSS approaches its cgroup memory limit")
	rootCmd.Flags().IntVar(&pressureTriggerPercent, "pressure-profiler-trigger-percent", defaultPressureTriggerPercent,
		"Capture when RSS / cgroup_max reaches this percentage (usage = process_resident_memory_bytes)")
	rootCmd.Flags().StringVar(&pressurePprofPort, "pressure-profiler-pprof-port", defaultPressurePprofPort,
		"Port of the monitored container's pprof endpoint to capture from")
	rootCmd.Flags().DurationVar(&pressureCooldown, "pressure-profiler-cooldown", defaultPressureCooldown,
		"Minimum interval between two pprof captures")
	rootCmd.Flags().StringVar(&pressureDir, "pressure-profiler-dir", defaultPressureDir,
		"Directory (absolute, on a writable volume) where captured profiles are stored")
	rootCmd.Flags().IntVar(&pressureMaxArtifacts, "pressure-profiler-max-artifacts", defaultPressureMaxArtifacts,
		"Maximum number of capture events to retain; the lowest-RSS events are evicted first")
	rootCmd.Flags().Int64Var(&pressureMaxDiskBytes, "pressure-profiler-max-disk-bytes", defaultPressureMaxDiskBytes,
		"Maximum total on-disk bytes for retained capture events (0 disables the disk bound)")
}

func calculateCapacity(log *logger.Logger) int64 {
	memoryLimit, memErr := cgroups.MemoryLimit()
	if memErr != nil {
		if runtime.GOOS != "linux" || strings.Contains(memErr.Error(), "mountinfo") {
			log.Debug().Err(memErr).Msg("Cgroup memory limit not available (expected on non-Linux systems), using default capacity")
		} else {
			log.Warn().Err(memErr).Msg("Failed to get cgroup memory limit, using default capacity")
		}
		memoryLimit = 1024 * 1024 * 1024
	}
	var capacitySize int64
	if memoryLimit > 0 {
		capacitySize = (memoryLimit * int64(maxMetricsMemoryUsagePercent)) / 100
	} else {
		capacitySize = 100 * 1024 * 1024
		log.Info().Msg("Memory limit is unlimited or invalid, using default capacity of 100MB")
	}
	log.Info().
		Int64("memory-limit-bytes", memoryLimit).
		Int("memory-usage-percent", maxMetricsMemoryUsagePercent).
		Int64("capacity-size-bytes", capacitySize).
		Msg("Flight Recorder capacity configured")
	return capacitySize
}

func calculateDiagnosisCapacity(log *logger.Logger) int64 {
	memoryLimit, memErr := cgroups.MemoryLimit()
	if memErr != nil {
		if runtime.GOOS != "linux" || strings.Contains(memErr.Error(), "mountinfo") {
			log.Debug().Err(memErr).Msg("Cgroup memory limit not available (expected on non-Linux systems), using default diagnosis capacity")
		} else {
			log.Warn().Err(memErr).Msg("Failed to get cgroup memory limit for diagnosis storage, using default capacity")
		}
		memoryLimit = 1024 * 1024 * 1024
	}
	var capacitySize int64
	if memoryLimit > 0 {
		capacitySize = (memoryLimit * int64(maxFODCDiagnosisMemoryUsagePercent)) / 100
	} else {
		capacitySize = 50 * 1024 * 1024
		log.Info().Msg("Memory limit is unlimited or invalid, using default diagnosis capacity of 50MB")
	}
	log.Info().
		Int64("memory-limit-bytes", memoryLimit).
		Int("memory-usage-percent", maxFODCDiagnosisMemoryUsagePercent).
		Int64("capacity-size-bytes", capacitySize).
		Msg("Diagnosis collector capacity configured")
	return capacitySize
}

func initializeKTM(ctx context.Context, log *logger.Logger, fr *flightrecorder.FlightRecorder) func() {
	if !ktmEnabled {
		return nil
	}

	if runtime.GOOS != "linux" {
		log.Info().Str("os", runtime.GOOS).Msg("KTM is only supported on Linux, skipping initialization")
		if updateErr := fr.Update([]fodcmetrics.RawMetric{
			{
				Name:  "ktm_status",
				Value: 0,
				Desc:  "KTM status: 0=Disabled, 1=Degraded (comm-only), 2=Full (cgroup+comm)",
			},
		}); updateErr != nil {
			log.Warn().Err(updateErr).Msg("Failed to write ktm_status metric")
		}
		return nil
	}

	startStop, startErr := startKTM(ctx, *log.Logger, fr)
	if startErr != nil {
		log.Warn().Err(startErr).Msg("KTM startup failed, continuing without kernel tracing")
		if updateErr := fr.Update([]fodcmetrics.RawMetric{
			{
				Name:  "ktm_status",
				Value: 0,
				Desc:  "KTM status: 0=Disabled, 1=Degraded (comm-only), 2=Full (cgroup+comm)",
			},
		}); updateErr != nil {
			log.Warn().Err(updateErr).Msg("Failed to write ktm_status metric")
		}
		return nil
	}
	return startStop
}

// runFODC is the main function for the FODC agent.
func runFODC(_ *cobra.Command, _ []string) error {
	if installErr := crashOutputCfg.InstallGlobalCrashOutput(); installErr != nil {
		return fmt.Errorf("failed to install crash output: %w", installErr)
	}
	if initErr := logger.Init(logger.Logging{Env: "prod", Level: "info"}); initErr != nil {
		return fmt.Errorf("failed to initialize logger: %w", initErr)
	}
	log := logger.GetLogger("fodc")

	if validateErr := validateFlags(); validateErr != nil {
		return validateErr
	}
	metricsEndpoints := generateMetricsEndpoints(pollMetricsPorts)
	log.Info().Strs("endpoints", metricsEndpoints).Dur("interval", metricsPollInterval).Msg("Starting FODC agent")

	capacitySize := calculateCapacity(log)
	fr := flightrecorder.NewFlightRecorder(capacitySize)
	diagnosisCapacitySize := calculateDiagnosisCapacity(log)

	reg := prometheus.NewRegistry()
	panicScope := meter.NewHierarchicalScope("fodc_agent", "_")
	panicdiag.SetDefaultPanicCounter(prom.NewProvider(panicScope, reg).Counter("panic_total", "component"))
	panicStore := crashcollector.NewInProcessPanicStore(0)
	panicdiag.SetDefaultReporter(panicStore.Reporter())

	metricsServer, serverErr := server.NewServer(server.Config{
		ListenAddr:          prometheusListenAddr,
		DiagnosisListenAddr: diagnosisListenAddr,
		ReadHeaderTimeout:   3 * time.Second,
		ShutdownTimeout:     5 * time.Second,
	})
	if serverErr != nil {
		return fmt.Errorf("failed to create metrics server: %w", serverErr)
	}

	ctx := context.Background()
	clusterCollector, clusterErr := cluster.StartCollector(ctx, log, clusterStatePorts, clusterStatePollInterval, podName)
	if clusterErr != nil {
		_ = metricsServer.Stop(ctx)
		return clusterErr
	}

	var nodeRole string
	var nodeLabels map[string]string
	if clusterCollector != nil {
		nodeRole, nodeLabels = clusterCollector.ResolveNodeRole(ctx)
		log.Info().Str("node_role", nodeRole).Int("labels_count", len(nodeLabels)).Msg("Node info fetched")
	}

	wd := watchdog.NewWatchdogWithConfig(fr, metricsEndpoints, metricsPollInterval, nodeRole, podName, containerNames)
	if clusterCollector != nil {
		// Stamp the node role and labels live from the collector, which keeps resolving after
		// startup, instead of the one-shot snapshot captured above.
		wd.SetNodeInfoProvider(clusterCollector.GetNodeInfo)
	}

	var profileSource proxy.ProfileSource
	if pressureProfilerEnabled {
		roleProvider := func() string { return nodeRole }
		if clusterCollector != nil {
			roleProvider = func() string { role, _ := clusterCollector.GetNodeInfo(); return role }
		}
		pp, ppErr := pressureprofiler.New(pressureprofiler.Config{
			Dir:            pressureDir,
			PprofPort:      pressurePprofPort,
			PodName:        podName,
			RoleProvider:   roleProvider,
			Cooldown:       pressureCooldown,
			MaxDiskBytes:   pressureMaxDiskBytes,
			TriggerPercent: pressureTriggerPercent,
			MaxArtifacts:   pressureMaxArtifacts,
		}, fr, prom.NewProvider(panicScope, reg), log)
		if ppErr != nil {
			cluster.StopCollector(clusterCollector)
			_ = metricsServer.Stop(ctx)
			return fmt.Errorf("failed to start pressure profiler: %w", ppErr)
		}
		// Drive evaluation off the watchdog's poll completion (same cadence, freshest values).
		wd.AddPostPollHook(pp.OnPollComplete)
		profileSource = pp
	}

	stopKTM := initializeKTM(ctx, log, fr)

	if preRunErr := wd.PreRun(ctx); preRunErr != nil {
		if stopKTM != nil {
			stopKTM()
		}
		cluster.StopCollector(clusterCollector)
		_ = metricsServer.Stop(ctx)
		return fmt.Errorf("failed to initialize watchdog: %w", preRunErr)
	}
	stopCh := wd.Serve()
	collectorCfg := crashcollector.Config{
		CapacitySizeBytes: diagnosisCapacitySize,
	}
	var stopDirWatcher func()
	providers := []crashcollector.CollectionLister{panicStore}
	if crashSourceDir != "" {
		dirWatcher := crashcollector.NewDirectoryWatcher(log, crashSourceDir, collectorCfg)
		stopDirWatcher = dirWatcher.Start(ctx)
		providers = append(providers, dirWatcher)
	}
	multi := crashcollector.NewMultiCollectionProvider(providers...)
	var collectionProvider server.CollectionProvider = multi
	var crashCollectionLister crashcollector.CollectionLister = multi
	serverErrCh, startErr := metricsServer.Start(reg, exporter.NewDatasourceCollector(fr), collectionProvider)
	if startErr != nil {
		if stopDirWatcher != nil {
			stopDirWatcher()
		}
		return fmt.Errorf("failed to start metrics server: %w", startErr)
	}
	proxyClient := startProxyClient(ctx, log, fr, nodeRole, nodeLabels, clusterCollector, crashCollectionLister, profileSource)

	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
	select {
	case <-sigCh:
		log.Info().Msg("Received shutdown signal")
	case <-stopCh:
		log.Info().Msg("Watchdog stopped")
	case err := <-serverErrCh:
		log.Error().Err(err).Msg("Metrics server error")
	}

	wd.GracefulStop()
	if stopDirWatcher != nil {
		stopDirWatcher()
	}

	if stopKTM != nil {
		stopKTM()
	}

	if proxyClient != nil {
		if disconnectErr := proxyClient.Disconnect(); disconnectErr != nil {
			log.Warn().Err(disconnectErr).Msg("Error disconnecting proxy client")
		}
	} else {
		cluster.StopCollector(clusterCollector)
	}
	if shutdownErr := metricsServer.Stop(ctx); shutdownErr != nil {
		log.Warn().Err(shutdownErr).Msg("Error shutting down metrics server")
	}
	return nil
}

func startKTM(ctx context.Context, log zerolog.Logger, fr *flightrecorder.FlightRecorder) (func(), error) {
	if ktmInterval <= 0 {
		return nil, fmt.Errorf("ktm-interval must be positive, got %v", ktmInterval)
	}

	ktmCfg := ktm.Config{
		Enabled:  ktmEnabled,
		Interval: ktmInterval,
		Modules:  []string{"iomonitor"},
	}

	ktmSvc, createErr := ktm.NewKTM(ktmCfg, log)
	if createErr != nil {
		return nil, fmt.Errorf("failed to create KTM: %w", createErr)
	}

	if startErr := ktmSvc.Start(ctx); startErr != nil {
		return nil, fmt.Errorf("failed to start KTM: %w", startErr)
	}

	stopBridgeCh := make(chan struct{})
	panicdiag.GoWithRecovery(ctx, panicdiag.RecoveryOptions{
		Component: "fodc-agent-ktm-bridge",
	}, nil, func(_ *context.Context) {
		ticker := time.NewTicker(ktmInterval)
		defer ticker.Stop()

		for {
			select {
			case <-ctx.Done():
				return
			case <-stopBridgeCh:
				return
			case <-ticker.C:
				rawMetrics := ktmSvc.GetMetrics()
				ktmStatus := 2.0
				if ktmSvc.IsDegraded() {
					ktmStatus = 1.0
				}
				rawMetrics = append(rawMetrics, fodcmetrics.RawMetric{
					Name:  "ktm_status",
					Value: ktmStatus,
					Desc:  "KTM status: 0=Disabled, 1=Degraded (comm-only), 2=Full (cgroup+comm)",
				})
				if updateErr := fr.Update(rawMetrics); updateErr != nil {
					log.Warn().Err(updateErr).Msg("Failed to update FlightRecorder with KTM metrics")
				}
			}
		}
	})

	return func() {
		close(stopBridgeCh)
		_ = ktmSvc.Stop()
	}, nil
}

func validateFlags() error {
	if metricsPollInterval <= 0 {
		return fmt.Errorf("poll-metrics-interval must be greater than 0")
	}
	if len(pollMetricsPorts) == 0 {
		return fmt.Errorf("poll-metrics-ports cannot be empty")
	}
	if len(containerNames) > 0 && len(containerNames) != len(pollMetricsPorts) {
		return fmt.Errorf("container-names count (%d) must match poll-metrics-ports count (%d)", len(containerNames), len(pollMetricsPorts))
	}
	if maxMetricsMemoryUsagePercent < 0 || maxMetricsMemoryUsagePercent > 100 {
		return fmt.Errorf("max-metrics-memory-usage-percentage must be between 0 and 100")
	}
	if diagnosisListenAddr == "" {
		return fmt.Errorf("diagnosis-listen-addr cannot be empty")
	}
	if maxFODCDiagnosisMemoryUsagePercent < 0 || maxFODCDiagnosisMemoryUsagePercent > 100 {
		return fmt.Errorf("max-fodc-diagnosis-memory-usage-percentage must be between 0 and 100")
	}
	if pressureProfilerEnabled && (pressureTriggerPercent < 1 || pressureTriggerPercent > 100) {
		return fmt.Errorf("pressure-profiler-trigger-percent must be between 1 and 100, got %d", pressureTriggerPercent)
	}
	return nil
}

func startProxyClient(ctx context.Context, log *logger.Logger, fr *flightrecorder.FlightRecorder,
	nodeRole string, nodeLabels map[string]string, collector *cluster.Collector,
	collectionLister crashcollector.CollectionLister, profileSource proxy.ProfileSource,
) *proxy.Client {
	if proxyAddr == "" || podName == "" || nodeRole == "" {
		log.Info().Msg("Proxy client not started (missing: --proxy-addr, --pod-name, and --node-role)")
		return nil
	}
	var lifecycleCollector *lifecycle.Collector
	if lifecyclePort > 0 {
		// Use the IPv4 loopback rather than "localhost" so the dial never resolves to the
		// IPv6 loopback (::1), which fails with "cannot assign requested address" in pods
		// without an IPv6 loopback.
		grpcAddr := fmt.Sprintf("127.0.0.1:%d", lifecyclePort)
		var registryAddr string
		if schemaRegistryPort > 0 {
			registryAddr = fmt.Sprintf("127.0.0.1:%d", schemaRegistryPort)
		}
		lifecycleCollector = lifecycle.NewCollector(log, grpcAddr, registryAddr, lifecycleReportDir, lifecycleCacheTTL)
		log.Info().Str("grpc_addr", grpcAddr).Str("registry_addr", registryAddr).Msg("Lifecycle collector initialized")
	}
	client := proxy.NewClient(proxyAddr, nodeRole, podName, containerNames, nodeLabels,
		heartbeatInterval, reconnectInterval, fr, collector, lifecycleCollector, collectionLister, profileSource, log)
	if collector != nil {
		// Keep the registered node_role fresh: it can resolve after startup (e.g. a
		// liaison whose client gRPC comes up later than the role-resolution budget),
		// so heartbeats carry the live value and the proxy upgrades the stored role.
		client.SetNodeRoleProvider(collector.GetNodeInfo)
	}
	panicdiag.GoWithRecovery(ctx, panicdiag.RecoveryOptions{
		Component: "fodc-agent-proxy-client",
		Logger:    log,
	}, nil, func(_ *context.Context) {
		if startErr := client.Start(ctx); startErr != nil {
			log.Error().Err(startErr).Msg("Proxy client error")
		}
	})
	log.Info().Str("proxy_addr", proxyAddr).Str("pod_name", podName).Str("node_role", nodeRole).Msg("Proxy client started")
	return client
}

func generateMetricsEndpoints(ports []string) []string {
	endpoints := make([]string, 0, len(ports))
	for _, port := range ports {
		endpoints = append(endpoints, fmt.Sprintf("http://localhost:%s/metrics", port))
	}
	return endpoints
}
