// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package metricstore

import (
	"math"
	"sync"
	"testing"

	"github.com/ClusterCockpit/cc-lib/v2/schema"
)

func TestNewBufferStatsInitialized(t *testing.T) {
	b := newBuffer(100, 10)
	if b.statSamples != 0 {
		t.Errorf("statSamples = %d, want 0", b.statSamples)
	}
	if b.statSum != 0 {
		t.Errorf("statSum = %v, want 0", b.statSum)
	}
	if b.statMin != math.MaxFloat32 {
		t.Errorf("statMin = %v, want MaxFloat32 sentinel", b.statMin)
	}
	if b.statMax != -math.MaxFloat32 {
		t.Errorf("statMax = %v, want -MaxFloat32 sentinel", b.statMax)
	}
	if !b.statsValid {
		t.Error("statsValid = false, want true for a fresh empty buffer")
	}
}

func TestWriteUpdatesRunningStats(t *testing.T) {
	b := newBuffer(100, 10)
	b.write(100, schema.Float(3.0))
	b.write(110, schema.Float(1.0))
	b.write(120, schema.Float(5.0))

	if b.statSamples != 3 {
		t.Errorf("statSamples = %d, want 3", b.statSamples)
	}
	if b.statSum != 9.0 {
		t.Errorf("statSum = %v, want 9.0", b.statSum)
	}
	if b.statMin != 1.0 {
		t.Errorf("statMin = %v, want 1.0", b.statMin)
	}
	if b.statMax != 5.0 {
		t.Errorf("statMax = %v, want 5.0", b.statMax)
	}
	if !b.statsValid {
		t.Error("statsValid = false, want true after appends only")
	}
}

func TestWriteNaNExcludedFromStats(t *testing.T) {
	b := newBuffer(100, 10)
	b.write(100, schema.Float(2.0))
	b.write(110, schema.NaN)        // explicit NaN
	b.write(130, schema.Float(4.0)) // leaves a NaN-filled gap at t=120

	if b.statSamples != 2 {
		t.Errorf("statSamples = %d, want 2 (NaN and gap excluded)", b.statSamples)
	}
	if b.statSum != 6.0 {
		t.Errorf("statSum = %v, want 6.0", b.statSum)
	}
	if b.statMin != 2.0 || b.statMax != 4.0 {
		t.Errorf("statMin/statMax = %v/%v, want 2.0/4.0", b.statMin, b.statMax)
	}
}

func TestWriteOverwriteInvalidatesStats(t *testing.T) {
	b := newBuffer(100, 10)
	b.write(100, schema.Float(1.0))
	b.write(110, schema.Float(2.0))
	if !b.statsValid {
		t.Fatal("precondition: statsValid should be true after appends")
	}

	b.write(100, schema.Float(99.0)) // overwrite existing index
	if b.statsValid {
		t.Error("statsValid = true, want false after overwrite")
	}
}

func TestRecomputeStatsRebuildsAfterOverwrite(t *testing.T) {
	b := newBuffer(100, 10)
	b.write(100, schema.Float(1.0))
	b.write(110, schema.Float(2.0))
	b.write(100, schema.Float(99.0)) // overwrite -> statsValid false, min stale

	b.recomputeStats()

	if !b.statsValid {
		t.Error("statsValid = false, want true after recompute")
	}
	if b.statSamples != 2 {
		t.Errorf("statSamples = %d, want 2", b.statSamples)
	}
	if b.statSum != 101.0 {
		t.Errorf("statSum = %v, want 101.0", b.statSum)
	}
	if b.statMin != 2.0 {
		t.Errorf("statMin = %v, want 2.0 (1.0 was overwritten)", b.statMin)
	}
	if b.statMax != 99.0 {
		t.Errorf("statMax = %v, want 99.0", b.statMax)
	}
}

func TestRecomputeStatsEmptyBuffer(t *testing.T) {
	b := newBuffer(100, 10)
	b.recomputeStats()
	if b.statSamples != 0 || b.statSum != 0 {
		t.Errorf("empty buffer stats = samples %d sum %v, want 0/0", b.statSamples, b.statSum)
	}
	if b.statMin != math.MaxFloat32 || b.statMax != -math.MaxFloat32 {
		t.Errorf("empty buffer min/max = %v/%v, want sentinels", b.statMin, b.statMax)
	}
}

func TestStatsFullBufferMatchesScan(t *testing.T) {
	b := newBuffer(100, 10)
	b.write(100, schema.Float(3.0))
	b.write(110, schema.Float(1.0))
	b.write(120, schema.Float(5.0))
	// Range fully covers the buffer (from before firstWrite, to at/after end).
	s, _, _, err := b.stats(100, 130)
	if err != nil {
		t.Fatalf("stats() error = %v", err)
	}
	if s.Samples != 3 {
		t.Errorf("Samples = %d, want 3", s.Samples)
	}
	if s.Min != 1.0 || s.Max != 5.0 {
		t.Errorf("Min/Max = %v/%v, want 1.0/5.0", s.Min, s.Max)
	}
	if s.Avg != 3.0 {
		t.Errorf("Avg = %v, want 3.0", s.Avg)
	}
}

func TestStatsPartialRangeScans(t *testing.T) {
	b := newBuffer(100, 10)
	b.write(100, schema.Float(3.0)) // t=100
	b.write(110, schema.Float(1.0)) // t=110
	b.write(120, schema.Float(5.0)) // t=120
	// Partial range: only t=110 and t=120 (from excludes t=100).
	s, _, _, err := b.stats(110, 130)
	if err != nil {
		t.Fatalf("stats() error = %v", err)
	}
	if s.Samples != 2 {
		t.Errorf("Samples = %d, want 2", s.Samples)
	}
	if s.Min != 1.0 || s.Max != 5.0 {
		t.Errorf("Min/Max = %v/%v, want 1.0/5.0", s.Min, s.Max)
	}
}

func TestStatsOverwriteThenFullQuery(t *testing.T) {
	b := newBuffer(100, 10)
	b.write(100, schema.Float(1.0))
	b.write(110, schema.Float(2.0))
	b.write(120, schema.Float(3.0))
	b.write(100, schema.Float(99.0)) // overwrite the running min -> statsValid false

	s, _, _, err := b.stats(100, 130)
	if err != nil {
		t.Fatalf("stats() error = %v", err)
	}
	if s.Min != 2.0 {
		t.Errorf("Min = %v, want 2.0 (scanned after overwrite)", s.Min)
	}
	if s.Max != 99.0 {
		t.Errorf("Max = %v, want 99.0", s.Max)
	}
	if s.Samples != 3 {
		t.Errorf("Samples = %d, want 3", s.Samples)
	}
	if b.statsValid {
		t.Error("statsValid should still be false: the read path must not mutate buffer state")
	}
}

func TestStatsMultiBufferChain(t *testing.T) {
	// Two full buffers of cap=3 (interior buffers are always full).
	// b1: t=100,110,120 = 1,2,3 ; b2: t=130,140,150 = 4,5,6.
	b1 := &buffer{data: make([]schema.Float, 0, 3), frequency: 10, start: 95}
	b1.data = append(b1.data, 1.0, 2.0, 3.0)
	b2 := &buffer{data: make([]schema.Float, 0, 3), frequency: 10, start: 125}
	b2.data = append(b2.data, 4.0, 5.0, 6.0)
	b2.prev = b1
	b1.next = b2
	// b1/b2 built bare: statsValid is false (zero value). Recompute here to mirror
	// checkpoint-loaded valid buffers, so the fast-path cache fold is exercised.
	b1.recomputeStats()
	b2.recomputeStats()

	s, _, _, err := b2.stats(100, 160)
	if err != nil {
		t.Fatalf("stats() error = %v", err)
	}
	if s.Samples != 6 {
		t.Errorf("Samples = %d, want 6", s.Samples)
	}
	if s.Min != 1.0 || s.Max != 6.0 {
		t.Errorf("Min/Max = %v/%v, want 1.0/6.0", s.Min, s.Max)
	}
	if s.Avg != 3.5 {
		t.Errorf("Avg = %v, want 3.5", s.Avg)
	}
}

func TestStatsFastPathThenPartialTail(t *testing.T) {
	// Regression test: three buffers (all frequency 10) where the query fast-paths
	// earlier fully-covered buffers, then only PARTIALLY covers the last buffer.
	// firstWrite = start + frequency/2, so with start=95 the first value is at t=100.
	// b1: 1,2,3 at t=100,110,120 (end=130, fully in [100,170) -> fast path folds all 3)
	// b2: 4,5,6 at t=130,140,150 (end=160, fully in [100,170) -> fast path folds all 3)
	// b3: 7,8,9 at t=160,170,180 (end=190; not fully covered -> scans; only t=160 < 170)
	// Query [100, 170) includes t=100,110,120,130,140,150,160 = values 1..7 (7 points).
	// t=170 is excluded since 170 >= to. Expected: Samples=7, Sum=28, Min=1, Max=7, Avg=4.0.
	// Buffers built bare: statsValid is false (zero value). Recompute on b1/b2 to mirror
	// checkpoint-loaded valid buffers so the fast path fires for them; b3 is only
	// partially covered by the query so it still scans regardless of validity,
	// giving intended mixed fast-path-then-scan coverage.

	b1 := &buffer{data: make([]schema.Float, 0, 3), frequency: 10, start: 95}
	b1.data = append(b1.data, 1.0, 2.0, 3.0)

	b2 := &buffer{data: make([]schema.Float, 0, 3), frequency: 10, start: 125}
	b2.data = append(b2.data, 4.0, 5.0, 6.0)

	b3 := &buffer{data: make([]schema.Float, 0, 3), frequency: 10, start: 155}
	b3.data = append(b3.data, 7.0, 8.0, 9.0)

	b1.next = b2
	b2.prev = b1
	b2.next = b3
	b3.prev = b2

	b1.recomputeStats()
	b2.recomputeStats()
	b3.recomputeStats()

	s, _, _, err := b3.stats(100, 170)
	if err != nil {
		t.Fatalf("stats() error = %v", err)
	}
	if s.Samples != 7 {
		t.Errorf("Samples = %d, want 7", s.Samples)
	}
	if s.Min != 1.0 || s.Max != 7.0 {
		t.Errorf("Min/Max = %v/%v, want 1.0/7.0", s.Min, s.Max)
	}
	if s.Avg != 4.0 {
		t.Errorf("Avg = %v, want 4.0", s.Avg)
	}
}

func TestCheckpointLoadedBufferStatsEager(t *testing.T) {
	// Mirror how loadFile constructs a buffer: bare struct, data assigned
	// directly with cap==len, then stats computed eagerly.
	data := []schema.Float{2.0, 4.0, 6.0}
	n := len(data)
	b := &buffer{
		frequency: 10,
		start:     95,
		data:      data[0:n:n],
		archived:  true,
	}
	b.recomputeStats() // the eager call loadFile will perform

	if !b.statsValid {
		t.Error("statsValid = false, want true after eager recompute at load")
	}
	if b.statSamples != 3 {
		t.Errorf("statSamples = %d, want 3", b.statSamples)
	}
	if b.statMin != 2.0 || b.statMax != 6.0 {
		t.Errorf("statMin/statMax = %v/%v, want 2.0/6.0", b.statMin, b.statMax)
	}
	if b.statSum != 12.0 {
		t.Errorf("statSum = %v, want 12.0", b.statSum)
	}
}

// TestStatsConcurrentQueriesNoRace guards against the data race fixed by making
// the stats() read path non-mutating: previously an invalid buffer's stats()
// call would run recomputeStats() (a write) under only the caller's shared
// RLock (see MemoryStore.Stats -> Level.findBuffers), so concurrent readers on
// the same buffer could race on statSum/statSamples/statMin/statMax/statsValid.
// Run with -race; both subtests must be race-clean.
func TestStatsConcurrentQueriesNoRace(t *testing.T) {
	const goroutines = 50

	t.Run("valid buffer", func(t *testing.T) {
		b := newBuffer(100, 10)
		vals := []schema.Float{3.0, 1.0, 5.0, 2.0, 4.0}
		for i, v := range vals {
			ts := int64(100 + i*10)
			if _, err := b.write(ts, v); err != nil {
				t.Fatalf("write(%d) error = %v", ts, err)
			}
		}
		if !b.statsValid {
			t.Fatal("precondition: statsValid should be true for an append-only buffer")
		}

		from, to := int64(100), int64(150)
		expected, _, _, err := b.stats(from, to)
		if err != nil {
			t.Fatalf("stats() error = %v", err)
		}

		var wg sync.WaitGroup
		for i := 0; i < goroutines; i++ {
			wg.Add(1)
			go func() {
				defer wg.Done()
				s, _, _, err := b.stats(from, to)
				if err != nil {
					t.Errorf("stats() error = %v", err)
					return
				}
				if s != expected {
					t.Errorf("stats() = %+v, want %+v", s, expected)
				}
			}()
		}
		wg.Wait()
	})

	t.Run("invalid buffer after overwrite", func(t *testing.T) {
		b := newBuffer(100, 10)
		if _, err := b.write(100, schema.Float(1.0)); err != nil {
			t.Fatalf("write error = %v", err)
		}
		if _, err := b.write(110, schema.Float(2.0)); err != nil {
			t.Fatalf("write error = %v", err)
		}
		if _, err := b.write(120, schema.Float(3.0)); err != nil {
			t.Fatalf("write error = %v", err)
		}
		if _, err := b.write(100, schema.Float(99.0)); err != nil { // overwrite -> statsValid false
			t.Fatalf("write error = %v", err)
		}
		if b.statsValid {
			t.Fatal("precondition: statsValid should be false after overwrite")
		}

		from, to := int64(100), int64(130)
		expected, _, _, err := b.stats(from, to)
		if err != nil {
			t.Fatalf("stats() error = %v", err)
		}

		var wg sync.WaitGroup
		for i := 0; i < goroutines; i++ {
			wg.Add(1)
			go func() {
				defer wg.Done()
				s, _, _, err := b.stats(from, to)
				if err != nil {
					t.Errorf("stats() error = %v", err)
					return
				}
				if s != expected {
					t.Errorf("stats() = %+v, want %+v", s, expected)
				}
			}()
		}
		wg.Wait()
	})
}

// TestStatsGappedChain covers a multi-buffer chain with a real time gap between
// buffers: b1's last real point is at t=120, and b2's first real point is at
// t=170, so t=130,140,150,160 have no data anywhere (unlike an in-buffer,
// NaN-filled gap). The query spans the gap; only the real points on either
// side should be counted.
func TestStatsGappedChain(t *testing.T) {
	b1 := &buffer{data: make([]schema.Float, 0, 3), frequency: 10, start: 95}
	b1.data = append(b1.data, 1.0, 2.0, 3.0) // t=100,110,120 (end=130)

	b2 := &buffer{data: make([]schema.Float, 0, 3), frequency: 10, start: 165}
	b2.data = append(b2.data, 4.0, 5.0, 6.0) // t=170,180,190 (end=200)

	b1.next = b2
	b2.prev = b1

	b1.recomputeStats()
	b2.recomputeStats()

	s, _, _, err := b2.stats(100, 200)
	if err != nil {
		t.Fatalf("stats() error = %v", err)
	}
	if s.Samples != 6 {
		t.Errorf("Samples = %d, want 6 (gap contributes no samples)", s.Samples)
	}
	if s.Min != 1.0 || s.Max != 6.0 {
		t.Errorf("Min/Max = %v/%v, want 1.0/6.0", s.Min, s.Max)
	}
	if s.Avg != 3.5 {
		t.Errorf("Avg = %v, want 3.5", s.Avg)
	}
}
