package main

import (
	"sync"
	"testing"
)

// TestSingleFlightClaim documents the per-(room,thread) single-flight invariant the
// async refactor relies on: at most one generation per conversation at a time, the claim
// is independent per (room,thread), and a release re-arms only that conversation.
// handleEvent takes this claim synchronously in transaction order, so the FIRST message
// for a conversation wins and later ones are dropped until release (never the reverse).
func TestSingleFlightClaim(t *testing.T) {
	b := &Bot{inflight: make(map[string]map[string]bool)}

	if !b.tryClaim("!a", "") {
		t.Fatal("first claim on (!a, main) should win")
	}
	if b.tryClaim("!a", "") {
		t.Fatal("second claim on (!a, main) must fail while in flight")
	}
	// A DIFFERENT thread in the SAME room must claim independently — the whole point of
	// per-(room,thread) single-flight: a slow answer in one conversation cannot block
	// another conversation in the same room.
	if !b.tryClaim("!a", "$root1") {
		t.Fatal("a different thread in the same room must claim independently")
	}
	if b.tryClaim("!a", "$root1") {
		t.Fatal("second claim on (!a, $root1) must fail while in flight")
	}
	if !b.tryClaim("!b", "") {
		t.Fatal("a different room must claim independently")
	}
	b.release("!a", "")
	if !b.tryClaim("!a", "") {
		t.Fatal("after release (!a, main) must be claimable again")
	}
	// Releasing the main timeline must NOT free the thread's claim.
	if b.tryClaim("!a", "$root1") {
		t.Fatal("releasing (!a, main) must not free (!a, $root1)")
	}
}

// TestSingleFlightClaimExactlyOneWinner runs many goroutines racing for the same
// conversation and asserts EXACTLY ONE wins — the property that prevents two concurrent
// generations (double xAI spend) for one conversation. It also races two DIFFERENT
// threads of one room together and asserts each has its own single winner, proving the
// claim is independent per (room,thread), not per room. Run under -race.
func TestSingleFlightClaimExactlyOneWinner(t *testing.T) {
	b := &Bot{inflight: make(map[string]map[string]bool)}
	const n = 64
	var sameWins, threadAWins, threadBWins int64
	var mu sync.Mutex
	var wg sync.WaitGroup
	wg.Add(n * 3)
	for i := 0; i < n; i++ {
		go func() {
			defer wg.Done()
			if b.tryClaim("!room", "$same") {
				mu.Lock()
				sameWins++
				mu.Unlock()
			}
		}()
		go func() {
			defer wg.Done()
			if b.tryClaim("!room", "$a") {
				mu.Lock()
				threadAWins++
				mu.Unlock()
			}
		}()
		go func() {
			defer wg.Done()
			if b.tryClaim("!room", "$b") {
				mu.Lock()
				threadBWins++
				mu.Unlock()
			}
		}()
	}
	wg.Wait()
	if sameWins != 1 {
		t.Fatalf("exactly one goroutine must win (!room, $same), got %d", sameWins)
	}
	if threadAWins != 1 {
		t.Fatalf("exactly one goroutine must win (!room, $a), got %d", threadAWins)
	}
	if threadBWins != 1 {
		t.Fatalf("exactly one goroutine must win (!room, $b), got %d", threadBWins)
	}
}

// TestLRUSetConcurrentAddOnce asserts the dedup set's check-and-insert is atomic:
// with many goroutines racing on the same id, Add returns true exactly once. This is
// the in-memory half of markSeen, now called from concurrent per-room goroutines.
// Run under -race.
func TestLRUSetConcurrentAddOnce(t *testing.T) {
	s := newLRUSet(1000)
	const n = 64
	var trues int64
	var mu sync.Mutex
	var wg sync.WaitGroup
	wg.Add(n)
	for i := 0; i < n; i++ {
		go func() {
			defer wg.Done()
			if s.Add("$evt") {
				mu.Lock()
				trues++
				mu.Unlock()
			}
		}()
	}
	wg.Wait()
	if trues != 1 {
		t.Fatalf("Add must return true exactly once for one id, got %d", trues)
	}
}
