// Package langfuse is a minimal Go-side client for the Langfuse v2
// ingestion API. Mirrors the surface area we need from the Rust
// crates/gateway/src/v1/langfuse_trace.rs emitter — Trace + Span,
// nothing else yet (no scores, no observations, no datasets).
//
// Auth is Basic over public_key:secret_key. URL + creds come from
// /etc/lakehouse/langfuse.env in production; tests can pass any URL.
//
// Best-effort transport: errors are logged but don't fail the calling
// path. Lakehouse's internal services should never go down because
// Langfuse is unreachable.
package langfuse

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"sync"
	"time"
)

// Client posts traces + spans to Langfuse's ingestion endpoint.
// Events are buffered and flushed in batches. Always call Flush
// before exit; Close also flushes.
type Client struct {
	url       string
	auth      string // pre-encoded "Basic ..."
	hc        *http.Client
	mu        sync.Mutex
	pending   []event
	maxBatch  int
}

// New constructs a Client. URL like "http://localhost:3001"; creds
// from langfuse.env. nil hc → uses default with 5s timeout.
func New(url, publicKey, secretKey string, hc *http.Client) *Client {
	if hc == nil {
		hc = &http.Client{Timeout: 5 * time.Second}
	}
	auth := "Basic " + base64.StdEncoding.EncodeToString([]byte(publicKey+":"+secretKey))
	return &Client{
		url:      url,
		auth:     auth,
		hc:       hc,
		maxBatch: 50,
	}
}

// NewID returns a hex string suitable as a trace/span id. Langfuse
// accepts arbitrary strings; a 16-byte random hex is unambiguous.
func NewID() string {
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	return hex.EncodeToString(b)
}

// event is one Langfuse ingestion envelope. Body shape varies by
// type (trace-create vs span-create); we use map[string]any to
// keep the wire shape declarative.
type event struct {
	ID        string         `json:"id"`
	Type      string         `json:"type"` // "trace-create" | "span-create"
	Timestamp string         `json:"timestamp"`
	Body      map[string]any `json:"body"`
}

// TraceInput is what callers fill in when starting a trace.
type TraceInput struct {
	Name     string
	UserID   string
	Input    any
	Metadata map[string]any
	Tags     []string
}

// Trace records a top-level trace. Returns the trace id so callers
// can attach spans. Best-effort: errors are logged and the trace
// id is still returned so callers don't need error-handling for the
// common case.
func (c *Client) Trace(ctx context.Context, t TraceInput) string {
	id := NewID()
	body := map[string]any{
		"id":   id,
		"name": t.Name,
	}
	if t.UserID != "" {
		body["userId"] = t.UserID
	}
	if t.Input != nil {
		body["input"] = t.Input
	}
	if t.Metadata != nil {
		body["metadata"] = t.Metadata
	}
	if len(t.Tags) > 0 {
		body["tags"] = t.Tags
	}
	c.queue(event{
		ID:        NewID(),
		Type:      "trace-create",
		Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
		Body:      body,
	})
	return id
}

// SpanInput is what callers fill in when recording a span.
type SpanInput struct {
	TraceID    string
	ParentID   string // optional — for nested spans
	Name       string
	Input      any
	Output     any
	Metadata   map[string]any
	StartTime  time.Time
	EndTime    time.Time
	StatusCode int    // 0 = success, anything else = error code
	Level      string // "DEBUG" | "DEFAULT" | "WARNING" | "ERROR"
}

// Span records one span attached to a trace. Returns the span id.
func (c *Client) Span(ctx context.Context, s SpanInput) string {
	id := NewID()
	body := map[string]any{
		"id":      id,
		"traceId": s.TraceID,
		"name":    s.Name,
	}
	if s.ParentID != "" {
		body["parentObservationId"] = s.ParentID
	}
	if s.Input != nil {
		body["input"] = s.Input
	}
	if s.Output != nil {
		body["output"] = s.Output
	}
	if s.Metadata != nil {
		body["metadata"] = s.Metadata
	}
	if !s.StartTime.IsZero() {
		body["startTime"] = s.StartTime.UTC().Format(time.RFC3339Nano)
	}
	if !s.EndTime.IsZero() {
		body["endTime"] = s.EndTime.UTC().Format(time.RFC3339Nano)
	}
	if s.Level != "" {
		body["level"] = s.Level
	}
	if s.StatusCode != 0 {
		body["statusMessage"] = fmt.Sprintf("status_code=%d", s.StatusCode)
	}
	c.queue(event{
		ID:        NewID(),
		Type:      "span-create",
		Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
		Body:      body,
	})
	return id
}

func (c *Client) queue(e event) {
	c.mu.Lock()
	c.pending = append(c.pending, e)
	shouldFlush := len(c.pending) >= c.maxBatch
	c.mu.Unlock()
	if shouldFlush {
		// Fire-and-forget so the calling goroutine isn't blocked by
		// the 5s HTTP timeout when Langfuse hiccups. Per scrum (Opus
		// WARN): synchronous flush from queue defeated the
		// "best-effort, never blocks calling path" guarantee in the
		// package doc.
		go func() { _ = c.Flush(context.Background()) }()
	}
}

// Flush sends all queued events in one batch. Best-effort: returns
// the error but also logs; callers can ignore.
func (c *Client) Flush(ctx context.Context) error {
	c.mu.Lock()
	if len(c.pending) == 0 {
		c.mu.Unlock()
		return nil
	}
	batch := c.pending
	c.pending = nil
	c.mu.Unlock()

	body, err := json.Marshal(map[string]any{"batch": batch})
	if err != nil {
		slog.Warn("langfuse: marshal batch", "err", err, "n", len(batch))
		return err
	}
	req, err := http.NewRequestWithContext(ctx, "POST", c.url+"/api/public/ingestion", bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", c.auth)
	req.Header.Set("Content-Type", "application/json")
	resp, err := c.hc.Do(req)
	if err != nil {
		slog.Warn("langfuse: post", "err", err, "n", len(batch))
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 && resp.StatusCode != 207 {
		slog.Warn("langfuse: non-2xx", "status", resp.StatusCode, "n", len(batch))
		return fmt.Errorf("langfuse ingestion: HTTP %d", resp.StatusCode)
	}
	return nil
}

// Close flushes any remaining events. Idempotent.
func (c *Client) Close() error {
	return c.Flush(context.Background())
}
