// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package telemetry

import (
	"context"
	"fmt"
	"regexp"
	"sort"
	"strings"
	"sync"
	"testing"

	gometrics "github.com/armon/go-metrics"
	"github.com/stretchr/testify/require"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/metric/metricdata"
	"go.opentelemetry.io/otel/sdk/resource"
)

type mockConfigProvider struct {
	filter   *regexp.Regexp
	labels   map[string]string
	disabled bool
}

func (m *mockConfigProvider) GetLabels() map[string]string {
	return m.labels
}

func (m *mockConfigProvider) GetFilters() *regexp.Regexp {
	return m.filter
}

func (m *mockConfigProvider) IsDisabled() bool {
	return m.disabled
}

var (
	expectedResource = resource.NewSchemaless()

	attrs = attribute.NewSet(attribute.KeyValue{
		Key:   attribute.Key("node_id"),
		Value: attribute.StringValue("test"),
	})
	attrsWithMetricLabel = attribute.NewSet(attribute.KeyValue{
		Key:   attribute.Key("metric.label"),
		Value: attribute.StringValue("test"),
	}, attribute.KeyValue{
		Key:   attribute.Key("node_id"),
		Value: attribute.StringValue("test"),
	})

	expectedSinkMetrics = map[string]metricdata.Metrics{
		"consul.raft.leader": {
			Name:        "consul.raft.leader",
			Description: "",
			Unit:        "",
			Data: metricdata.Gauge[float64]{
				DataPoints: []metricdata.DataPoint[float64]{
					{
						Attributes: attrs,
						Value:      float64(float32(0)),
					},
				},
			},
		},
		"consul.autopilot.healthy": {
			Name:        "consul.autopilot.healthy",
			Description: "",
			Unit:        "",
			Data: metricdata.Gauge[float64]{
				DataPoints: []metricdata.DataPoint[float64]{
					{
						Attributes: attrsWithMetricLabel,
						Value:      float64(float32(1.23)),
					},
				},
			},
		},
		"consul.raft.state.leader": {
			Name:        "consul.raft.state.leader",
			Description: "",
			Unit:        "",
			Data: metricdata.Sum[float64]{
				DataPoints: []metricdata.DataPoint[float64]{
					{
						Attributes: attrs,
						Value:      float64(float32(23.23)),
					},
				},
			},
		},
		"consul.raft.apply": {
			Name:        "consul.raft.apply",
			Description: "",
			Unit:        "",
			Data: metricdata.Sum[float64]{
				DataPoints: []metricdata.DataPoint[float64]{
					{
						Attributes: attrsWithMetricLabel,
						Value:      float64(float32(1.44)),
					},
				},
			},
		},
		"consul.raft.leader.lastContact": {
			Name:        "consul.raft.leader.lastContact",
			Description: "",
			Unit:        "",
			Data: metricdata.Histogram[float64]{
				DataPoints: []metricdata.HistogramDataPoint[float64]{
					{
						Attributes: attrs,
						Count:      1,
						Sum:        float64(float32(45.32)),
						Min:        metricdata.NewExtrema(float64(float32(45.32))),
						Max:        metricdata.NewExtrema(float64(float32(45.32))),
					},
				},
			},
		},
		"consul.raft.commitTime": {
			Name:        "consul.raft.commitTime",
			Description: "",
			Unit:        "",
			Data: metricdata.Histogram[float64]{
				DataPoints: []metricdata.HistogramDataPoint[float64]{
					{
						Attributes: attrsWithMetricLabel,
						Count:      1,
						Sum:        float64(float32(26.34)),
						Min:        metricdata.NewExtrema(float64(float32(26.34))),
						Max:        metricdata.NewExtrema(float64(float32(26.34))),
					},
				},
			},
		},
	}
)

func TestNewOTELSink(t *testing.T) {
	t.Parallel()
	for name, test := range map[string]struct {
		wantErr string
		opts    *OTELSinkOpts
	}{
		"failsWithEmptyReader": {
			wantErr: "ferror: provide valid reader",
			opts: &OTELSinkOpts{
				Reader:         nil,
				ConfigProvider: &mockConfigProvider{},
			},
		},
		"failsWithEmptyConfigProvider": {
			wantErr: "ferror: provide valid config provider",
			opts: &OTELSinkOpts{
				Reader: metric.NewManualReader(),
			},
		},
		"success": {
			opts: &OTELSinkOpts{
				Reader:         metric.NewManualReader(),
				ConfigProvider: &mockConfigProvider{},
			},
		},
	} {
		test := test
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			sink, err := NewOTELSink(context.Background(), test.opts)
			if test.wantErr != "" {
				require.Error(t, err)
				require.Contains(t, err.Error(), test.wantErr)
				return
			}

			require.NotNil(t, sink)
		})
	}
}

func TestOTELSink(t *testing.T) {
	t.Parallel()

	// Manual reader outputs the aggregated metrics when reader.Collect is called.
	reader := metric.NewManualReader()

	ctx := context.Background()
	opts := &OTELSinkOpts{
		Reader: reader,
		ConfigProvider: &mockConfigProvider{
			filter: regexp.MustCompile("raft|autopilot"),
			labels: map[string]string{
				"node_id": "test",
			},
		},
	}

	sink, err := NewOTELSink(ctx, opts)
	require.NoError(t, err)

	labels := []gometrics.Label{
		{
			Name:  "metric.label",
			Value: "test",
		},
	}

	sink.SetGauge([]string{"test", "bad_filter", "gauge"}, float32(0))
	sink.SetGauge([]string{"consul", "raft", "leader"}, float32(0))
	sink.SetGaugeWithLabels([]string{"consul", "autopilot", "healthy"}, float32(1.23), labels)

	sink.IncrCounter([]string{"test", "bad_filter", "counter"}, float32(23.23))
	sink.IncrCounter([]string{"consul", "raft", "state", "leader"}, float32(23.23))
	sink.IncrCounterWithLabels([]string{"consul", "raft", "apply"}, float32(1.44), labels)

	sink.AddSample([]string{"test", "bad_filter", "sample"}, float32(45.32))
	sink.AddSample([]string{"consul", "raft", "leader", "lastContact"}, float32(45.32))
	sink.AddSampleWithLabels([]string{"consul", "raft", "commitTime"}, float32(26.34), labels)

	var collected metricdata.ResourceMetrics
	err = reader.Collect(ctx, &collected)
	require.NoError(t, err)

	isSame(t, expectedSinkMetrics, collected)
}

func TestOTELSinkDisabled(t *testing.T) {
	reader := metric.NewManualReader()
	ctx := context.Background()

	sink, err := NewOTELSink(ctx, &OTELSinkOpts{
		ConfigProvider: &mockConfigProvider{
			filter:   regexp.MustCompile("raft"),
			disabled: true,
		},
		Reader: reader,
	})
	require.NoError(t, err)

	sink.SetGauge([]string{"consul", "raft", "gauge"}, 1)
	sink.IncrCounter([]string{"consul", "raft", "counter"}, 1)
	sink.AddSample([]string{"consul", "raft", "sample"}, 1)

	var collected metricdata.ResourceMetrics
	err = reader.Collect(ctx, &collected)
	require.NoError(t, err)
	require.Empty(t, collected.ScopeMetrics)
}

func TestLabelsToAttributes(t *testing.T) {
	for name, test := range map[string]struct {
		providerLabels         map[string]string
		goMetricsLabels        []gometrics.Label
		expectedOTELAttributes []attribute.KeyValue
	}{
		"emptyLabels": {
			expectedOTELAttributes: []attribute.KeyValue{},
		},
		"emptyGoMetricsLabels": {
			providerLabels: map[string]string{
				"node_id": "test",
			},
			expectedOTELAttributes: []attribute.KeyValue{
				{
					Key:   attribute.Key("node_id"),
					Value: attribute.StringValue("test"),
				},
			},
		},
		"emptyProviderLabels": {
			goMetricsLabels: []gometrics.Label{
				{
					Name:  "server_type",
					Value: "internal",
				},
			},
			expectedOTELAttributes: []attribute.KeyValue{
				{
					Key:   attribute.Key("server_type"),
					Value: attribute.StringValue("internal"),
				},
			},
		},
		"combinedLabels": {
			goMetricsLabels: []gometrics.Label{
				{
					Name:  "server_type",
					Value: "internal",
				},
				{
					Name:  "method",
					Value: "get",
				},
			},
			providerLabels: map[string]string{
				"node_id":   "test",
				"node_name": "labels_test",
			},
			expectedOTELAttributes: []attribute.KeyValue{
				{
					Key:   attribute.Key("server_type"),
					Value: attribute.StringValue("internal"),
				},
				{
					Key:   attribute.Key("method"),
					Value: attribute.StringValue("get"),
				},
				{
					Key:   attribute.Key("node_id"),
					Value: attribute.StringValue("test"),
				},
				{
					Key:   attribute.Key("node_name"),
					Value: attribute.StringValue("labels_test"),
				},
			},
		},
	} {
		test := test
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			ctx := context.Background()
			opts := &OTELSinkOpts{
				Reader: metric.NewManualReader(),
				ConfigProvider: &mockConfigProvider{
					filter: regexp.MustCompile("raft|autopilot"),
					labels: test.providerLabels,
				},
			}
			sink, err := NewOTELSink(ctx, opts)
			require.NoError(t, err)

			require.ElementsMatch(t, test.expectedOTELAttributes, sink.labelsToAttributes(test.goMetricsLabels))
		})
	}
}

func TestOTELSinkFilters(t *testing.T) {
	t.Parallel()
	for name, tc := range map[string]struct {
		cfgProvider ConfigProvider
		expected    bool
	}{
		"emptyMatch": {
			cfgProvider: &mockConfigProvider{},
			expected:    true,
		},
		"matchingFilter": {
			cfgProvider: &mockConfigProvider{
				filter: regexp.MustCompile("raft"),
			},
			expected: true,
		},
		"mismatchFilter": {cfgProvider: &mockConfigProvider{
			filter: regexp.MustCompile("test"),
		}},
	} {
		tc := tc
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			testMetricKey := "consul.raft"
			s, err := NewOTELSink(context.Background(), &OTELSinkOpts{
				ConfigProvider: tc.cfgProvider,
				Reader:         metric.NewManualReader(),
			})
			require.NoError(t, err)
			require.Equal(t, tc.expected, s.allowedMetric(testMetricKey))
		})
	}
}

func TestOTELSink_Race(t *testing.T) {
	reader := metric.NewManualReader()
	ctx := context.Background()
	defaultLabels := map[string]string{
		"node_id": "test",
	}
	opts := &OTELSinkOpts{
		Reader: reader,
		ConfigProvider: &mockConfigProvider{
			filter: regexp.MustCompile("test"),
			labels: defaultLabels,
		},
	}

	sink, err := NewOTELSink(context.Background(), opts)
	require.NoError(t, err)

	samples := 100
	expectedMetrics := generateSamples(samples, defaultLabels)
	wg := &sync.WaitGroup{}
	errCh := make(chan error, samples)
	for k, v := range expectedMetrics {
		wg.Add(1)
		go func(k string, v metricdata.Metrics) {
			defer wg.Done()
			performSinkOperation(sink, k, v, errCh)
		}(k, v)
	}
	wg.Wait()

	require.Empty(t, errCh)

	var collected metricdata.ResourceMetrics
	err = reader.Collect(ctx, &collected)
	require.NoError(t, err)

	isSame(t, expectedMetrics, collected)
}

// generateSamples generates n of each gauges, counter and histogram measurements to use for test purposes.
func generateSamples(n int, labels map[string]string) map[string]metricdata.Metrics {
	generated := make(map[string]metricdata.Metrics, 3*n)
	attrs := *attribute.EmptySet()

	kvs := make([]attribute.KeyValue, 0, len(labels))
	for k, v := range labels {
		kvs = append(kvs, attribute.KeyValue{Key: attribute.Key(k), Value: attribute.StringValue(v)})
	}
	if len(kvs) > 0 {
		attrs = attribute.NewSet(kvs...)
	}

	for i := 0; i < n; i++ {
		v := 12.3
		k := fmt.Sprintf("consul.test.gauges.%d", i)
		generated[k] = metricdata.Metrics{
			Name: k,
			Data: metricdata.Gauge[float64]{
				DataPoints: []metricdata.DataPoint[float64]{
					{
						Attributes: attrs,
						Value:      float64(float32(v)),
					},
				},
			},
		}
	}

	for i := 0; i < n; i++ {
		v := 22.23
		k := fmt.Sprintf("consul.test.sum.%d", i)
		generated[k] = metricdata.Metrics{
			Name: k,
			Data: metricdata.Sum[float64]{
				DataPoints: []metricdata.DataPoint[float64]{
					{
						Attributes: attrs,
						Value:      float64(float32(v)),
					},
				},
			},
		}

	}

	for i := 0; i < n; i++ {
		v := 13.24
		k := fmt.Sprintf("consul.test.hist.%d", i)
		generated[k] = metricdata.Metrics{
			Name: k,
			Data: metricdata.Histogram[float64]{
				DataPoints: []metricdata.HistogramDataPoint[float64]{
					{
						Attributes: attrs,
						Sum:        float64(float32(v)),
						Max:        metricdata.NewExtrema(float64(float32(v))),
						Min:        metricdata.NewExtrema(float64(float32(v))),
						Count:      1,
					},
				},
			},
		}
	}

	return generated
}

// performSinkOperation emits a measurement using the OTELSink and calls wg.Done() when completed.
func performSinkOperation(sink *OTELSink, k string, v metricdata.Metrics, errCh chan error) {
	key := strings.Split(k, ".")
	data := v.Data
	switch data.(type) {
	case metricdata.Gauge[float64]:
		gauge, ok := data.(metricdata.Gauge[float64])
		if !ok {
			errCh <- fmt.Errorf("unexpected type assertion error for key: %s", key)
		}
		sink.SetGauge(key, float32(gauge.DataPoints[0].Value))
	case metricdata.Sum[float64]:
		sum, ok := data.(metricdata.Sum[float64])
		if !ok {
			errCh <- fmt.Errorf("unexpected type assertion error for key: %s", key)
		}
		sink.IncrCounter(key, float32(sum.DataPoints[0].Value))
	case metricdata.Histogram[float64]:
		hist, ok := data.(metricdata.Histogram[float64])
		if !ok {
			errCh <- fmt.Errorf("unexpected type assertion error for key: %s", key)
		}
		sink.AddSample(key, float32(hist.DataPoints[0].Sum))
	}
}

func isSame(t *testing.T, expectedMap map[string]metricdata.Metrics, actual metricdata.ResourceMetrics) {
	// Validate resource
	require.Equal(t, expectedResource, actual.Resource)

	// Validate Metrics
	require.NotEmpty(t, actual.ScopeMetrics)
	actualMetrics := actual.ScopeMetrics[0].Metrics
	require.Equal(t, len(expectedMap), len(actualMetrics))

	for _, actual := range actualMetrics {
		name := actual.Name
		expected, ok := expectedMap[actual.Name]
		require.True(t, ok, "metric key %s should be in expectedMetrics map", name)
		isSameMetrics(t, expected, actual)
	}
}

// compareMetrics verifies if two metricdata.Metric objects are equal by ignoring the time component.
// avoid duplicate datapoint values to ensure predictable order of sort.
func isSameMetrics(t *testing.T, expected metricdata.Metrics, actual metricdata.Metrics) {
	require.Equal(t, expected.Name, actual.Name, "different .Name field")
	require.Equal(t, expected.Description, actual.Description, "different .Description field")
	require.Equal(t, expected.Unit, actual.Unit, "different .Unit field")

	switch expectedData := expected.Data.(type) {
	case metricdata.Gauge[float64]:
		actualData, ok := actual.Data.(metricdata.Gauge[float64])
		require.True(t, ok, "different metric types: expected metricdata.Gauge[float64]")

		isSameDataPoint(t, expectedData.DataPoints, actualData.DataPoints)
	case metricdata.Sum[float64]:
		actualData, ok := actual.Data.(metricdata.Sum[float64])
		require.True(t, ok, "different metric types: expected metricdata.Sum[float64]")

		isSameDataPoint(t, expectedData.DataPoints, actualData.DataPoints)
	case metricdata.Histogram[float64]:
		actualData, ok := actual.Data.(metricdata.Histogram[float64])
		require.True(t, ok, "different metric types: expected metricdata.Histogram")

		isSameHistogramData(t, expectedData.DataPoints, actualData.DataPoints)
	}
}

func isSameDataPoint(t *testing.T, expected []metricdata.DataPoint[float64], actual []metricdata.DataPoint[float64]) {
	require.Equal(t, len(expected), len(actual), "different datapoints length")

	// Sort for predictable data in order of lowest value.
	sort.Slice(expected, func(i, j int) bool {
		return expected[i].Value < expected[j].Value
	})
	sort.Slice(actual, func(i, j int) bool {
		return expected[i].Value < expected[j].Value
	})

	// Only verify the value and  attributes.
	for i, dp := range expected {
		currActual := actual[i]
		require.Equal(t, dp.Value, currActual.Value, "different datapoint value")
		require.Equal(t, dp.Attributes, currActual.Attributes, "different attributes")
	}
}

func isSameHistogramData(t *testing.T, expected []metricdata.HistogramDataPoint[float64], actual []metricdata.HistogramDataPoint[float64]) {
	require.Equal(t, len(expected), len(actual), "different histogram datapoint length")

	// Sort for predictable data in order of lowest sum.
	sort.Slice(expected, func(i, j int) bool {
		return expected[i].Sum < expected[j].Sum
	})
	sort.Slice(actual, func(i, j int) bool {
		return expected[i].Sum < expected[j].Sum
	})

	// Only verify the value and the attributes.
	for i, dp := range expected {
		currActual := actual[i]
		require.Equal(t, dp.Sum, currActual.Sum, "different histogram datapoint .Sum value")
		require.Equal(t, dp.Max, currActual.Max, "different histogram datapoint .Max value")
		require.Equal(t, dp.Min, currActual.Min, "different histogram datapoint .Min value")
		require.Equal(t, dp.Count, currActual.Count, "different histogram datapoint .Count value")
		require.Equal(t, dp.Attributes, currActual.Attributes, "different attributes")
	}
}
