package main

// metricsSampler maintains a rolling, in-memory time-series of cluster-wide
// totals — players online, summed CPU%, and running-instance count — so the
// dashboard's overview sparklines have history that survives a browser
// reload. There is no DB persistence (a controller restart resets it); the
// goal is "the last hour of trend", not durable metrics.
//
// How it works: a single long-lived subscription to the event bus folds
// every AppStateUpdate / InstanceStatsUpdate / InstanceStateUpdate into a
// per-instance latest-value map. A ticker snapshots the cluster totals from
// that map into a ring buffer every `sampleInterval`. The same data the
// per-card sparklines accumulate live, but computed controller-side so it's
// shared across all viewers and isn't lost on reload.

import (
	"context"
	"encoding/json"
	"net/http"
	"sync"
	"time"

	"github.com/dbledeez/panel/controller/internal/events"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

const (
	// 30s × 120 = the last hour of cluster history.
	metricsSampleInterval = 30 * time.Second
	metricsRingCap        = 120
)

// metricSample is one point in the cluster time-series.
type metricSample struct {
	T       int64 `json:"t"`       // unix seconds
	Players int   `json:"players"` // total players online across running instances
	CPU     int   `json:"cpu"`     // summed cpu_percent across running instances (rounded)
	Running int   `json:"running"` // count of running instances
}

// instLive holds the latest per-instance live values the sampler folds.
type instLive struct {
	players int
	cpu     float64
	running bool
	lastApp time.Time
}

type metricsSampler struct {
	bus *events.Bus

	mu    sync.Mutex
	live  map[string]*instLive // instance_id -> latest live values
	ring  []metricSample       // ring buffer, oldest-first after fill
	start int                  // ring head index
	size  int                  // number of valid entries (<= cap)
}

func newMetricsSampler(bus *events.Bus) *metricsSampler {
	return &metricsSampler{
		bus:  bus,
		live: make(map[string]*instLive),
		ring: make([]metricSample, metricsRingCap),
	}
}

// run blocks until ctx is cancelled. Fold bus events into per-instance state;
// snapshot cluster totals into the ring every interval. Start it in a
// goroutine.
func (m *metricsSampler) run(ctx context.Context) {
	if m == nil || m.bus == nil {
		return
	}
	// fold() only consumes AppState/InstanceStats/InstanceState — exclude
	// log events at the bus so a boot's log storm (thousands of lines/s)
	// doesn't churn this subscriber's buffer for nothing.
	sub := m.bus.Subscribe(events.Filter{ExcludeLogs: true})
	defer sub.Close()
	tick := time.NewTicker(metricsSampleInterval)
	defer tick.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case ev, ok := <-sub.Events():
			if !ok {
				return
			}
			m.fold(ev)
		case <-tick.C:
			m.snapshot(time.Now())
		}
	}
}

func (m *metricsSampler) get(id string) *instLive {
	v := m.live[id]
	if v == nil {
		v = &instLive{}
		m.live[id] = v
	}
	return v
}

// fold updates the per-instance latest values from a single event.
func (m *metricsSampler) fold(ev *panelv1.Event) {
	m.mu.Lock()
	defer m.mu.Unlock()
	switch p := ev.Payload.(type) {
	case *panelv1.Event_AppState:
		v := m.get(p.AppState.InstanceId)
		v.players = int(p.AppState.PlayersOnline)
		v.lastApp = time.Now()
	case *panelv1.Event_InstanceStats:
		v := m.get(p.InstanceStats.InstanceId)
		v.cpu = p.InstanceStats.CpuPercent
	case *panelv1.Event_InstanceState:
		v := m.get(p.InstanceState.InstanceId)
		running := p.InstanceState.Status == panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING
		v.running = running
		if !running {
			// A stopped/crashed/deleted instance contributes nothing — zero
			// its live counters so a stale sample doesn't keep summing in.
			v.players = 0
			v.cpu = 0
			if p.InstanceState.Detail == "deleted" {
				delete(m.live, p.InstanceState.InstanceId)
			}
		}
	}
}

// snapshot computes cluster totals from the live map and appends to the ring.
func (m *metricsSampler) snapshot(now time.Time) {
	m.mu.Lock()
	defer m.mu.Unlock()
	var players, running int
	var cpu float64
	for _, v := range m.live {
		if !v.running {
			continue
		}
		running++
		players += v.players
		cpu += v.cpu
	}
	s := metricSample{T: now.Unix(), Players: players, CPU: int(cpu + 0.5), Running: running}
	idx := (m.start + m.size) % metricsRingCap
	if m.size < metricsRingCap {
		m.ring[idx] = s
		m.size++
	} else {
		// Full: overwrite oldest, advance head.
		m.ring[m.start] = s
		m.start = (m.start + 1) % metricsRingCap
	}
}

// history returns the samples oldest-first.
func (m *metricsSampler) history() []metricSample {
	if m == nil {
		return nil
	}
	m.mu.Lock()
	defer m.mu.Unlock()
	out := make([]metricSample, 0, m.size)
	for i := 0; i < m.size; i++ {
		out = append(out, m.ring[(m.start+i)%metricsRingCap])
	}
	return out
}

// handleMetricsHistory serves the rolling cluster time-series as JSON. The
// dashboard seeds its overview sparklines from this on load so trends survive
// a page reload.
func (h *httpServer) handleMetricsHistory(w http.ResponseWriter, r *http.Request) {
	hist := []metricSample{}
	if h.metrics != nil {
		hist = h.metrics.history()
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"interval_seconds": int(metricsSampleInterval / time.Second),
		"samples":          hist,
	})
}
