package dedupe

import (
	"context"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"git3.techno-world.net/lrosales/broad-announce/internal/alert"
)

// flushMs used by most tests; small enough to be fast.
const testFlushMs = 50

// mkAlert is a small constructor so the tests stay readable.
func mkAlert(severity, sourceID, key string, count uint32) alert.Alert {
	return alert.Alert{
		ID:          "test-" + sourceID + "-" + key,
		CompanyID:   "co",
		SourceID:    sourceID,
		Severity:    alert.Severity(severity),
		Category:    "test",
		Title:       "t",
		Body:        "b",
		DedupeKey:   key,
		DedupeCount: count,
		ReceivedAt:  time.Now(),
	}
}

// TestObserve_Passthrough_EmptyKey: an empty dedupe_key is
// delivered as Passthrough, the collapser holds nothing.
func TestObserve_Passthrough_EmptyKey(t *testing.T) {
	var pass int32
	c := NewCollapser(testFlushMs*time.Millisecond, func(_, _ string, _ alert.Alert) {
		t.Fatal("onFlush should not be called for empty key")
	})
	c.OnPassthrough = func() { atomic.AddInt32(&pass, 1) }

	for i := 0; i < 5; i++ {
		a := mkAlert("warning", "prom-prod", "", 1)
		if d := c.Observe("prom-prod", "", a); d != Passthrough {
			t.Fatalf("expected Passthrough, got %v", d)
		}
	}
	if got := atomic.LoadInt32(&pass); got != 5 {
		t.Errorf("OnPassthrough fired %d times, want 5", got)
	}
	if c.Pending() != 0 {
		t.Errorf("Pending() = %d, want 0", c.Pending())
	}
}

// TestObserve_Collapse_FirstThenFlush: 100 observes with the
// same (source, key) collapse into 1 flush, dedupe_count=100.
func TestObserve_Collapse_FirstThenFlush(t *testing.T) {
	var (
		mu       sync.Mutex
		flushed  []alert.Alert
		flushSrc []string
		flushKey []string
	)
	c := NewCollapser(testFlushMs*time.Millisecond, func(src, key string, a alert.Alert) {
		mu.Lock()
		flushed = append(flushed, a)
		flushSrc = append(flushSrc, src)
		flushKey = append(flushKey, key)
		mu.Unlock()
	})

	for i := uint32(1); i <= 100; i++ {
		a := mkAlert("warning", "prom-prod", "burst-1", i)
		var d Decision
		if i == 1 {
			d = c.Observe("prom-prod", "burst-1", a)
			if d != CollapseNew {
				t.Fatalf("first Observe: got %v, want CollapseNew", d)
			}
		} else {
			d = c.Observe("prom-prod", "burst-1", a)
			if d != CollapseDupe {
				t.Fatalf("Observe #%d: got %v, want CollapseDupe", i, d)
			}
		}
	}
	if c.Pending() != 1 {
		t.Errorf("Pending() during burst = %d, want 1", c.Pending())
	}

	runCtx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() { c.Run(runCtx); close(done) }()

	// Wait up to 1s for the flush. With testFlushMs=50, it
	// should fire on the next tick (≤25ms).
	deadline := time.Now().Add(1 * time.Second)
	for time.Now().Before(deadline) {
		mu.Lock()
		n := len(flushed)
		mu.Unlock()
		if n > 0 {
			break
		}
		time.Sleep(2 * time.Millisecond)
	}

	cancel()
	<-done
	c.FlushAll()

	mu.Lock()
	defer mu.Unlock()
	if len(flushed) != 1 {
		t.Fatalf("flushed %d alerts, want 1", len(flushed))
	}
	if flushed[0].DedupeCount != 100 {
		t.Errorf("flushed dedupe_count = %d, want 100", flushed[0].DedupeCount)
	}
	if flushSrc[0] != "prom-prod" || flushKey[0] != "burst-1" {
		t.Errorf("flushed (%q, %q), want (prom-prod, burst-1)", flushSrc[0], flushKey[0])
	}
}

// TestObserve_PerSourceIsolation: same key from two sources
// produces two flushes.
func TestObserve_PerSourceIsolation(t *testing.T) {
	var mu sync.Mutex
	flushed := make(map[string]alert.Alert) // key = "source|dedupeKey"
	c := NewCollapser(testFlushMs*time.Millisecond, func(src, key string, a alert.Alert) {
		mu.Lock()
		flushed[src+"|"+key] = a
		mu.Unlock()
	})

	for i := uint32(1); i <= 5; i++ {
		c.Observe("prom-prod", "shared", mkAlert("warning", "prom-prod", "shared", i))
		c.Observe("grafana", "shared", mkAlert("warning", "grafana", "shared", i))
	}
	if c.Pending() != 2 {
		t.Errorf("Pending() = %d, want 2", c.Pending())
	}

	runCtx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() { c.Run(runCtx); close(done) }()

	// Wait for 2 flushes.
	deadline := time.Now().Add(1 * time.Second)
	for time.Now().Before(deadline) {
		mu.Lock()
		n := len(flushed)
		mu.Unlock()
		if n == 2 {
			break
		}
		time.Sleep(2 * time.Millisecond)
	}
	cancel()
	<-done
	c.FlushAll()

	mu.Lock()
	defer mu.Unlock()
	if len(flushed) != 2 {
		t.Fatalf("flushed %d, want 2", len(flushed))
	}
	if flushed["prom-prod|shared"].DedupeCount != 5 {
		t.Errorf("prom-prod count = %d, want 5", flushed["prom-prod|shared"].DedupeCount)
	}
	if flushed["grafana|shared"].DedupeCount != 5 {
		t.Errorf("grafana count = %d, want 5", flushed["grafana|shared"].DedupeCount)
	}
}

// TestRun_MaxWaitReFlush: a continuous stream re-flushes every
// max-wait, each flush carrying the running count.
func TestRun_MaxWaitReFlush(t *testing.T) {
	flushMs := 30
	var (
		mu      sync.Mutex
		flushed []uint32
	)
	c := NewCollapser(time.Duration(flushMs)*time.Millisecond, func(_, _ string, a alert.Alert) {
		mu.Lock()
		flushed = append(flushed, a.DedupeCount)
		mu.Unlock()
	})

	runCtx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() { c.Run(runCtx); close(done) }()

	// Send 90 alerts over ~100ms (1ms each). With flushMs=30ms
	// the first flush should fire around 30ms in. After the
	// flush, the (source, key) is removed from pending, so the
	// next Observe starts a new collapse window. Over 100ms we
	// should see 3+ flushes.
	for i := uint32(1); i <= 90; i++ {
		c.Observe("prom-prod", "continuous", mkAlert("warning", "prom-prod", "continuous", i))
		time.Sleep(1 * time.Millisecond)
	}

	// Wait a tick for any final flush.
	time.Sleep(2 * time.Duration(flushMs) * time.Millisecond)
	cancel()
	<-done
	c.FlushAll()

	mu.Lock()
	defer mu.Unlock()
	if len(flushed) < 2 {
		t.Errorf("got %d flushes, want ≥2 (continuous burst should re-flush)", len(flushed))
	}
	// Each flush count must be ≥1 and ≤90.
	for i, n := range flushed {
		if n < 1 || n > 90 {
			t.Errorf("flushed[%d] = %d, want 1..90", i, n)
		}
	}
	// Counts should be monotonically non-decreasing per window
	// (each new collapse window starts fresh; counts are local
	// to that window, so this is a weak assertion — just check
	// the last is > 0).
	if flushed[len(flushed)-1] == 0 {
		t.Errorf("final flush had count 0")
	}
}

// TestObserve_EmptyKeyDoesNotBlockRealKey: an empty-key alert
// during a non-empty-key burst doesn't reset the burst's timer.
func TestObserve_EmptyKeyDoesNotBlockRealKey(t *testing.T) {
	var mu sync.Mutex
	flushed := 0
	c := NewCollapser(testFlushMs*time.Millisecond, func(_, _ string, _ alert.Alert) {
		mu.Lock()
		flushed++
		mu.Unlock()
	})

	// Real key burst.
	c.Observe("prom-prod", "real", mkAlert("warning", "prom-prod", "real", 1))
	// Empty keys interspersed.
	for i := 0; i < 5; i++ {
		c.Observe("prom-prod", "", mkAlert("warning", "prom-prod", "", 1))
	}
	if c.Pending() != 1 {
		t.Errorf("Pending() = %d, want 1", c.Pending())
	}

	runCtx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() { c.Run(runCtx); close(done) }()
	time.Sleep(2 * testFlushMs * time.Millisecond)
	cancel()
	<-done
	c.FlushAll()

	mu.Lock()
	defer mu.Unlock()
	if flushed != 1 {
		t.Errorf("flushed = %d, want 1 (empty keys must not interfere)", flushed)
	}
}

// TestObserve_ConcurrentSameKey: 8 goroutines all hitting the
// same (source, key) — final dedupe_count is exactly the max
// observed.
func TestObserve_ConcurrentSameKey(t *testing.T) {
	var mu sync.Mutex
	flushed := alert.Alert{}
	c := NewCollapser(testFlushMs*time.Millisecond, func(_, _ string, a alert.Alert) {
		mu.Lock()
		flushed = a
		mu.Unlock()
	})

	const goroutines = 8
	const perG = 500
	var wg sync.WaitGroup
	wg.Add(goroutines)
	counter := uint32(0)
	for g := 0; g < goroutines; g++ {
		go func() {
			defer wg.Done()
			for i := 0; i < perG; i++ {
				n := atomic.AddUint32(&counter, 1)
				c.Observe("prom-prod", "concurrent", mkAlert("warning", "prom-prod", "concurrent", n))
			}
		}()
	}
	wg.Wait()

	runCtx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() { c.Run(runCtx); close(done) }()

	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		mu.Lock()
		ok := flushed.DedupeCount > 0
		mu.Unlock()
		if ok {
			break
		}
		time.Sleep(2 * time.Millisecond)
	}
	cancel()
	<-done
	c.FlushAll()

	mu.Lock()
	defer mu.Unlock()
	want := uint32(goroutines * perG)
	if flushed.DedupeCount != want {
		t.Errorf("flushed dedupe_count = %d, want %d", flushed.DedupeCount, want)
	}
}

// TestFlushAll_DrainsPending: FlushAll publishes every held
// entry immediately, regardless of timer state.
func TestFlushAll_DrainsPending(t *testing.T) {
	var mu sync.Mutex
	flushed := []string{}
	c := NewCollapser(10*time.Second, func(src, key string, _ alert.Alert) {
		mu.Lock()
		flushed = append(flushed, src+"|"+key)
		mu.Unlock()
	})
	c.Observe("s1", "k1", mkAlert("warning", "s1", "k1", 1))
	c.Observe("s2", "k2", mkAlert("warning", "s2", "k2", 1))
	if c.Pending() != 2 {
		t.Fatalf("Pending() = %d, want 2", c.Pending())
	}
	c.FlushAll()
	if c.Pending() != 0 {
		t.Errorf("Pending() after FlushAll = %d, want 0", c.Pending())
	}
	mu.Lock()
	defer mu.Unlock()
	if len(flushed) != 2 {
		t.Errorf("flushed %d, want 2", len(flushed))
	}
}
