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

package telemetry

import (
	"context"
	"fmt"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/armon/go-metrics"
	"github.com/stretchr/testify/require"
	"go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/metric/aggregation"
	"go.opentelemetry.io/otel/sdk/metric/metricdata"
	"go.opentelemetry.io/otel/sdk/resource"
	metricpb "go.opentelemetry.io/proto/otlp/metrics/v1"
)

const (
	testExportEndpoint = "https://test.com/v1/metrics"
)

type mockMetricsClient struct {
	exportErr error
}

func (m *mockMetricsClient) ExportMetrics(ctx context.Context, protoMetrics *metricpb.ResourceMetrics, endpoint string) error {
	return m.exportErr
}

type mockEndpointProvider struct {
	endpoint *url.URL
	disabled bool
}

func (m *mockEndpointProvider) GetEndpoint() *url.URL { return m.endpoint }
func (m *mockEndpointProvider) IsDisabled() bool      { return m.disabled }

func TestTemporality(t *testing.T) {
	t.Parallel()
	exp := &otelExporter{}
	require.Equal(t, metricdata.CumulativeTemporality, exp.Temporality(metric.InstrumentKindCounter))
}

func TestAggregation(t *testing.T) {
	t.Parallel()
	for name, test := range map[string]struct {
		kind   metric.InstrumentKind
		expAgg aggregation.Aggregation
	}{
		"gauge": {
			kind:   metric.InstrumentKindObservableGauge,
			expAgg: aggregation.LastValue{},
		},
		"counter": {
			kind:   metric.InstrumentKindCounter,
			expAgg: aggregation.Sum{},
		},
		"histogram": {
			kind:   metric.InstrumentKindHistogram,
			expAgg: aggregation.ExplicitBucketHistogram{Boundaries: []float64{0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000}, NoMinMax: false},
		},
	} {
		test := test
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			exp := &otelExporter{}
			require.Equal(t, test.expAgg, exp.Aggregation(test.kind))
		})
	}
}

func TestExport(t *testing.T) {
	t.Parallel()
	for name, test := range map[string]struct {
		wantErr  string
		metrics  *metricdata.ResourceMetrics
		client   MetricsClient
		provider EndpointProvider
	}{
		"earlyReturnDisabledProvider": {
			client: &mockMetricsClient{},
			provider: &mockEndpointProvider{
				disabled: true,
			},
		},
		"earlyReturnWithoutEndpoint": {
			client:   &mockMetricsClient{},
			provider: &mockEndpointProvider{},
		},
		"earlyReturnWithoutScopeMetrics": {
			client:   &mockMetricsClient{},
			metrics:  mutateMetrics(nil),
			provider: &mockEndpointProvider{},
		},
		"earlyReturnWithoutMetrics": {
			client: &mockMetricsClient{},
			metrics: mutateMetrics([]metricdata.ScopeMetrics{
				{Metrics: []metricdata.Metrics{}},
			},
			),
			provider: &mockEndpointProvider{},
		},
		"errorWithExportFailure": {
			client: &mockMetricsClient{
				exportErr: fmt.Errorf("failed to export metrics."),
			},
			metrics: mutateMetrics([]metricdata.ScopeMetrics{
				{
					Metrics: []metricdata.Metrics{
						{
							Name: "consul.raft.commitTime",
							Data: metricdata.Gauge[float64]{},
						},
					},
				},
			},
			),
			provider: &mockEndpointProvider{
				endpoint: &url.URL{},
			},
			wantErr: "failed to export metrics",
		},
	} {
		test := test
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			provider := test.provider
			if provider == nil {
				u, err := url.Parse(testExportEndpoint)
				require.NoError(t, err)
				provider = &mockEndpointProvider{
					endpoint: u,
				}
			}

			exp := newOTELExporter(test.client, provider)

			err := exp.Export(context.Background(), test.metrics)
			if test.wantErr != "" {
				require.Error(t, err)
				require.Contains(t, err.Error(), test.wantErr)
				return
			}

			require.NoError(t, err)
		})
	}
}

// TestExport_CustomMetrics tests that a custom metric (hcp.otel.exporter.*) is emitted
// for exporter operations. This test cannot be run in parallel as the metrics.NewGlobal()
// sets a shared global sink.
func TestExport_CustomMetrics(t *testing.T) {
	for name, tc := range map[string]struct {
		client    MetricsClient
		metricKey []string
		operation string
	}{
		"exportSuccessEmitsCustomMetric": {
			client:    &mockMetricsClient{},
			metricKey: internalMetricExportSuccess,
			operation: "export",
		},
		"exportFailureEmitsCustomMetric": {
			client: &mockMetricsClient{
				exportErr: fmt.Errorf("client err"),
			},
			metricKey: internalMetricExportFailure,
			operation: "export",
		},
		"shutdownEmitsCustomMetric": {
			metricKey: internalMetricExporterShutdown,
			operation: "shutdown",
		},
		"forceFlushEmitsCustomMetric": {
			metricKey: internalMetricExporterForceFlush,
			operation: "flush",
		},
	} {
		t.Run(name, func(t *testing.T) {
			// Init global sink.
			serviceName := "test.transform"
			cfg := metrics.DefaultConfig(serviceName)
			cfg.EnableHostname = false

			sink := metrics.NewInmemSink(10*time.Second, 10*time.Second)
			metrics.NewGlobal(cfg, sink)

			// Perform operation that emits metric.
			u, err := url.Parse(testExportEndpoint)
			require.NoError(t, err)

			exp := newOTELExporter(tc.client, &mockEndpointProvider{
				endpoint: u,
			})

			ctx := context.Background()
			switch tc.operation {
			case "flush":
				exp.ForceFlush(ctx)
			case "shutdown":
				exp.Shutdown(ctx)
			default:
				exp.Export(ctx, inputResourceMetrics)
			}

			// Collect sink metrics.
			intervals := sink.Data()
			require.Len(t, intervals, 1)
			key := serviceName + "." + strings.Join(tc.metricKey, ".")
			sv := intervals[0].Counters[key]

			// Verify count for transform failure metric.
			require.NotNil(t, sv)
			require.NotNil(t, sv.AggregateSample)
			require.Equal(t, 1, sv.AggregateSample.Count)
		})
	}
}

func TestForceFlush(t *testing.T) {
	t.Parallel()
	exp := &otelExporter{}
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	err := exp.ForceFlush(ctx)
	require.ErrorIs(t, err, context.Canceled)
}

func TestShutdown(t *testing.T) {
	t.Parallel()
	exp := &otelExporter{}
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	err := exp.Shutdown(ctx)
	require.ErrorIs(t, err, context.Canceled)
}

func mutateMetrics(m []metricdata.ScopeMetrics) *metricdata.ResourceMetrics {
	return &metricdata.ResourceMetrics{
		Resource:     resource.Empty(),
		ScopeMetrics: m,
	}
}
