// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package stdouttrace_test

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"math"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/codes"
	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace/internal/counter"
	"go.opentelemetry.io/otel/exporters/stdout/stdouttrace/internal/observ"
	"go.opentelemetry.io/otel/sdk/instrumentation"
	"go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/metric/metricdata"
	"go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest"
	"go.opentelemetry.io/otel/sdk/resource"
	tracesdk "go.opentelemetry.io/otel/sdk/trace"
	"go.opentelemetry.io/otel/sdk/trace/tracetest"
	semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
	"go.opentelemetry.io/otel/semconv/v1.39.0/otelconv"
	"go.opentelemetry.io/otel/trace"
)

func TestExporterExportSpan(t *testing.T) {
	// setup test span
	now := time.Now()
	traceID, _ := trace.TraceIDFromHex("0102030405060708090a0b0c0d0e0f10")
	spanID, _ := trace.SpanIDFromHex("0102030405060708")
	traceState, _ := trace.ParseTraceState("key=val")
	keyValue := "value"
	doubleValue := 123.456
	res := resource.NewSchemaless(attribute.String("rk1", "rv11"))

	ss := tracetest.SpanStub{
		SpanContext: trace.NewSpanContext(trace.SpanContextConfig{
			TraceID:    traceID,
			SpanID:     spanID,
			TraceState: traceState,
		}),
		Name:      "/foo",
		StartTime: now,
		EndTime:   now,
		Attributes: []attribute.KeyValue{
			attribute.String("key", keyValue),
			attribute.Float64("double", doubleValue),
		},
		Events: []tracesdk.Event{
			{Name: "foo", Attributes: []attribute.KeyValue{attribute.String("key", keyValue)}, Time: now},
			{Name: "bar", Attributes: []attribute.KeyValue{attribute.Float64("double", doubleValue)}, Time: now},
		},
		SpanKind: trace.SpanKindInternal,
		Status: tracesdk.Status{
			Code:        codes.Error,
			Description: "interesting",
		},
		Resource: res,
	}

	tests := []struct {
		opts      []stdouttrace.Option
		expectNow time.Time
		ctx       context.Context
		wantErr   error
	}{
		{
			opts:      []stdouttrace.Option{stdouttrace.WithPrettyPrint()},
			expectNow: now,
			ctx:       t.Context(),
		},
		{
			opts: []stdouttrace.Option{stdouttrace.WithPrettyPrint(), stdouttrace.WithoutTimestamps()},
			// expectNow is an empty time.Time
			ctx: t.Context(),
		},
		{
			opts: []stdouttrace.Option{},
			ctx: func() context.Context {
				ctx, cancel := context.WithCancel(t.Context())
				cancel()
				return ctx
			}(),
			wantErr: context.Canceled,
		},
	}

	for _, tt := range tests {
		// write to buffer for testing
		var b bytes.Buffer
		ex, err := stdouttrace.New(append(tt.opts, stdouttrace.WithWriter(&b))...)
		require.NoError(t, err)

		err = ex.ExportSpans(tt.ctx, tracetest.SpanStubs{ss, ss}.Snapshots())
		assert.Equal(t, tt.wantErr, err)

		if tt.wantErr == nil {
			got := b.String()
			wantone := expectedJSON(tt.expectNow)
			assert.Equal(t, wantone+wantone, got)
		}
	}
}

func expectedJSON(now time.Time) string {
	serializedNow, _ := json.Marshal(now)
	return `{
	"Name": "/foo",
	"SpanContext": {
		"TraceID": "0102030405060708090a0b0c0d0e0f10",
		"SpanID": "0102030405060708",
		"TraceFlags": "00",
		"TraceState": "key=val",
		"Remote": false
	},
	"Parent": {
		"TraceID": "00000000000000000000000000000000",
		"SpanID": "0000000000000000",
		"TraceFlags": "00",
		"TraceState": "",
		"Remote": false
	},
	"SpanKind": 1,
	"StartTime": ` + string(serializedNow) + `,
	"EndTime": ` + string(serializedNow) + `,
	"Attributes": [
		{
			"Key": "key",
			"Value": {
				"Type": "STRING",
				"Value": "value"
			}
		},
		{
			"Key": "double",
			"Value": {
				"Type": "FLOAT64",
				"Value": 123.456
			}
		}
	],
	"Events": [
		{
			"Name": "foo",
			"Attributes": [
				{
					"Key": "key",
					"Value": {
						"Type": "STRING",
						"Value": "value"
					}
				}
			],
			"DroppedAttributeCount": 0,
			"Time": ` + string(serializedNow) + `
		},
		{
			"Name": "bar",
			"Attributes": [
				{
					"Key": "double",
					"Value": {
						"Type": "FLOAT64",
						"Value": 123.456
					}
				}
			],
			"DroppedAttributeCount": 0,
			"Time": ` + string(serializedNow) + `
		}
	],
	"Links": null,
	"Status": {
		"Code": "Error",
		"Description": "interesting"
	},
	"DroppedAttributes": 0,
	"DroppedEvents": 0,
	"DroppedLinks": 0,
	"ChildSpanCount": 0,
	"Resource": [
		{
			"Key": "rk1",
			"Value": {
				"Type": "STRING",
				"Value": "rv11"
			}
		}
	],
	"InstrumentationScope": {
		"Name": "",
		"Version": "",
		"SchemaURL": "",
		"Attributes": null
	},
	"InstrumentationLibrary": {
		"Name": "",
		"Version": "",
		"SchemaURL": "",
		"Attributes": null
	}
}
`
}

func TestExporterShutdownIgnoresContext(t *testing.T) {
	ctx, cancel := context.WithTimeout(t.Context(), 1*time.Minute)
	t.Cleanup(cancel)

	e, err := stdouttrace.New()
	if err != nil {
		t.Fatalf("failed to create exporter: %v", err)
	}

	innerCtx, innerCancel := context.WithCancel(ctx)
	innerCancel()
	err = e.Shutdown(innerCtx)
	assert.NoError(t, err)
}

func TestExporterShutdownNoError(t *testing.T) {
	e, err := stdouttrace.New()
	if err != nil {
		t.Fatalf("failed to create exporter: %v", err)
	}

	if err := e.Shutdown(t.Context()); err != nil {
		t.Errorf("shutdown errored: expected nil, got %v", err)
	}
}

func TestObservability(t *testing.T) {
	defaultCallExportSpans := func(t *testing.T, exporter *stdouttrace.Exporter) {
		require.NoError(t, exporter.ExportSpans(t.Context(), tracetest.SpanStubs{
			{Name: "/foo"},
			{Name: "/bar"},
		}.Snapshots()))
	}

	tests := []struct {
		name            string
		enabled         bool
		callExportSpans func(t *testing.T, exporter *stdouttrace.Exporter)
		assertMetrics   func(t *testing.T, rm metricdata.ResourceMetrics)
	}{
		{
			name:            "Disabled",
			enabled:         false,
			callExportSpans: defaultCallExportSpans,
			assertMetrics: func(t *testing.T, rm metricdata.ResourceMetrics) {
				assert.Empty(t, rm.ScopeMetrics)
			},
		},
		{
			name:            "Enabled",
			enabled:         true,
			callExportSpans: defaultCallExportSpans,
			assertMetrics: func(t *testing.T, rm metricdata.ResourceMetrics) {
				t.Helper()
				require.Len(t, rm.ScopeMetrics, 1)

				sm := rm.ScopeMetrics[0]
				require.Len(t, sm.Metrics, 3)

				assert.Equal(t, instrumentation.Scope{
					Name:      observ.ScopeName,
					Version:   observ.Version,
					SchemaURL: observ.SchemaURL,
				}, sm.Scope)

				metricdatatest.AssertEqual(t, metricdata.Metrics{
					Name:        otelconv.SDKExporterSpanInflight{}.Name(),
					Description: otelconv.SDKExporterSpanInflight{}.Description(),
					Unit:        otelconv.SDKExporterSpanInflight{}.Unit(),
					Data: metricdata.Sum[int64]{
						Temporality: metricdata.CumulativeTemporality,
						DataPoints: []metricdata.DataPoint[int64]{
							{
								Attributes: attribute.NewSet(
									semconv.OTelComponentName(observ.ComponentName(0)),
									semconv.OTelComponentTypeKey.String(observ.ComponentType),
								),
								Value: 0,
							},
						},
					},
				}, sm.Metrics[0], metricdatatest.IgnoreTimestamp())

				metricdatatest.AssertEqual(t, metricdata.Metrics{
					Name:        otelconv.SDKExporterSpanExported{}.Name(),
					Description: otelconv.SDKExporterSpanExported{}.Description(),
					Unit:        otelconv.SDKExporterSpanExported{}.Unit(),
					Data: metricdata.Sum[int64]{
						Temporality: metricdata.CumulativeTemporality,
						IsMonotonic: true,
						DataPoints: []metricdata.DataPoint[int64]{
							{
								Attributes: attribute.NewSet(
									semconv.OTelComponentName(observ.ComponentName(0)),
									semconv.OTelComponentTypeKey.String(observ.ComponentType),
								),
								Value: 2,
							},
						},
					},
				}, sm.Metrics[1], metricdatatest.IgnoreTimestamp())

				metricdatatest.AssertEqual(t, metricdata.Metrics{
					Name:        otelconv.SDKExporterOperationDuration{}.Name(),
					Description: otelconv.SDKExporterOperationDuration{}.Description(),
					Unit:        otelconv.SDKExporterOperationDuration{}.Unit(),
					Data: metricdata.Histogram[float64]{
						Temporality: metricdata.CumulativeTemporality,
						DataPoints: []metricdata.HistogramDataPoint[float64]{
							{
								Attributes: attribute.NewSet(
									semconv.OTelComponentName(observ.ComponentName(0)),
									semconv.OTelComponentTypeKey.String(observ.ComponentType),
								),
							},
						},
					},
				}, sm.Metrics[2], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue())
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if tt.enabled {
				t.Setenv("OTEL_GO_X_OBSERVABILITY", "true")

				// Reset component name counter for each test.
				_ = counter.SetExporterID(0)
			}

			original := otel.GetMeterProvider()
			t.Cleanup(func() { otel.SetMeterProvider(original) })

			r := metric.NewManualReader()
			mp := metric.NewMeterProvider(metric.WithReader(r))
			otel.SetMeterProvider(mp)

			exporter, err := stdouttrace.New(
				stdouttrace.WithWriter(io.Discard))
			require.NoError(t, err)

			tt.callExportSpans(t, exporter)

			var rm metricdata.ResourceMetrics
			require.NoError(t, r.Collect(t.Context(), &rm))

			tt.assertMetrics(t, rm)
		})
	}
}

func BenchmarkExporterExportSpans(b *testing.B) {
	ss := tracetest.SpanStubs{
		{Name: "/foo"},
		{
			Name:       "JSON encoder cannot marshal math.Inf(1)",
			Attributes: []attribute.KeyValue{attribute.Float64("", math.Inf(1))},
		},
		{Name: "/bar"},
	}.Snapshots()

	run := func(b *testing.B) {
		ex, err := stdouttrace.New(stdouttrace.WithWriter(io.Discard))
		if err != nil {
			b.Fatalf("failed to create exporter: %v", err)
		}

		b.ReportAllocs()
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			err = ex.ExportSpans(b.Context(), ss)
		}
		_ = err
	}

	b.Run("Observability", func(b *testing.B) {
		b.Setenv("OTEL_GO_X_OBSERVABILITY", "true")
		run(b)
	})

	b.Run("NoObservability", run)
}
