"""
The Dimension module holds all of the classes relating to storing and generating dimensions and worlds, excluding the lower levels of Tiles and Biomes.

Custom dimensions can be made by overriding the DimensionHandler class, and custom world generation algorithms can be made by overriding the ChunkProvider class.

However, the DefaultChunkProvider class is offered for simple world customisation, without requiring generation algorithm design.
"""

# Import the Python Standard Libraries
from threading import Thread
import random
import time
import noise

# Import the mod files
from api.biome import *
from api.entity import *
from api.vehicle import *

class DimensionHandler:
    """
    The DimensionHandler class is a container class for a dimension of a game.

    It holds the specific information about generating this dimension (in the chunkProvider field), and the current state of this dimension (in the worldObj field).
    """
    def __init__(self, chunkProvider, world):
        #: The ChunkProvider object which generates the world for this dimension.
        self.chunkProvider = chunkProvider
        #: The WorldMP object which holds all of the data of this dimension.
        self.worldObj = world

    def getName(self):
        """
        Return a name for this DimensionHandler's dimension.

        :returns: A string object of the name of this dimension.
        """
        return ''

    def getWorldObj(self):
        """
        Return a World Object for this DimensionHandler.

        :returns: A WorldMP object of this dimension's world.
        """
        return self.worldObj

    def getBiomeSize(self):
        """
        Get the biome generation size for this DimensionHandler.

        :returns: The biomeSize as an integer.
        """
        return self.chunkProvider.biomeSize

    def generate(self, pos, gameRegistry):
        """
        Generate the world according to this DimensionHandler's ChunkProvider.
        """
        tileMap = self.chunkProvider.generate(pos, gameRegistry)
        self.worldObj.setTileMap(tileMap)

class ChunkProvider:
    """
    The ChunkProvider class can be used for customising world generation algorithms.

    All custom world generation algorithms must generate the same results on all machines, by using the seed integer value. Failure to do so will result in invalid verification by the server, and different appearance of the world on different clients.

    Although not strictly necessary, the provided biome list and biomeSize fields should preferably be taken into account when generating, in order to provide a simple method for world modification for mods.
    """
    def __init__(self, biomes, biomeSize):
        #: A list of Biome classes which can be generated by this ChunkProvider.
        self.biomes = biomes
        #: An integer value for scaling the biomes in this ChunkProvider.
        #: Subclasses of ChunkProvider should make use of this during generation.
        self.biomeSize = biomeSize

    def generate(self, pos, seed):
        """
        Generate the tile map of the world based on ``pos``, biomeSize and Biome list.

        :raises NotImplementedError: If not overridden by a subclass.
        """
        raise NotImplementedError('ChunkProvider has no generate method.')

class DefaultChunkProvider(ChunkProvider):
    """
    The default ChunkProvider. This ChunkProvider creates simple cloud-like noise and associates Biomes with different value ranges.

    It can easily be extended by adding new Biomes to the biomes list, or by modifying the biomeSize field.
    """
    def generate(self, pos, seed):
        """
        Generate a 200x300 square section of the world around a central position, ``pos``.

        Biomes are associated in order with equally sized ranges between 0 and 1. For example, a biomes list with 4 biomes would associate the first biome (index 0), with noise values between 0 and 0.25, the second biome with noise values between 0.25 and 0.5 and so on.

        After the Biome calculation is performed, each tile is assigned a certain type based on a second, tighter packed noise map. The same tile to noise association is performed as with the Biomes.

        Finally, the plant types are assigned, again in the same fashion.

        :returns: A TileMap object of the generated 200x300 section of the world.
        """
        start = time.time()
        xPos, yPos = pos
        xPos = round(xPos)
        yPos = round(yPos)

        biomes = self.biomes
        biomeSize = self.biomeSize

        # Generate the tile noise
        noiseMap = []
        for y in range(yPos - 105, yPos + 106):
            noiseMap.append([])
            for x in range(xPos - 150, xPos + 151):
                # Generate the noise value for this x,y position
                noiseMap[-1].append(noise.snoise2(x, y, 8, 1.4, 0.45, base=seed)/2 + 0.5)

        # Generate the biome map noise
        biomeNoise = []
        for y in range(yPos - 105, yPos + 106):
            biomeNoise.append([])
            for x in range(xPos - 150, xPos + 151):
                # Generate the noise value for this x,y position
                biomeNoise[-1].append(noise.snoise2(x, y, 7, 3, 0.6 - (biomeSize * 0.1), base=seed/2)/2 + 0.5)

        # Generate the 'detail noise'. Used for plants
        # 0.85 is the threshold for trees
        # 0.7 is the threshold for plants
        # 0.5 is the threshold for grass
        detailNoise = []
        for y in range(yPos - 105, yPos + 106):
            detailNoise.append([])
            for x in range(xPos - 150, xPos + 151):
                # Generate the noise value for this x,y position
                detailNoise[-1].append((noise.snoise2(x, y, 2, 3, 0.02)**3)/2 + 0.5)

        # Generate an empty starting biome map
        width, height = (120, 84)
        biomeMap = TileMap(width, height)

        # Loop the TileMap, set biomes, then set tiles in the biome
        for y, row in enumerate(biomeMap.map):
            for x, tile in enumerate(row):
                # Set the tile in the array to be a certain biome
                biomeMap.map[y][x] = biomes[round(biomeNoise[y][x] * (len(biomes) - 1))]()
                # Set the tile to be a certain type from the biome
                biomeMap.map[y][x].setTileType(noiseMap[y][x], detailNoise[y][x])

        print('Time taken: ' + str(time.time() - start) + ' seconds')

        return biomeMap

class WorldMP:
    """
    The WorldMP class holds data about the entities, vehicles and players in a dimension, as well as the tiledata for the world.

    It also has a few convenience methods for handling simple tasks like adding new entities, or players.
    """
    def __init__(self):
        #: A list of Entities in the world.
        self.entities = []
        #: A list of Vehicles in the world.
        self.vehicles = []
        #: A list of Players in the world.
        self.players = []

        #: The coordinates of the tile at the middle of the currently generated section of the world.
        self.centrePos = [0, 1]

        #: The TileMap object of the currently generated section of the world.
        self._world = None

    def setTileMap(self, tileMap):
        """
        Set ``tileMap`` as the current tile map of this world.
        """
        self._world = tileMap

    def getTileMap(self):
        """
        Return this world's current TileMap.

        :returns: A TileMap object representing the current section of this world.
        """
        return self._world

    def isWorldLoaded(self):
        """
        Return whether this world is currently generated. Used for determining if first-time generation has run yet.

        :returns: True if this world is generated, False if not.
        """
        return bool(self._world)

    def spawnEntityInWorld(self, entity):
        """
        Spawn ``entity`` into this world.

        :returns: True if ``entity`` is successfully spawned, False if not.
        """
        if not isinstance(entity, EntityBase):
            print('[WARNING] Invalid entity. Did you forget to set the UUID?')
            return False
        elif not isinstance(entity.uuid, int):
            print('[WARNING] Invalid entity UUID. You forgot to run getUUIDForEntity in ModLoader')
            return False
        if isinstance(entity, Vehicle):
            self.vehicles.append(entity)
        else:
            self.entities.append(entity)
        return True

    def tickUpdate(self, game):
        """
        Run one tick of updates on this world and everything in it.

        Note that this method is only run on the server-side, not the client.
        """
        # Separate the Game Registry
        gameRegistry = game.modLoader.gameRegistry

        # Loop through the entities and update them
        toRemove = []
        for e in range(len(self.entities)):
            self.entities[e].aiHandler.runAITick(game)
            game.fireEvent('onEntityUpdate', self.entities[e])
            if self.entities[e].tickDamage:
                # Trigger on Entity Damaged events
                game.fireEvent('onEntityDamage', self.entities[e], self.entities[e].tickDamage)
            # If they die, delete them, and trigger events
            if self.entities[e].isDead:
                entityBackup = self.entities[e]
                toRemove.append(e)
                # Trigger on Entity Death events
                game.fireEvent('onEntityDeath', entityBackup, entityBackup.tickDamage)
            else:
                self.entities[e].tickDamage = None
        # Remove the vehicles afterwards to prevent issues with list iteration
        for a in toRemove[::-1]:
            self.entities.pop(a)

        # Loop through the vehicles and update them
        toRemove = []
        for v in range(len(self.vehicles)):
            self.vehicles[v].onVehicleUpdate(game)
            game.fireEvent('onVehicleUpdate', self.vehicles[v])
            # If they get destroyed, delete them, and trigger events
            if self.vehicles[v].isDestroyed:
                vehicleBackup = self.vehicles[v]
                toRemove.append(v)
                # Trigger on Vehicle Destruction events
                game.fireEvent('onVehicleDestroyed', vehicleBackup)
        # Remove the vehicles afterwards to prevent issues with list iteration
        for a in toRemove[::-1]:
            self.vehicles.pop(a)

        # Loop through the players and update them
        toRemove = []
        for p in range(len(self.players)):
            if self.players[p].tickDamage:
                # Trigger on Player Damaged events
                game.fireEvent('onPlayerDamage', self.players[p], self.players[p].tickDamage)
            if self.players[p].isDead:
                playerBackup = self.players[p]
                toRemove.append(p)
                # If the player has died, fire an onDeath event
                game.fireEvent('onPlayerDeath', playerBackup, playerBackup.tickDamage)
            else:
                self.players[p].tickDamage = None
        # Remove the players afterwards to prevent issues with list iteration
        for a in toRemove[::-1]:
            self.players.pop(a)

    def getEntitiesNear(self, pos, distance):
        """
        Return a list of entities within distance, ``distance`` from a position, ``pos``.
        """
        return self.getObjectsNear(pos, distance, self.entities)

    def getVehiclesNear(self, pos, distance):
        """
        Return a list of vehicles within distance, ``distance`` from a position, ``pos``.
        """
        return self.getObjectsNear(pos, distance, self.vehicles)

    def getPlayersNear(self, pos, distance):
        """
        Return a list of players within distance, ``distance`` from a position, ``pos``.
        """
        return self.getObjectsNear(pos, distance, self.players)

    def getObjectsNear(self, pos, distance, objects):
        """
        Return a subset of ``objects`` within distance, ``distance`` from a position, ``pos``.
        """
        # REMEMBER: 0 distance means all players
        if distance == 0:
            return objects

        closeObjects = []
        for e in objects:
            x, y = [e.pos[0] - pos[0], e.pos[1] - pos[1]]
            dist = (x**2 + y**2)**0.5
            if dist <= distance:
                closeObjects.append(e)

        return closeObjects

    def getUpdateData(self, player):
        """
        Collate this world's update data into a byte string.

        Data is adjusted based on ``player``.
        """
        playerData = str([p.toBytes() for p in self.getPlayersNear(player.pos, 30)])
        entityData = str([e.toBytes() for e in self.getEntitiesNear(player.pos, 30)])
        vehicleData = str([v.toBytes() for v in self.getVehiclesNear(player.pos, 30)])
        return '{}$$${}$$${}'.format(playerData, entityData, vehicleData).encode()

    def handleUpdate(self, updateBytes, game):
        """
        Use the binary data, ``updateBytes`` to update this world.
        """
        # Split the data up for parsing separately
        players, entities, vehicles = updateBytes.decode().split('$$$')

        # Loop the transferred players
        players = eval(players)
        players = [Player.fromBytes(p) for p in players]

        p = 0
        while p < len(self.players):
            toKeep = self.players[p] in players
            if not toKeep:
                self.players.pop(p)
            p += int(toKeep)

        for player in players:
            game.fireEvent("onPlayerSync", player, self.players)

        # Loop the transferred entities
        entities = eval(entities)
        entities = [Entity.fromBytes(e, game.modLoader.gameRegistry.entities) for e in entities]

        e = 0
        while e < len(self.entities):
            toKeep = self.entities[e] in entities
            if not toKeep:
                self.entities.pop(e)
            e += int(toKeep)

        for entity in entities:
            game.fireEvent('onEntitySync', entity, self.entities)

        # Loop the transferred entities
        vehicles = eval(vehicles)
        vehicles = [Vehicle.fromBytes(v, game.modLoader.gameRegistry.vehicles) for v in vehicles]

        v = 0
        while v < len(self.vehicles):
            toKeep = self.vehicles[v] in vehicles
            if not toKeep:
                self.vehicles.pop(v)
            v += int(toKeep)

        for vehicle in vehicles:
            game.fireEvent('onVehicleSync', vehicle, self.vehicles)

    def addPlayer(self, game, player):
        """
        Add ``player`` to this world.
        """
        for p in self.players:
            if p.name == player.name:
                return p
        player.pos = [0, 0]
        self.players.append(player)
        game.fireEvent('onPlayerCreated', player)
        return player
