//go:build otel

// Phase B Step 8 — Real OpenTelemetry-backed implementation. Compiled only
// with `-tags otel`, matching the wazero build-tag pattern used
// elsewhere in the repo. The default build ships telemetry_default.go with a
// no-op provider so sqlite-only binaries do not pull the OTel SDK in.
//
// Exporter modes (config.TelemetryConfig.Exporter):
//
//	"none"       — no-op provider; same as the default build.
//	"prometheus" — pull-based metrics via a /metrics handler, spans are still
//	               processed by a tracer provider with no exporter.
//	"otlp"       — push-based traces over OTLP/gRPC to cfg.OTLPEndpoint AND
//	               pull-based metrics via the Prometheus exporter (operators
//	               typically want both).
//
// The concrete Provider adapts our tiny telemetry.* API onto the upstream
// OTel SDK so the rest of the codebase never depends on the SDK directly.
package telemetry

import (
	"context"
	"errors"
	"fmt"
	"math"
	"net/http"
	"sync"

	"github.com/owncord/server/config"

	promclient "github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	otelprom "go.opentelemetry.io/otel/exporters/prometheus"
	"go.opentelemetry.io/otel/metric"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
	"go.opentelemetry.io/otel/trace"
)

// otelProvider adapts the OTel SDK to the telemetry.Provider interface.
type otelProvider struct {
	cfg         config.TelemetryConfig
	promHandler http.Handler
	tp          *sdktrace.TracerProvider
	mp          *sdkmetric.MeterProvider
	serviceName string
}

// Init wires the OTel SDK according to cfg and installs it as the global
// Provider. The returned ShutdownFunc flushes the batchers and releases
// exporter resources; it is safe to call more than once.
func Init(ctx context.Context, cfg config.TelemetryConfig) (ShutdownFunc, error) {
	if !cfg.Enabled || cfg.Exporter == "" || cfg.Exporter == "none" {
		SetGlobal(noopProvider{})
		return func(context.Context) error { return nil }, nil
	}

	svcName := cfg.ServiceName
	if svcName == "" {
		svcName = "owncord-server"
	}

	res, err := resource.New(ctx,
		resource.WithAttributes(
			semconv.ServiceName(svcName),
		),
	)
	if err != nil {
		return nil, fmt.Errorf("telemetry: resource: %w", err)
	}

	provider := &otelProvider{cfg: cfg, serviceName: svcName}

	// ── Tracing ────────────────────────────────────────────────────────────
	tpOpts := []sdktrace.TracerProviderOption{sdktrace.WithResource(res)}
	if cfg.Exporter == "otlp" {
		endpoint := cfg.OTLPEndpoint
		if endpoint == "" {
			endpoint = "localhost:4317"
		}
		otlpOpts := []otlptracegrpc.Option{
			otlptracegrpc.WithEndpoint(endpoint),
		}
		// OTLPInsecure honours the explicit operator opt-in for plaintext
		// gRPC; production deployments should leave it false and provide
		// a TLS endpoint.
		if cfg.OTLPInsecure {
			otlpOpts = append(otlpOpts, otlptracegrpc.WithInsecure())
		}
		traceExp, err := otlptracegrpc.New(ctx, otlpOpts...)
		if err != nil {
			return nil, fmt.Errorf("telemetry: otlp trace exporter: %w", err)
		}
		tpOpts = append(tpOpts, sdktrace.WithBatcher(traceExp))
	}
	provider.tp = sdktrace.NewTracerProvider(tpOpts...)
	otel.SetTracerProvider(provider.tp)

	// ── Metrics ────────────────────────────────────────────────────────────
	// Both prometheus and otlp modes surface metrics via a pull-based
	// Prometheus endpoint. OTLP tracing does not preclude Prometheus metrics
	// — operators usually want both — so we wire the exporter unconditionally
	// for the two real modes.
	reg := promclient.NewRegistry()
	promExp, err := otelprom.New(otelprom.WithRegisterer(reg))
	if err != nil {
		// Shut the trace provider down so the OTLP exporter's gRPC
		// connection (if any) is torn down. Init returning an error must
		// not leak resources.
		_ = provider.tp.Shutdown(ctx)
		return nil, fmt.Errorf("telemetry: prometheus exporter: %w", err)
	}
	provider.mp = sdkmetric.NewMeterProvider(
		sdkmetric.WithResource(res),
		sdkmetric.WithReader(promExp),
	)
	otel.SetMeterProvider(provider.mp)
	provider.promHandler = promhttp.HandlerFor(reg, promhttp.HandlerOpts{})

	// Drop the cached AppMetrics bundle BEFORE publishing the new global
	// provider. A concurrent NewAppMetrics() caller that observes the old
	// no-op provider and the stale cache is harmless (it just re-populates
	// once); the failure mode we avoid is a caller seeing the new provider
	// but still reading the old cached (no-op) instruments.
	resetAppMetricsForInit()
	SetGlobal(provider)

	var once sync.Once
	return func(shutCtx context.Context) error {
		var rerr error
		once.Do(func() {
			var errs []error
			if provider.tp != nil {
				if err := provider.tp.Shutdown(shutCtx); err != nil {
					errs = append(errs, err)
				}
			}
			if provider.mp != nil {
				if err := provider.mp.Shutdown(shutCtx); err != nil {
					errs = append(errs, err)
				}
			}
			if len(errs) > 0 {
				rerr = fmt.Errorf("telemetry shutdown: %w", errors.Join(errs...))
			}
			SetGlobal(noopProvider{})
		})
		return rerr
	}, nil
}

// Tracer returns an otel-backed tracer for the given instrumentation scope.
func (p *otelProvider) Tracer(name string) Tracer {
	return &otelTracer{inner: p.tp.Tracer(name)}
}

// Meter returns an otel-backed meter for the given instrumentation scope.
func (p *otelProvider) Meter(name string) Meter {
	return &otelMeter{inner: p.mp.Meter(name)}
}

// HTTPMiddleware wraps next with otelhttp so every REST request becomes a
// span named after its route pattern.
func (p *otelProvider) HTTPMiddleware(next http.Handler) http.Handler {
	return otelhttp.NewHandler(next, "http.server",
		otelhttp.WithServerName(p.serviceName),
	)
}

// PrometheusHandler returns the /metrics handler backed by the active
// exporter registry.
func (p *otelProvider) PrometheusHandler() http.Handler { return p.promHandler }

// TraceIDFromContext returns the active trace ID as a hex string, or "" when no
// span is recording in ctx. Used to stamp log records with trace_id so logs
// correlate with traces.
func TraceIDFromContext(ctx context.Context) string {
	if sc := trace.SpanContextFromContext(ctx); sc.HasTraceID() {
		return sc.TraceID().String()
	}
	return ""
}

// ── Tracer / Span adapters ─────────────────────────────────────────────────

type otelTracer struct{ inner trace.Tracer }

func (t *otelTracer) Start(ctx context.Context, name string, attrs ...Attr) (context.Context, Span) {
	ctx, span := t.inner.Start(ctx, name, trace.WithAttributes(convertAttrs(attrs)...))
	return ctx, &otelSpan{inner: span}
}

type otelSpan struct{ inner trace.Span }

func (s *otelSpan) End()                        { s.inner.End() }
func (s *otelSpan) SetAttributes(attrs ...Attr) { s.inner.SetAttributes(convertAttrs(attrs)...) }
func (s *otelSpan) RecordError(err error) {
	if err == nil {
		return
	}
	s.inner.RecordError(err)
}

// ── Meter / instrument adapters ────────────────────────────────────────────

type otelMeter struct{ inner metric.Meter }

func (m *otelMeter) Counter(name, description string) Counter {
	c, err := m.inner.Int64Counter(name, metric.WithDescription(description))
	if err != nil {
		return noopCounter{}
	}
	return &otelCounter{inner: c}
}

func (m *otelMeter) Histogram(name, description, unit string) Histogram {
	opts := []metric.Float64HistogramOption{metric.WithDescription(description)}
	if unit != "" {
		opts = append(opts, metric.WithUnit(unit))
	}
	h, err := m.inner.Float64Histogram(name, opts...)
	if err != nil {
		return noopHistogram{}
	}
	return &otelHistogram{inner: h}
}

func (m *otelMeter) Gauge(name, description string) Gauge {
	g, err := m.inner.Float64Gauge(name, metric.WithDescription(description))
	if err != nil {
		return noopGauge{}
	}
	return &otelGauge{inner: g}
}

type otelCounter struct{ inner metric.Int64Counter }

func (c *otelCounter) Add(ctx context.Context, delta int64, attrs ...Attr) {
	c.inner.Add(ctx, delta, metric.WithAttributes(convertAttrs(attrs)...))
}

type otelHistogram struct{ inner metric.Float64Histogram }

func (h *otelHistogram) Record(ctx context.Context, value float64, attrs ...Attr) {
	h.inner.Record(ctx, value, metric.WithAttributes(convertAttrs(attrs)...))
}

type otelGauge struct{ inner metric.Float64Gauge }

func (g *otelGauge) Set(ctx context.Context, value float64, attrs ...Attr) {
	g.inner.Record(ctx, value, metric.WithAttributes(convertAttrs(attrs)...))
}

// convertAttrs maps telemetry.Attr values to attribute.KeyValue. Unknown
// types are rendered via fmt.Sprint so callers never panic on exotic values.
// The uint64 / uint32 / uint cases matter: sequence numbers and ID fields
// in OwnCord are unsigned, and routing them through the default fmt.Sprint
// path would encode them as string attributes and break metric aggregation.
func convertAttrs(in []Attr) []attribute.KeyValue {
	if len(in) == 0 {
		return nil
	}
	out := make([]attribute.KeyValue, 0, len(in))
	for _, a := range in {
		switch v := a.Value.(type) {
		case string:
			out = append(out, attribute.String(a.Key, v))
		case int:
			out = append(out, attribute.Int(a.Key, v))
		case int32:
			out = append(out, attribute.Int64(a.Key, int64(v)))
		case int64:
			out = append(out, attribute.Int64(a.Key, v))
		case uint:
			out = append(out, attribute.Int64(a.Key, int64(v)))
		case uint32:
			out = append(out, attribute.Int64(a.Key, int64(v)))
		case uint64:
			// Most uint64 values in OwnCord (ids, seqs) fit comfortably
			// within int64 range. A wrapped negative would corrupt
			// metric aggregation, so we fall back to a string for the
			// pathological case rather than silently misreporting.
			if v <= math.MaxInt64 {
				out = append(out, attribute.Int64(a.Key, int64(v)))
			} else {
				out = append(out, attribute.String(a.Key, fmt.Sprint(v)))
			}
		case float32:
			out = append(out, attribute.Float64(a.Key, float64(v)))
		case float64:
			out = append(out, attribute.Float64(a.Key, v))
		case bool:
			out = append(out, attribute.Bool(a.Key, v))
		default:
			out = append(out, attribute.String(a.Key, fmt.Sprint(v)))
		}
	}
	return out
}
