package world

import (
	"github.com/go-gl/mathgl/mgl64"
	"maps"
	"math"
	"sync"
)

// Loader implements the loading of the world. A loader can typically be moved around the world to load
// different parts of the world. An example usage is the player, which uses a loader to load chunks around it
// so that it can view them.
type Loader struct {
	r      int
	w      *World
	viewer Viewer

	mu        sync.RWMutex
	pos       ChunkPos
	loadQueue []ChunkPos
	loaded    map[ChunkPos]*Column

	closed bool
}

// NewLoader creates a new loader using the chunk radius passed. Chunks beyond this radius from the position
// of the loader will never be loaded.
// The Viewer passed will handle the loading of chunks, including the viewing of entities that were loaded in
// those chunks.
func NewLoader(chunkRadius int, world *World, v Viewer) *Loader {
	l := &Loader{r: chunkRadius, loaded: make(map[ChunkPos]*Column), viewer: v}
	l.world(world)
	return l
}

// World returns the World that the Loader is in.
func (l *Loader) World() *World {
	l.mu.RLock()
	defer l.mu.RUnlock()
	return l.w
}

// ChangeWorld changes the World of the Loader. The currently loaded chunks are reset and any future loading
// is done from the new World.
func (l *Loader) ChangeWorld(tx *Tx, new *World) {
	l.mu.Lock()
	defer l.mu.Unlock()

	loaded := maps.Clone(l.loaded)
	l.w.Exec(func(tx *Tx) {
		for pos := range loaded {
			tx.World().removeViewer(tx, pos, l)
		}
	})
	clear(l.loaded)
	l.w.viewerMu.Lock()
	delete(l.w.viewers, l)
	l.w.viewerMu.Unlock()

	l.world(new)
}

// ChangeRadius changes the maximum chunk radius of the Loader.
func (l *Loader) ChangeRadius(tx *Tx, new int) {
	l.mu.Lock()
	defer l.mu.Unlock()

	l.r = new
	l.evictUnused(tx)
	l.populateLoadQueue()
}

// Move moves the loader to the position passed. The position is translated to a chunk position to load
func (l *Loader) Move(tx *Tx, pos mgl64.Vec3) {
	l.mu.Lock()
	defer l.mu.Unlock()

	chunkPos := chunkPosFromVec3(pos)
	if chunkPos == l.pos {
		return
	}
	l.pos = chunkPos
	l.evictUnused(tx)
	l.populateLoadQueue()
}

// Load loads n chunks around the centre of the chunk, starting with the middle and working outwards. For
// every chunk loaded, the Viewer passed through construction in New has its ViewChunk method called.
// Load does nothing for n <= 0.
func (l *Loader) Load(tx *Tx, n int) {
	l.mu.Lock()
	defer l.mu.Unlock()

	if l.closed || l.w == nil {
		return
	}
	for i := 0; i < n; i++ {
		if len(l.loadQueue) == 0 {
			break
		}

		pos := l.loadQueue[0]
		c := tx.w.chunk(pos)

		l.viewer.ViewChunk(pos, l.w.Dimension(), c.BlockEntities, c.Chunk)
		l.w.addViewer(tx, c, l)

		l.loaded[pos] = c

		// Shift the first element from the load queue off so that we can take a new one during the next
		// iteration.
		l.loadQueue = l.loadQueue[1:]
	}
}

// Chunk attempts to return a chunk at the given ChunkPos. If the chunk is not loaded, the second return value will
// be false.
func (l *Loader) Chunk(pos ChunkPos) (*Column, bool) {
	l.mu.RLock()
	c, ok := l.loaded[pos]
	l.mu.RUnlock()
	return c, ok
}

// Close closes the loader. It unloads all chunks currently loaded for the viewer, and hides all entities that
// are currently shown to it.
func (l *Loader) Close(tx *Tx) {
	l.mu.Lock()
	defer l.mu.Unlock()

	for pos := range l.loaded {
		tx.World().removeViewer(tx, pos, l)
	}
	l.loaded = map[ChunkPos]*Column{}

	l.w.viewerMu.Lock()
	delete(l.w.viewers, l)
	l.w.viewerMu.Unlock()

	l.closed = true
	l.viewer = nil
}

// world sets the loader's world, adds them to the world's viewer list, then starts populating the load queue.
// This is only here to get rid of duplicated code, ChangeWorld should be used instead of this.
func (l *Loader) world(new *World) {
	l.w = new
	l.w.addWorldViewer(l)
	l.populateLoadQueue()
}

// evictUnused gets rid of chunks in the loaded map which are no longer within the chunk radius of the loader,
// and should therefore be removed.
func (l *Loader) evictUnused(tx *Tx) {
	for pos := range l.loaded {
		diffX, diffZ := pos[0]-l.pos[0], pos[1]-l.pos[1]
		dist := math.Sqrt(float64(diffX*diffX) + float64(diffZ*diffZ))
		if int(dist) > l.r {
			delete(l.loaded, pos)
			l.w.removeViewer(tx, pos, l)
		}
	}
}

// populateLoadQueue populates the load queue of the loader. This method is called once to create the order in
// which chunks around the position the loader is now in should be loaded. Chunks are ordered to be loaded
// from the middle outwards.
func (l *Loader) populateLoadQueue() {
	// We'll first load the chunk positions to load in a map indexed by the distance to the centre (basically,
	// what precedence it should have), and put them in the loadQueue in that order.
	queue := map[int32][]ChunkPos{}

	r := int32(l.r)
	for x := -r; x <= r; x++ {
		for z := -r; z <= r; z++ {
			distance := math.Sqrt(float64(x*x) + float64(z*z))
			chunkDistance := int32(math.Round(distance))
			if chunkDistance > r {
				// The chunk was outside the chunk radius.
				continue
			}
			pos := ChunkPos{x + l.pos[0], z + l.pos[1]}
			if _, ok := l.loaded[pos]; ok {
				// The chunk was already loaded, so we don't need to do anything.
				continue
			}
			if m, ok := queue[chunkDistance]; ok {
				queue[chunkDistance] = append(m, pos)
				continue
			}
			queue[chunkDistance] = []ChunkPos{pos}
		}
	}

	l.loadQueue = l.loadQueue[:0]
	for i := int32(0); i < r; i++ {
		l.loadQueue = append(l.loadQueue, queue[i]...)
	}
}
