package world

import (
	"bytes"
	"context"
	"log/slog"
	"os"
	"path/filepath"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"regionio/internal/nbt"
)

// TestRegionFileRoundTrip writes arbitrary NBT bytes into a region file and
// reads them back, confirming the Anvil container (header + sectors + zlib)
// preserves the payload exactly.
func TestRegionFileRoundTrip(t *testing.T) {
	dir := t.TempDir()
	rf, err := OpenRegion(dir, 0, 0)
	if err != nil {
		t.Fatalf("OpenRegion: %v", err)
	}
	defer rf.Close()

	payload := []byte("hello-regionio-chunk-nbt-payload-0123456789")
	if err := rf.WriteChunk(3, 7, payload); err != nil {
		t.Fatalf("WriteChunk: %v", err)
	}
	got, err := rf.ReadChunk(3, 7)
	if err != nil {
		t.Fatalf("ReadChunk: %v", err)
	}
	if string(got) != string(payload) {
		t.Errorf("payload mismatch: got %q want %q", got, payload)
	}
}

// TestRegionFileAbsentChunk confirms ReadChunk returns ErrChunkNotFound for a
// chunk that was never written (offset table entry is 0).
func TestRegionFileAbsentChunk(t *testing.T) {
	rf, err := OpenRegion(t.TempDir(), 0, 0)
	if err != nil {
		t.Fatal(err)
	}
	defer rf.Close()
	if _, err := rf.ReadChunk(5, 5); err != ErrChunkNotFound {
		t.Errorf("absent chunk err = %v, want ErrChunkNotFound", err)
	}
}

// TestRegionFileOverwrite confirms writing the same chunk twice keeps the
// latest data (the second write replaces the first).
func TestRegionFileOverwrite(t *testing.T) {
	rf, err := OpenRegion(t.TempDir(), 0, 0)
	if err != nil {
		t.Fatal(err)
	}
	defer rf.Close()
	if err := rf.WriteChunk(1, 1, []byte("first")); err != nil {
		t.Fatal(err)
	}
	if err := rf.WriteChunk(1, 1, []byte("second")); err != nil {
		t.Fatal(err)
	}
	got, err := rf.ReadChunk(1, 1)
	if err != nil {
		t.Fatal(err)
	}
	if string(got) != "second" {
		t.Errorf("after overwrite got %q, want %q", got, "second")
	}
}

func TestRegionFileOverwriteUsesCopyOnWrite(t *testing.T) {
	dir := t.TempDir()
	rf, err := OpenRegion(dir, 0, 0)
	if err != nil {
		t.Fatal(err)
	}
	defer rf.Close()
	if err := rf.WriteChunk(1, 1, []byte("first")); err != nil {
		t.Fatal(err)
	}
	first := rf.offsets[locationIndex(1, 1)] >> 8
	if err := rf.WriteChunk(1, 1, []byte("second")); err != nil {
		t.Fatal(err)
	}
	second := rf.offsets[locationIndex(1, 1)] >> 8
	if second <= first {
		t.Fatalf("overwrite reused published sector %d; new location is %d", first, second)
	}
}

// TestStoreChunkRoundTrip encodes a chunk to NBT, decodes it back, and confirms
// the blocks/biomes match. This validates the chunkToNBT/nbtToChunk bridge.
func TestStoreChunkRoundTrip(t *testing.T) {
	original := NewChunk(10, -5, BiomePlains)
	// Build a recognizable section: a grass surface over stone, with one water
	// block, so the palette has >1 entry and exercises the packed long array.
	si := (SeaLevel - MinY) >> 4
	original.section(si)
	s := original.sections[si]
	for x := 0; x < 16; x++ {
		for z := 0; z < 16; z++ {
			s[blockIndex(x, SeaLevel, z)] = StateGrass
			s[blockIndex(x, SeaLevel-1, z)] = StateDirt
		}
	}
	s[blockIndex(0, SeaLevel, 0)] = StateWater

	nbtBytes := nbt.MarshalNamed("", chunkToNBT(original))
	if len(nbtBytes) == 0 {
		t.Fatal("empty NBT")
	}
	_, tag, err := nbt.UnmarshalNamed(nbtBytes)
	if err != nil {
		t.Fatalf("decode: %v", err)
	}
	root, ok := tag.(*nbt.Compound)
	if !ok {
		t.Fatal("root not compound")
	}
	rx, rz, lx, lz := regionIndex(original.X, original.Z)
	decoded, err := nbtToChunk(root, rx, rz, lx, lz)
	if err != nil {
		t.Fatalf("nbtToChunk: %v", err)
	}
	// Spot-check the recognizable blocks.
	if got := decoded.GetBlock(0, SeaLevel, 0); got != StateWater {
		t.Errorf("block (0, %d, 0) = %d, want water %d", SeaLevel, got, StateWater)
	}
	if got := decoded.GetBlock(5, SeaLevel, 5); got != StateGrass {
		t.Errorf("block (5, %d, 5) = %d, want grass %d", SeaLevel, got, StateGrass)
	}
	if got := decoded.GetBlock(5, SeaLevel-1, 5); got != StateDirt {
		t.Errorf("block (5, %d, 5) = %d, want dirt %d", SeaLevel-1, got, StateDirt)
	}
	if decoded.X != 10 || decoded.Z != -5 {
		t.Errorf("coords = (%d,%d), want (10,-5)", decoded.X, decoded.Z)
	}
	if decoded.lightReady {
		t.Error("legacy chunk without isLightOn loaded as light-ready")
	}
}

func TestChunkNBTRejectsMissingSections(t *testing.T) {
	root := nbt.NewCompound().
		Set(generatorVersionTag, nbt.Int(generatorVersion)).
		Set("xPos", nbt.Int(0)).
		Set("zPos", nbt.Int(0))
	if _, err := nbtToChunk(root, 0, 0, 0, 0); err == nil {
		t.Fatal("accepted chunk without sections")
	}
}

func TestChunkNBTRejectsMalformedPaletteData(t *testing.T) {
	root := chunkToNBT(GenerateFlat(0, 0))
	sectionsTag, _ := root.Get("sections")
	sections := sectionsTag.(nbt.List)
	section := sections.Elems[0].(*nbt.Compound)
	blocksTag, _ := section.Get("block_states")
	blocks := blocksTag.(*nbt.Compound)
	blocks.Set("data", nbt.LongArray{0})
	if _, err := nbtToChunk(root, 0, 0, 0, 0); err == nil {
		t.Fatal("accepted packed block data with the wrong length")
	}
}

func TestChunkNBTRejectsUnknownBlock(t *testing.T) {
	root := chunkToNBT(NewChunk(0, 0, BiomePlains))
	sectionsTag, _ := root.Get("sections")
	sections := sectionsTag.(nbt.List)
	section := sections.Elems[0].(*nbt.Compound)
	blocksTag, _ := section.Get("block_states")
	blocks := blocksTag.(*nbt.Compound)
	blocks.Set("palette", nbt.List{ElemID: nbt.TagCompound, Elems: []nbt.Tag{
		nbt.NewCompound().Set("Name", nbt.String("minecraft:not_a_block")),
	}})
	if _, err := nbtToChunk(root, 0, 0, 0, 0); err == nil {
		t.Fatal("accepted unknown block palette entry")
	}
}

func TestStoreLightRoundTrip(t *testing.T) {
	dir := t.TempDir()
	store, err := NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	cache := NewCacheWithStore(-1, func(cx, cz int32) *Chunk {
		return NewChunk(cx, cz, BiomePlains)
	}, store)
	left := cache.chunkAt(0, 0)
	right := cache.chunkAt(1, 0)
	if _, err := cache.FrameErr(0, 0); err != nil {
		t.Fatal(err)
	}
	if _, err := cache.FrameErr(1, 0); err != nil {
		t.Fatal(err)
	}
	glowstone, _ := nameToStateID("minecraft:glowstone", nil)
	if valid, _ := cache.SetBlockWithLight(15, 0, 8, glowstone); !valid {
		t.Fatal("glowstone edit rejected")
	}
	wantLeftSky, wantLeftBlock, ready := left.LightAt(15, 0, 8)
	if !ready || wantLeftBlock != 15 {
		t.Fatalf("pre-save source light = sky %d block %d ready %v", wantLeftSky, wantLeftBlock, ready)
	}
	wantRightSky, wantRightBlock, ready := right.LightAt(0, 0, 8)
	if !ready || wantRightBlock != 14 {
		t.Fatalf("pre-save neighbor light = sky %d block %d ready %v", wantRightSky, wantRightBlock, ready)
	}
	if err := cache.SaveAll(); err != nil {
		t.Fatal(err)
	}
	if err := store.Close(); err != nil {
		t.Fatal(err)
	}

	store, err = NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	loadedLeft, err := store.LoadChunk(0, 0)
	if err != nil {
		t.Fatal(err)
	}
	gotSky, gotBlock, gotReady := loadedLeft.LightAt(15, 0, 8)
	if !gotReady || gotSky != wantLeftSky || gotBlock != wantLeftBlock {
		t.Fatalf("loaded source light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantLeftSky, wantLeftBlock)
	}
	loadedRight, err := store.LoadChunk(1, 0)
	if err != nil {
		t.Fatal(err)
	}
	gotSky, gotBlock, gotReady = loadedRight.LightAt(0, 0, 8)
	if !gotReady || gotSky != wantRightSky || gotBlock != wantRightBlock {
		t.Fatalf("loaded neighbor light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantRightSky, wantRightBlock)
	}
}

func TestCacheReconcilesPersistedLightWithLoadedNeighbor(t *testing.T) {
	dir := t.TempDir()
	store, err := NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	glowstone, _ := nameToStateID("minecraft:glowstone", nil)
	left := NewChunk(0, 0, BiomePlains)
	left.SetBlock(15, 100, 8, glowstone)
	if err := store.SaveChunk(left); err != nil {
		t.Fatal(err)
	}
	// Simulate a chunk saved before its unloaded neighbor gained a light source.
	right := NewChunk(1, 0, BiomePlains)
	right.lightReady = true
	if err := store.SaveChunk(right); err != nil {
		t.Fatal(err)
	}
	if err := store.Close(); err != nil {
		t.Fatal(err)
	}

	store, err = NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	cache := NewCacheWithLimit(-1, func(cx, cz int32) *Chunk {
		return NewChunk(cx, cz, BiomePlains)
	}, store, 1)
	tickets := cache.NewTicketSet()
	tickets.Replace([]ChunkPos{{X: 1, Z: 0}}, nil)
	loaded, err := cache.chunkAtErr(1, 0)
	if err != nil {
		t.Fatal(err)
	}
	if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 0 {
		t.Fatalf("persisted pre-reconcile light = %d ready=%v, want stale zero", block, ready)
	}
	if _, err := cache.FrameErr(1, 0); err != nil {
		t.Fatal(err)
	}
	if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 14 {
		t.Fatalf("reconciled border light = %d ready=%v, want 14", block, ready)
	}
	if err := cache.SaveAll(); err != nil {
		t.Fatal(err)
	}
	tickets.Close()
	if _, err := cache.FrameErr(10, 0); err != nil {
		t.Fatal(err)
	}
	if hasChunk(cache, 1, 0) {
		t.Fatal("released light chunk remained resident after LRU replacement")
	}
	reloadedAfterEviction, err := cache.chunkAtErr(1, 0)
	if err != nil {
		t.Fatal(err)
	}
	if _, block, ready := reloadedAfterEviction.LightAt(0, 100, 8); !ready || block != 14 {
		t.Fatalf("light after ticket unload/reload = %d ready=%v, want 14", block, ready)
	}
	if err := store.Close(); err != nil {
		t.Fatal(err)
	}
	store, err = NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	reloaded, err := store.LoadChunk(1, 0)
	if err != nil {
		t.Fatal(err)
	}
	if _, block, ready := reloaded.LightAt(0, 100, 8); !ready || block != 14 {
		t.Fatalf("persisted reconciled light = %d ready=%v, want 14", block, ready)
	}
}

// TestStoreSaveLoadIntegration is the end-to-end "world survives restart" test:
// generate a chunk via a store-backed cache, edit a block, SaveAll, then open a
// fresh cache over the same store and confirm the edit is present.
func TestStoreSaveLoadIntegration(t *testing.T) {
	dir := t.TempDir()
	store, err := NewStore(dir)
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	gen := NewVanillaGenerator(12345)
	cache := NewCacheWithStore(int32(256), gen, store)

	// Force chunk (1,2) into the cache, then edit a block near the surface.
	ch := cache.chunkAt(1, 2)
	targetY := SeaLevel
	// Find the top solid block in column (0,0) to place our marker above it.
	markerY := targetY + 1
	if !cache.SetBlock(1*16, markerY, 2*16, StateBedrock) {
		t.Fatalf("SetBlock out of range y=%d", markerY)
	}
	_ = ch
	if err := cache.SaveAll(); err != nil {
		t.Fatalf("SaveAll: %v", err)
	}
	store.Close()

	// New cache, same store — simulates a restart.
	store2, err := NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	defer store2.Close()
	cache2 := NewCacheWithStore(int32(256), NewVanillaGenerator(12345), store2)
	loaded := cache2.chunkAt(1, 2)
	if loaded == nil {
		t.Fatal("nil loaded chunk")
	}
	if got := loaded.GetBlock(1*16, markerY, 2*16); got != StateBedrock {
		t.Errorf("after reload marker block = %d, want bedrock %d", got, StateBedrock)
	}
}

// TestStoreNegativeCoords exercises the floor-division region math for negative
// chunk coordinates (e.g. chunk -1 belongs to region -1, local 31).
func TestStoreNegativeCoords(t *testing.T) {
	store, err := NewStore(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	c := NewChunk(-1, -1, BiomePlains)
	c.section((SeaLevel - MinY) >> 4)
	c.SetBlock(0, SeaLevel, 0, StateGrass)
	if err := store.SaveChunk(c); err != nil {
		t.Fatalf("SaveChunk(-1,-1): %v", err)
	}
	loaded, err := store.LoadChunk(-1, -1)
	if err != nil {
		t.Fatalf("LoadChunk(-1,-1): %v", err)
	}
	if loaded.X != -1 || loaded.Z != -1 {
		t.Errorf("coords = (%d,%d), want (-1,-1)", loaded.X, loaded.Z)
	}
	// Confirm the file landed in region r.-1.-1.mca.
	if _, err := os.Stat(filepath.Join(store.dir, "region", "r.-1.-1.mca")); err != nil {
		t.Errorf("expected r.-1.-1.mca: %v", err)
	}
}

// TestCacheAutosavePersistsEdits starts an autosave loop with a short interval,
// edits a block, and confirms a second cache sees the edit after the interval.
func TestCacheAutosavePersistsEdits(t *testing.T) {
	dir := t.TempDir()
	store, err := NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	gen := NewVanillaGenerator(12345)
	cache := NewCacheWithStore(int32(256), gen, store)
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	autosaveDone := cache.StartAutosave(ctx, slog.Default(), 50*time.Millisecond)

	cache.chunkAt(0, 0)
	cache.SetBlock(8, SeaLevel, 8, StateBedrock)

	// Wait for at least one autosave cycle.
	time.Sleep(250 * time.Millisecond)
	cancel()       // stop the autosave loop so it releases the store
	<-autosaveDone // wait for the goroutine to fully exit
	store.Close()

	// Fresh cache over the same store should see the edit without SaveAll.
	store2, err := NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	defer store2.Close()
	cache2 := NewCacheWithStore(int32(256), gen, store2)
	loaded := cache2.chunkAt(0, 0)
	if got := loaded.GetBlock(8, SeaLevel, 8); got != StateBedrock {
		t.Errorf("autosaved block = %d, want bedrock %d", got, StateBedrock)
	}
}

func TestStoreRejectsSeedMismatch(t *testing.T) {
	dir := t.TempDir()
	store, err := NewStoreForSeed(dir, 12345)
	if err != nil {
		t.Fatal(err)
	}
	if err := store.Close(); err != nil {
		t.Fatal(err)
	}
	store, err = NewStoreForSeed(dir, 12345)
	if err != nil {
		t.Fatalf("reopen with matching seed: %v", err)
	}
	if err := store.Close(); err != nil {
		t.Fatal(err)
	}
	if _, err := NewStoreForSeed(dir, 54321); err == nil {
		t.Fatal("opening a world with a different seed succeeded")
	}
}

func TestCacheDoesNotRegenerateCorruptStoredChunk(t *testing.T) {
	dir := t.TempDir()
	regionDir := filepath.Join(dir, "region")
	rf, err := OpenRegion(regionDir, 0, 0)
	if err != nil {
		t.Fatal(err)
	}
	corrupt := []byte("not an nbt document")
	if err := rf.WriteChunk(0, 0, corrupt); err != nil {
		t.Fatal(err)
	}
	if err := rf.Close(); err != nil {
		t.Fatal(err)
	}

	store, err := NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	var generated atomic.Bool
	cache := NewCacheWithStore(256, func(cx, cz int32) *Chunk {
		generated.Store(true)
		return NewChunk(cx, cz, BiomePlains)
	}, store)
	if _, err := cache.FrameErr(0, 0); err == nil {
		t.Fatal("FrameErr succeeded for corrupt stored chunk")
	}
	if generated.Load() {
		t.Fatal("generator ran after a stored chunk read error")
	}
	if cache.SetBlock(0, SeaLevel, 0, StateBedrock) {
		t.Fatal("SetBlock accepted an edit over a corrupt stored chunk")
	}
	if err := cache.SaveAll(); err != nil {
		t.Fatal(err)
	}

	_, _, lx, lz := regionIndex(0, 0)
	rf = store.regions[[2]int{0, 0}]
	raw, err := rf.ReadChunk(lx, lz)
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Equal(raw, corrupt) {
		t.Fatalf("stored corrupt payload was overwritten: %q", raw)
	}
}

func TestConcurrentAutosavePreservesLatestEdit(t *testing.T) {
	dir := t.TempDir()
	store, err := NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	cache := NewCacheWithStore(256, flatGen(), store)
	cache.chunkAt(0, 0)

	done := make(chan struct{})
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		for i := 0; i < 100; i++ {
			state := StateStone
			if i%2 == 0 {
				state = StateDirt
			}
			cache.SetBlock(5, SeaLevel, 5, state)
		}
		close(done)
	}()
	go func() {
		defer wg.Done()
		for {
			select {
			case <-done:
				return
			default:
				_ = cache.flushDirty()
			}
		}
	}()
	wg.Wait()
	cache.SetBlock(5, SeaLevel, 5, StateBedrock)
	if err := cache.SaveAll(); err != nil {
		t.Fatal(err)
	}
	if err := store.Close(); err != nil {
		t.Fatal(err)
	}

	store, err = NewStore(dir)
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	loaded, err := store.LoadChunk(0, 0)
	if err != nil {
		t.Fatal(err)
	}
	if got := loaded.GetBlock(5, SeaLevel, 5); got != StateBedrock {
		t.Fatalf("persisted final block = %d, want %d", got, StateBedrock)
	}
}

// TestGeneratorVersionStampRejectsStaleChunks covers the guard that keeps a
// saved world from masking generator changes.
//
// chunkAt prefers the store over the generator, so without this check a chunk
// saved by an older build is served forever and every later worldgen fix looks
// like it did nothing in the already-explored area around spawn. Chunks written
// before the stamp existed carry no tag and must be rejected the same way.
func TestGeneratorVersionStampRejectsStaleChunks(t *testing.T) {
	toRoot := func(t *testing.T, c *nbt.Compound) *nbt.Compound {
		t.Helper()
		_, tag, err := nbt.UnmarshalNamed(nbt.MarshalNamed("", c))
		if err != nil {
			t.Fatalf("decode: %v", err)
		}
		root, ok := tag.(*nbt.Compound)
		if !ok {
			t.Fatal("root is not a compound")
		}
		return root
	}

	t.Run("current version loads", func(t *testing.T) {
		root := toRoot(t, chunkToNBT(NewChunk(0, 0, BiomePlains)))
		if _, err := nbtToChunk(root, 0, 0, 0, 0); err != nil {
			t.Fatalf("nbtToChunk on a freshly written chunk: %v", err)
		}
	})

	t.Run("older version is rejected", func(t *testing.T) {
		c := chunkToNBT(NewChunk(0, 0, BiomePlains))
		c.Set(generatorVersionTag, nbt.Int(generatorVersion-1))
		if _, err := nbtToChunk(toRoot(t, c), 0, 0, 0, 0); err != ErrChunkNotFound {
			t.Errorf("err = %v, want ErrChunkNotFound so the chunk regenerates", err)
		}
	})

	t.Run("missing stamp is rejected", func(t *testing.T) {
		// What every chunk written before this guard existed looks like.
		c := chunkToNBT(NewChunk(0, 0, BiomePlains))
		stripped := nbt.NewCompound()
		for _, name := range c.Keys() {
			if name == generatorVersionTag {
				continue
			}
			v, _ := c.Get(name)
			stripped.Set(name, v)
		}
		if _, err := nbtToChunk(toRoot(t, stripped), 0, 0, 0, 0); err != ErrChunkNotFound {
			t.Errorf("err = %v, want ErrChunkNotFound so the chunk regenerates", err)
		}
	})
}
