package world

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

	"regionio/internal/protocol"
)

// flatGen returns a generator that produces distinct chunks keyed by coordinate,
// so eviction is observable: each (cx,cz) gets a chunk whose only block encodes
// its position (block at local 0,SeaLevel,0 = a sentinel derived from coords).
func flatGen() Generator {
	return func(cx, cz int32) *Chunk {
		c := NewChunk(cx, cz, BiomePlains)
		si := (SeaLevel - MinY) >> 4
		c.section(si)
		// Sentinel: the block at column (0,0) of the surface is the chunk's
		// low byte cx, and (1,0) is cz, so a reloaded chunk proves it's the
		// right coordinate.
		c.SetBlock(0, SeaLevel, 0, uint16(cx&0xFF))
		c.SetBlock(1, SeaLevel, 0, uint16(cz&0xFF))
		return c
	}
}

// cachedCount returns the number of chunks currently in the cache.
func cachedCount(c *Cache) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return len(c.chunks)
}

// hasChunk reports whether the cache holds the given chunk key.
func hasChunk(c *Cache, cx, cz int32) bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	_, ok := c.chunks[[2]int32{cx, cz}]
	return ok
}

// TestEvictionRespectsLimit confirms the cache stays at or below maxChunks after
// a burst of Frame calls that would otherwise grow it unbounded.
func TestEvictionRespectsLimit(t *testing.T) {
	c := NewCacheWithLimit(int32(256), flatGen(), nil, 4)
	for cx := int32(0); cx < 6; cx++ {
		c.Frame(cx, 0)
	}
	if got := cachedCount(c); got > 4 {
		t.Errorf("cached count = %d, want <= 4 after inserting 6", got)
	}
	// The oldest two (0,0) and (1,0) should have been evicted.
	if hasChunk(c, 0, 0) {
		t.Error("(0,0) should have been evicted as LRU")
	}
	if hasChunk(c, 1, 0) {
		t.Error("(1,0) should have been evicted as LRU")
	}
}

// TestEvictionLRUOrder confirms a touched (re-accessed) chunk survives while an
// untouched one between is evicted. Insert A B C D (cap 4); touch A; insert E →
// B (not A) is the victim.
func TestEvictionLRUOrder(t *testing.T) {
	c := NewCacheWithLimit(int32(256), flatGen(), nil, 4)
	c.Frame(0, 0) // A
	c.Frame(1, 0) // B
	c.Frame(2, 0) // C
	c.Frame(3, 0) // D
	// Re-access A so it is most-recently-used; B becomes least.
	c.Frame(0, 0)
	c.Frame(4, 0) // E → evicts B
	if !hasChunk(c, 0, 0) {
		t.Error("(0,0)/A should survive after being touched")
	}
	if hasChunk(c, 1, 0) {
		t.Error("(1,0)/B should have been evicted (least recently used)")
	}
}

// TestEvictionDropsBothMaps confirms eviction removes the entry from both the
// chunks and frames maps (otherwise memory would still leak).
func TestEvictionDropsBothMaps(t *testing.T) {
	c := NewCacheWithLimit(int32(256), flatGen(), nil, 2)
	c.Frame(0, 0)
	c.Frame(1, 0)
	c.Frame(2, 0) // evicts (0,0)
	c.mu.Lock()
	_, hasChunkMap := c.chunks[[2]int32{0, 0}]
	_, hasFrameMap := c.frames[[2]int32{0, 0}]
	c.mu.Unlock()
	if hasChunkMap {
		t.Error("evicted chunk still present in chunks map")
	}
	if hasFrameMap {
		t.Error("evicted chunk still present in frames map")
	}
}

// TestEvictionKeepsDirty confirms a dirty chunk (pending autosave) is NOT
// evicted, so its edits survive until flushed.
func TestEvictionKeepsDirty(t *testing.T) {
	dir := t.TempDir()
	store, err := NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	c := NewCacheWithLimit(int32(256), flatGen(), store, 2)

	c.Frame(0, 0) // load/generate into cache
	// Mark it dirty via a block edit (sets dirty + touches).
	if !c.SetBlock(0, SeaLevel, 0, StateBedrock) {
		t.Fatal("SetBlock failed")
	}
	// Now insert two more chunks to exceed the cap of 2; (0,0) is dirty and
	// must be retained.
	c.Frame(1, 0)
	c.Frame(2, 0)
	if !hasChunk(c, 0, 0) {
		t.Error("dirty chunk (0,0) was evicted; edits would be lost")
	}
}

// TestEvictionReloadsOnAccess confirms a chunk evicted then re-requested is
// regenerated (or loaded from disk) transparently and serves a valid frame.
func TestEvictionReloadsOnAccess(t *testing.T) {
	c := NewCacheWithLimit(int32(256), flatGen(), nil, 2)
	c.Frame(5, 7)
	c.Frame(6, 7)
	c.Frame(7, 7) // evicts (5,7)
	if hasChunk(c, 5, 7) {
		t.Fatal("(5,7) should have been evicted")
	}
	// Re-request: must regenerate and return a non-empty frame.
	frame := c.Frame(5, 7)
	if len(frame) == 0 {
		t.Fatal("reloaded chunk frame is empty")
	}
	if !hasChunk(c, 5, 7) {
		t.Error("re-requested chunk not present in cache after reload")
	}
}

// TestEvictionReloadPreservesEdits confirms that a dirty chunk, once flushed by
// the autosave and then evicted, reloads its saved edits from disk (not a stale
// re-generation). This is the end-to-end "edits survive eviction" guarantee.
func TestEvictionReloadPreservesEdits(t *testing.T) {
	dir := t.TempDir()
	store, err := NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	c := NewCacheWithLimit(int32(256), flatGen(), store, 2)

	// Edit (9,9) and flush it to disk.
	c.SetBlock(9*16+0, SeaLevel, 9*16+0, StateBedrock)
	if err := c.SaveAll(); err != nil {
		t.Fatal(err)
	}
	// Force eviction of (9,9) by pulling in other chunks (cap is 2; (9,9) is
	// dirty-but-now-flushed so it can be evicted).
	c.Frame(10, 10)
	c.Frame(11, 11)
	// Keep touching others until (9,9) is gone or we've filled beyond it. Since
	// it's no longer dirty after SaveAll, the next eviction pass can drop it.
	c.Frame(12, 12)
	// Reload (9,9) — should come from disk with the bedrock edit intact.
	frameBefore := c.Frame(9, 9)
	if len(frameBefore) == 0 {
		t.Fatal("reloaded frame empty")
	}
	ch := c.chunkAt(9, 9)
	if got := ch.GetBlock(9*16+0, SeaLevel, 9*16+0); got != StateBedrock {
		t.Errorf("after eviction+reload, edited block = %d, want bedrock %d", got, StateBedrock)
	}
}

func TestConcurrentMissGeneratesChunkOnce(t *testing.T) {
	var targetCalls atomic.Int32
	gen := func(cx, cz int32) *Chunk {
		if cx == 4 && cz == -7 {
			targetCalls.Add(1)
		}
		time.Sleep(10 * time.Millisecond)
		return NewChunk(cx, cz, BiomePlains)
	}
	c := NewCache(256, gen)
	var wg sync.WaitGroup
	for i := 0; i < 16; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if frame, err := c.FrameErr(4, -7); err != nil || len(frame) == 0 {
				t.Errorf("FrameErr = %d bytes, %v", len(frame), err)
			}
		}()
	}
	wg.Wait()
	if got := targetCalls.Load(); got != 1 {
		t.Fatalf("target generator calls = %d, want 1", got)
	}
}

func TestBatchGeneratorPublishesNeighborsAtomically(t *testing.T) {
	var calls atomic.Int32
	c := NewCache(-1, GenerateFlat)
	c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
		calls.Add(1)
		return map[[2]int32]*Chunk{
			{cx, cz}:     NewChunk(cx, cz, BiomePlains),
			{cx + 1, cz}: NewChunk(cx+1, cz, BiomePlains),
			{cx, cz + 1}: NewChunk(cx, cz+1, BiomePlains),
		}, nil
	})
	if got := c.chunkAt(2, 3); got == nil {
		t.Fatal("batch target was not published")
	}
	if got := c.chunkAt(3, 3); got == nil {
		t.Fatal("batch east neighbor was not published")
	}
	if got := c.chunkAt(2, 4); got == nil {
		t.Fatal("batch south neighbor was not published")
	}
	if got := calls.Load(); got != 1 {
		t.Fatalf("batch generator calls = %d, want 1", got)
	}
}

func TestConcurrentOverlappingBatchLoadsShareFirstResult(t *testing.T) {
	started := make(chan struct{}, 2)
	release := make(chan struct{})
	c := NewCache(-1, GenerateFlat)
	c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
		started <- struct{}{}
		<-release
		biome := uint16(cx + 10)
		return map[[2]int32]*Chunk{
			{cx, cz}:     NewChunk(cx, cz, biome),
			{cx + 1, cz}: NewChunk(cx+1, cz, biome),
			{cx - 1, cz}: NewChunk(cx-1, cz, biome),
		}, nil
	})

	type result struct {
		chunk *Chunk
		err   error
	}
	results := make(chan result, 2)
	go func() {
		chunk, err := c.chunkAtErr(0, 0)
		results <- result{chunk, err}
	}()
	go func() {
		chunk, err := c.chunkAtErr(1, 0)
		results <- result{chunk, err}
	}()
	<-started
	<-started
	close(release)
	loaded := make(map[[2]int32]*Chunk)
	for range 2 {
		result := <-results
		if result.err != nil || result.chunk == nil {
			t.Fatalf("overlapping batch load = %v, %v", result.chunk, result.err)
		}
		loaded[[2]int32{result.chunk.X, result.chunk.Z}] = result.chunk
	}
	left, right := c.chunkAt(0, 0), c.chunkAt(1, 0)
	if left == nil || right == nil {
		t.Fatal("overlapping targets were not retained")
	}
	if left != loaded[[2]int32{0, 0}] || right != loaded[[2]int32{1, 0}] {
		t.Fatal("requesters did not receive the canonical cached batch results")
	}
	if left.GetBiome(0, MinY, 0) != right.GetBiome(0, MinY, 0) {
		t.Fatal("overlapping chunks came from different batch results")
	}
}

func TestBatchGeneratorRejectsWrongTargetCoordinates(t *testing.T) {
	c := NewCache(-1, GenerateFlat)
	c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
		return map[[2]int32]*Chunk{{cx, cz}: NewChunk(cx+1, cz, BiomePlains)}, nil
	})
	if _, err := c.chunkAtErr(2, 3); err == nil {
		t.Fatal("batch generator with wrong target coordinates succeeded")
	}
}

func TestBatchGeneratorPrefersPersistedNeighbors(t *testing.T) {
	store, err := NewStore(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	persisted := NewChunk(1, 0, BiomePlains)
	persisted.SetBlock(0, SeaLevel, 0, StateBedrock)
	if err := store.SaveChunk(persisted); err != nil {
		t.Fatal(err)
	}
	c := NewCacheWithStore(-1, GenerateFlat, store)
	c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
		generatedNeighbor := NewChunk(cx+1, cz, BiomePlains)
		generatedNeighbor.SetBlock(0, SeaLevel, 0, StateGrass)
		return map[[2]int32]*Chunk{
			{cx, cz}:     NewChunk(cx, cz, BiomePlains),
			{cx + 1, cz}: generatedNeighbor,
		}, nil
	})
	if got := c.chunkAt(0, 0); got == nil {
		t.Fatal("batch target was not loaded")
	}
	neighbor := c.chunkAt(1, 0)
	if got := neighbor.GetBlock(0, SeaLevel, 0); got != StateBedrock {
		t.Fatalf("batch neighbor state = %d, want persisted bedrock %d", got, StateBedrock)
	}
}

func TestBatchGeneratorRespectsBoundedCache(t *testing.T) {
	c := NewCacheWithLimit(-1, GenerateFlat, nil, 2)
	c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
		return map[[2]int32]*Chunk{
			{cx, cz}:     NewChunk(cx, cz, BiomePlains),
			{cx + 1, cz}: NewChunk(cx+1, cz, BiomePlains),
			{cx, cz + 1}: NewChunk(cx, cz+1, BiomePlains),
		}, nil
	})
	if got := c.chunkAt(0, 0); got == nil {
		t.Fatal("batch target was not loaded")
	}
	c.mu.Lock()
	got := len(c.chunks)
	c.mu.Unlock()
	if got > 2 {
		t.Fatalf("cached batch chunks = %d, want at most 2", got)
	}
}

func TestConcurrentFrameAndBlockEdits(t *testing.T) {
	c := NewCache(256, flatGen())
	if len(c.Frame(0, 0)) == 0 {
		t.Fatal("initial frame is empty")
	}

	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		for i := 0; i < 250; i++ {
			state := StateStone
			if i%2 == 0 {
				state = StateBedrock
			}
			c.SetBlock(3, SeaLevel, 3, state)
		}
	}()
	go func() {
		defer wg.Done()
		for i := 0; i < 100; i++ {
			if len(c.Frame(0, 0)) == 0 {
				t.Error("frame became empty")
				return
			}
		}
	}()
	wg.Wait()
	c.SetBlock(3, SeaLevel, 3, StateBedrock)
	if got := c.GetBlock(3, SeaLevel, 3); got != StateBedrock {
		t.Fatalf("final block = %d, want %d", got, StateBedrock)
	}
	finalFrame := c.Frame(0, 0)
	if len(finalFrame) == 0 {
		t.Fatal("final frame is empty")
	}
	snapshot, _ := c.chunkAt(0, 0).snapshot()
	wantFrame := protocol.AppendPacket(nil, 256, protocol.PlayLevelChunk, snapshot.encode())
	if !bytes.Equal(finalFrame, wantFrame) {
		t.Fatal("cached frame does not represent the final chunk revision")
	}
}
