'''
Created on Jul 22, 2011

@author: Rio
'''

from box import BoundingBox
from collections import defaultdict
from entity import Entity, TileEntity, TileTick
import itertools
from logging import getLogger
import materials
from math import floor
from mclevelbase import ChunkMalformed, ChunkNotPresent
import nbt
from numpy import argmax, swapaxes, zeros, zeros_like
import os.path
import id_definitions

log = getLogger(__name__)


def computeChunkHeightMap(materials, blocks, HeightMap=None):
    """Computes the HeightMap array for a chunk, which stores the lowest
    y-coordinate of each column where the sunlight is still at full strength.
    The HeightMap array is indexed z,x contrary to the blocks array which is x,z,y.

    If HeightMap is passed, fills it with the result and returns it. Otherwise, returns a
    new array.
    """

    lightAbsorption = materials.lightAbsorption[blocks]
    heights = extractHeights(lightAbsorption)
    heights = heights.swapaxes(0, 1)
    if HeightMap is None:
        return heights.astype('uint8')
    else:
        HeightMap[:] = heights
        return HeightMap


def extractHeights(array):
    """ Given an array of bytes shaped (x, z, y), return the coordinates of the highest
    non-zero value in each y-column into heightMap
    """

    # The fastest way I've found to do this is to make a boolean array with >0,
    # then turn it upside down with ::-1 and use argmax to get the _first_ nonzero
    # from each column.

    w, h = array.shape[:2]
    heightMap = zeros((w, h), 'int16')

    heights = argmax((array > 0)[..., ::-1], 2)
    heights = array.shape[2] - heights

    # if the entire column is air, argmax finds the first air block and the result is a top height column
    # top height columns won't ever have air in the top block so we can find air columns by checking for both
    heights[(array[..., -1] == 0) & (heights == array.shape[2])] = 0

    heightMap[:] = heights

    return heightMap


def getSlices(box, height):
    """ call this method to iterate through a large slice of the world by
        visiting each chunk and indexing its data with a subslice.

    this returns an iterator, which yields 3-tuples containing:
    +  a pair of chunk coordinates (cx, cz),
    +  a x,z,y triplet of slices that can be used to index the AnvilChunk's data arrays,
    +  a x,y,z triplet representing the relative location of this subslice within the requested world slice.

    Note the different order of the coordinates between the 'slices' triplet
    and the 'offset' triplet. x,z,y ordering is used only
    to index arrays, since it reflects the order of the blocks in memory.
    In all other places, including an entity's 'Pos', the order is x,y,z.
    """

    # when yielding slices of chunks on the edge of the box, adjust the
    # slices by an offset
    minxoff, minzoff = box.minx - (box.mincx << 4), box.minz - (box.mincz << 4)
    maxxoff, maxzoff = box.maxx - (box.maxcx << 4) + 16, box.maxz - (box.maxcz << 4) + 16

    newMinY = 0
    if box.miny < 0:
        newMinY = -box.miny
    miny = max(0, box.miny)
    maxy = min(height, box.maxy)

    for cx in xrange(box.mincx, box.maxcx):
        localMinX = 0
        localMaxX = 16
        if cx == box.mincx:
            localMinX = minxoff

        if cx == box.maxcx - 1:
            localMaxX = maxxoff
        newMinX = localMinX + (cx << 4) - box.minx

        for cz in xrange(box.mincz, box.maxcz):
            localMinZ = 0
            localMaxZ = 16
            if cz == box.mincz:
                localMinZ = minzoff
            if cz == box.maxcz - 1:
                localMaxZ = maxzoff
            newMinZ = localMinZ + (cz << 4) - box.minz
            slices, point = (
                (slice(localMinX, localMaxX), slice(localMinZ, localMaxZ), slice(miny, maxy)),
                (newMinX, newMinY, newMinZ)
            )

            yield (cx, cz), slices, point


class MCLevel(object):
    """ MCLevel is an abstract class providing many routines to the different level types,
    including a common copyEntitiesFrom built on class-specific routines, and
    a dummy getChunk/allChunks for the finite levels.

    MCLevel subclasses must have Width, Length, and Height attributes.  The first two are always zero for infinite levels.
    Subclasses must also have Blocks, and optionally Data and BlockLight.
    """

    # ## common to Creative, Survival and Indev. these routines assume
    ### self has Width, Height, Length, and Blocks

    materials = materials.classicMaterials
    isInfinite = False

    saving = False

    root_tag = None

    Height = None
    Length = None
    Width = None

    players = ["Player"]
    dimNo = 0
    parentWorld = None
    world = None

    entityClass = Entity

    # Game version check. Stores the info found in the 'Version::Name' tag

    @property
    def gamePlatform(self): # Should return the platform the world is from ("Java", "PE" or "Schematic")
        """Returns the platform the world is from ("Java", "PE", "Schematic"...)"""
        # I would use isinstance() here, but importing the separate level classes would cause a circular import, so this will have to do
        if not hasattr(self, '_gamePlatform'):
            type_map = {
                'pymclevel.schematic.MCSchematic': 'Schematic',
                'pymclevel.leveldbpocket.PocketLeveldbWorld': 'PE',
                'pymclevel.infiniteworld.MCInfdevOldLevel': 'Java',
            }
            t = str(type(self))
            self._gamePlatform = type_map.get(t[8:len(t) - 2], 'Unknown')
        return self._gamePlatform

    @property
    def gameVersionNumber(self):  # Should return the version number that the world is from (eg "1.12.2"...)
        """Returns the version number the world was last opened in or 'Unknown'"""
        if self.root_tag and not hasattr(self, '_gameVersionNumber'):
            self._gameVersionNumber = 'Unknown'
            if self.gamePlatform == "PE":
                if 'Data' in self.root_tag and isinstance(self.root_tag['Data'], nbt.TAG_Compound):
                    if 'lastOpenedWithVersion' in self.root_tag['Data'] and isinstance(self.root_tag['Data']['lastOpenedWithVersion'], nbt.TAG_List):
                        self._gameVersionNumber = '.'.join([str(num.value) for num in self.root_tag['Data']['lastOpenedWithVersion']])
                    else:
                        self._gameVersionNumber = self.root_tag['Data']['lastOpenedWithVersion']
            elif self.gamePlatform == "Java":
                if 'Data' in self.root_tag and isinstance(self.root_tag['Data'], nbt.TAG_Compound):
                    # We're opening a world.
                    if 'Version' in self.root_tag['Data']:
                        if 'Name' in self.root_tag['Data']['Version']:
                            self._gameVersionNumber = self.root_tag['Data']['Version']['Name'].value
                elif self.root_tag.name:
                    self._gameVersion = self.root_tag.name
        return self._gameVersionNumber

    @property
    def gameVersion(self): #depreciated. The output of this function is inconsistent. Use either gamePlatform or gameVersionNumber
        """Depreciated property. Use gamePlatform or gameVersionNumber. This returns gameVersionNumber for Java worlds and gamePlatform for all others"""
        log.warning('gameVersion is depreciated. Use gamePlatform or gameVersionNumber instead')
        if self.root_tag and not hasattr(self, '_gameVersion'):
            self._gameVersion = 'Unknown'
            if self.gamePlatform == "Java":
                self._gameVersion = self.gameVersionNumber
            else:
                self._gameVersion = self.gamePlatform
        return self._gameVersion

    @property
    def defsIds(self):
        if self.root_tag and not hasattr(self, '__defs_ids'):
            self.__defs_ids = id_definitions.get_defs_ids(self.gamePlatform, self.gameVersionNumber)
        return self.__defs_ids

    def loadDefIds(self):
        if self.root_tag and not hasattr(self, '__defs_ids'):
            self.__defs_ids = id_definitions.get_defs_ids(self.gamePlatform, self.gameVersionNumber)

    @classmethod
    def isLevel(cls, filename):
        """Tries to find out whether the given filename can be loaded
        by this class.  Returns True or False.

        Subclasses should implement _isLevel, _isDataLevel, or _isTagLevel.
        """
        if hasattr(cls, "_isLevel"):
            return cls._isLevel(filename)

        with file(filename) as f:
            data = f.read()

        if hasattr(cls, "_isDataLevel"):
            return cls._isDataLevel(data)

        if hasattr(cls, "_isTagLevel"):
            try:
                root_tag = nbt.load(filename, data)
            except:
                return False

            return cls._isTagLevel(root_tag)

        return False

    def getWorldBounds(self):
        return BoundingBox((0, 0, 0), self.size)

    @property
    def displayName(self):
        return os.path.basename(self.filename)

    @property
    def size(self):
        "Returns the level's dimensions as a tuple (X,Y,Z)"
        return self.Width, self.Height, self.Length

    @property
    def bounds(self):
        return BoundingBox((0, 0, 0), self.size)

    def close(self):
        pass

    # --- Entity Methods ---
    def addEntity(self, entityTag):
        pass

    def addEntities(self, entities):
        pass

    def tileEntityAt(self, x, y, z):
        return None

    def addTileEntity(self, entityTag):
        pass

    def addTileTick(self, entityTag):
        pass

    def addTileTicks(self, tileTicks):
        pass

    def getEntitiesInBox(self, box):
        return []

    def getTileEntitiesInBox(self, box):
        return []

    def getTileTicksInBox(self, box):
        return []

    def removeEntitiesInBox(self, box):
        pass

    def removeTileEntitiesInBox(self, box):
        pass

    def removeTileTicksInBox(self, box):
        pass

    @property
    def chunkCount(self):
        return (self.Width + 15 >> 4) * (self.Length + 15 >> 4)

    @property
    def allChunks(self):
        """Returns a synthetic list of chunk positions (xPos, zPos), to fake
        being a chunked level format."""
        return itertools.product(xrange(0, self.Width + 15 >> 4), xrange(0, self.Length + 15 >> 4))

    def getChunks(self, chunks=None):
        """ pass a list of chunk coordinate tuples to get an iterator yielding
        AnvilChunks. pass nothing for an iterator of every chunk in the level.
        the chunks are automatically loaded."""
        if chunks is None:
            chunks = self.allChunks
        return (self.getChunk(cx, cz) for (cx, cz) in chunks if self.containsChunk(cx, cz))

    def _getFakeChunkEntities(self, cx, cz):
        """Returns Entities, TileEntities"""
        return nbt.TAG_List(), nbt.TAG_List()

    def getChunk(self, cx, cz):
        """Synthesize a FakeChunk object representing the chunk at the given
        position. Subclasses override fakeBlocksForChunk and fakeDataForChunk
        to fill in the chunk arrays"""

        f = FakeChunk()
        f.world = self
        f.chunkPosition = (cx, cz)

        f.Blocks = self.fakeBlocksForChunk(cx, cz)

        f.Data = self.fakeDataForChunk(cx, cz)

        whiteLight = zeros_like(f.Blocks)
        whiteLight[:] = 15

        f.BlockLight = whiteLight
        f.SkyLight = whiteLight

        f.Entities, f.TileEntities, f.TileTicks = self._getFakeChunkEntities(cx, cz)

        f.root_tag = nbt.TAG_Compound()

        return f

    def getAllChunkSlices(self):
        slices = (slice(None), slice(None), slice(None),)
        box = self.bounds
        x, y, z = box.origin

        for cpos in self.allChunks:
            xPos, zPos = cpos
            try:
                chunk = self.getChunk(xPos, zPos)
            except (ChunkMalformed, ChunkNotPresent):
                continue

            yield (chunk, slices, (xPos * 16 - x, 0, zPos * 16 - z))

    def _getSlices(self, box):
        if box == self.bounds:
            log.info("All chunks selected! Selecting %s chunks instead of %s", self.chunkCount, box.chunkCount)
            y = box.miny
            slices = slice(0, 16), slice(0, 16), slice(0, box.maxy)

            def getAllSlices():
                for cPos in self.allChunks:
                    x, z = cPos
                    x *= 16
                    z *= 16
                    x -= box.minx
                    z -= box.minz
                    yield cPos, slices, (x, y, z)

            return getAllSlices()
        else:
            return getSlices(box, self.Height)

    def getChunkSlices(self, box):
        return ((self.getChunk(*cPos), slices, point)
                for cPos, slices, point in self._getSlices(box)
                if self.containsChunk(*cPos))

    def containsPoint(self, x, y, z):
        return (x, y, z) in self.bounds

    def containsChunk(self, cx, cz):
        bounds = self.bounds
        return ((bounds.mincx <= cx < bounds.maxcx) and
                (bounds.mincz <= cz < bounds.maxcz))

    def fakeBlocksForChunk(self, cx, cz):
        # return a 16x16xH block array for rendering.  Alpha levels can
        # just return the chunk data.  other levels need to reorder the
        # indices and return a slice of the blocks.

        cxOff = cx << 4
        czOff = cz << 4
        b = self.Blocks[cxOff:cxOff + 16, czOff:czOff + 16, 0:self.Height, ]
        # (w, l, h) = b.shape
        # if w<16 or l<16:
        #    b = resize(b, (16,16,h) )
        return b

    def fakeDataForChunk(self, cx, cz):
        # Data is emulated for flexibility
        cxOff = cx << 4
        czOff = cz << 4

        if hasattr(self, "Data"):
            return self.Data[cxOff:cxOff + 16, czOff:czOff + 16, 0:self.Height, ]

        else:
            return zeros(shape=(16, 16, self.Height), dtype='uint8')

    # --- Block accessors ---
    def skylightAt(self, *args):
        return 15

    def setSkylightAt(self, *args):
        pass

    def setBlockDataAt(self, x, y, z, newdata):
        pass

    def blockDataAt(self, x, y, z):
        return 0

    def blockLightAt(self, x, y, z):
        return 15

    def blockAt(self, x, y, z):
        if (x, y, z) not in self.bounds:
            return 0
        return self.Blocks[x, z, y]

    def setBlockAt(self, x, y, z, blockID):
        if (x, y, z) not in self.bounds:
            return 0
        self.Blocks[x, z, y] = blockID

    # --- Fill and Replace ---

    from block_fill import fillBlocks, fillBlocksIter

    # --- Transformations ---
    def rotateLeft(self):
        self.Blocks = swapaxes(self.Blocks, 1, 0)[:, ::-1, :]  # x=z; z=-x
        pass

    def roll(self):
        self.Blocks = swapaxes(self.Blocks, 2, 0)[:, :, ::-1]  # x=y; y=-x
        pass

    def flipVertical(self):
        self.Blocks = self.Blocks[:, :, ::-1]  # y=-y
        pass

    def flipNorthSouth(self):
        self.Blocks = self.Blocks[::-1, :, :]  # x=-x
        pass

    def flipEastWest(self):
        self.Blocks = self.Blocks[:, ::-1, :]  # z=-z
        pass

    # --- Copying ---

    from block_copy import copyBlocksFrom, copyBlocksFromIter

    def saveInPlaceGen(self):
        self.saveToFile(self.filename)
        yield

    def saveInPlace(self):
        gen = self.saveInPlaceGen()
        for _ in gen:
            pass

    # --- Player Methods ---
    def setPlayerPosition(self, pos, player="Player"):
        pass

    def getPlayerPosition(self, player="Player"):
        return 8, self.Height * 0.75, 8

    def getPlayerDimension(self, player="Player"):
        return 0

    def setPlayerDimension(self, d, player="Player"):
        return

    def setPlayerSpawnPosition(self, pos, player=None):
        pass

    def playerSpawnPosition(self, player=None):
        return self.getPlayerPosition()

    def setPlayerOrientation(self, yp, player="Player"):
        pass

    def getPlayerOrientation(self, player="Player"):
        return -45., 0.

    # --- Dummy Lighting Methods ---
    def generateLights(self, dirtyChunks=None):
        pass

    def generateLightsIter(self, dirtyChunks=None):
        yield 0


class EntityLevel(MCLevel):
    """Abstract subclass of MCLevel that adds default entity behavior"""

    def getEntitiesInBox(self, box):
        """Returns a list of references to entities in this chunk, whose positions are within box"""
        return [ent for ent in self.Entities if Entity.pos(ent) in box]

    def getTileEntitiesInBox(self, box):
        """Returns a list of references to tile entities in this chunk, whose positions are within box"""
        return [ent for ent in self.TileEntities if TileEntity.pos(ent) in box]

    def getTileTicksInBox(self, box):
        if hasattr(self, "TileTicks"):
            return [ent for ent in self.TileTicks if TileTick.pos(ent) in box]
        else:
            return []

    def removeEntities(self, func):
        if not hasattr(self, "Entities"):
            return
        newEnts = []
        for ent in self.Entities:
            if func(Entity.pos(ent)):
                continue
            newEnts.append(ent)

        entsRemoved = len(self.Entities) - len(newEnts)
        log.debug("Removed {0} entities".format(entsRemoved))

        self.Entities.value[:] = newEnts

        return entsRemoved

    def removeEntitiesInBox(self, box):
        return self.removeEntities(lambda p: p in box)

    def removeTileEntities(self, func):
        if not hasattr(self, "TileEntities"):
            return
        newEnts = []
        for ent in self.TileEntities:
            if func(TileEntity.pos(ent)):
                continue
            newEnts.append(ent)

        entsRemoved = len(self.TileEntities) - len(newEnts)
        log.debug("Removed {0} tile entities".format(entsRemoved))

        self.TileEntities.value[:] = newEnts

        return entsRemoved

    def removeTileEntitiesInBox(self, box):
        return self.removeTileEntities(lambda p: p in box)

    def removeTileTicks(self, func):
        if not hasattr(self, "TileTicks"):
            return
        newEnts = []
        for ent in self.TileTicks:
            if func(TileTick.pos(ent)):
                continue
            newEnts.append(ent)

        entsRemoved = len(self.TileTicks) - len(newEnts)
        log.debug("Removed {0} tile tickss".format(entsRemoved))

        self.TileTicks.value[:] = newEnts

        return entsRemoved

    def removeTileTicksInBox(self, box):
        return self.removeTileTicks(lambda p: p in box)

    def addEntities(self, entities):
        for e in entities:
            self.addEntity(e)

    def addEntity(self, entityTag):
        assert isinstance(entityTag, nbt.TAG_Compound)
        self.Entities.append(entityTag)
        self._fakeEntities = None

    def tileEntityAt(self, x, y, z, print_stuff=False):
        entities = []
        if print_stuff:
            print "len(self.TileEntities)", len(self.TileEntities)
        for entityTag in self.TileEntities:
            if print_stuff:
                print entityTag["id"].value, TileEntity.pos(entityTag), x, y, z
            if TileEntity.pos(entityTag) == [x, y, z]:
                entities.append(entityTag)

        if len(entities) > 1:
            log.info("Multiple tile entities found: {0}".format(entities))
        if len(entities) == 0:
            return None

        return entities[0]

    def addTileEntity(self, tileEntityTag):
        assert isinstance(tileEntityTag, nbt.TAG_Compound)

        def differentPosition(a):
            return not ((tileEntityTag is a) or TileEntity.pos(a) == TileEntity.pos(tileEntityTag))

        self.TileEntities.value[:] = filter(differentPosition, self.TileEntities)

        self.TileEntities.append(tileEntityTag)
        self._fakeEntities = None

    def addTileTick(self, tickTag):
        assert isinstance(tickTag, nbt.TAG_Compound)
        if hasattr(self, "TileTicks"):
            def differentPosition(a):
                return not ((tickTag is a) or TileTick.pos(a) == TileTick.pos(tickTag))

            self.TileTicks.value[:] = filter(differentPosition, self.TileTicks)

            self.TileTicks.append(tickTag)
            self._fakeEntities = None

    def addTileTicks(self, tileTicks):
        for e in tileTicks:
            self.addTileTick(e)

    _fakeEntities = None

    def _getFakeChunkEntities(self, cx, cz):
        """distribute entities into sublists based on fake chunk position
        _fakeEntities keys are (cx, cz) and values are (Entities, TileEntities, TileTicks)"""
        if self._fakeEntities is None:
            self._fakeEntities = defaultdict(lambda: (nbt.TAG_List(), nbt.TAG_List(), nbt.TAG_List()))
            for i, e in enumerate((self.Entities, self.TileEntities, self.TileTicks)):
                for ent in e:
                    x, y, z = [Entity, TileEntity, TileTick][i].pos(ent)
                    ecx, ecz = map(lambda x: (int(floor(x)) >> 4), (x, z))

                    self._fakeEntities[ecx, ecz][i].append(ent)

        return self._fakeEntities[cx, cz]


class ChunkBase(EntityLevel):
    dirty = False
    needsLighting = False

    chunkPosition = NotImplemented
    Blocks = Data = SkyLight = BlockLight = HeightMap = NotImplemented  # override these!

    Width = Length = 16

    @property
    def Height(self):
        return self.world.Height

    @property
    def bounds(self):
        cx, cz = self.chunkPosition
        return BoundingBox((cx << 4, 0, cz << 4), self.size)

    def chunkChanged(self, needsLighting=True):
        self.dirty = True
        self.needsLighting = needsLighting or self.needsLighting

    @property
    def materials(self):
        return self.world.materials

    def getChunkSlicesForBox(self, box):
        """
         Given a BoundingBox enclosing part of the world, return a smaller box enclosing the part of this chunk
         intersecting the given box, and a tuple of slices that can be used to select the corresponding parts
         of this chunk's block and data arrays.
        """
        bounds = self.bounds
        localBox = box.intersect(bounds)

        slices = (
            slice(localBox.minx - bounds.minx, localBox.maxx - bounds.minx),
            slice(localBox.minz - bounds.minz, localBox.maxz - bounds.minz),
            slice(localBox.miny - bounds.miny, localBox.maxy - bounds.miny),
        )
        return localBox, slices


class FakeChunk(ChunkBase):
    @property
    def HeightMap(self):
        if hasattr(self, "_heightMap"):
            return self._heightMap
        self._heightMap = computeChunkHeightMap(self.materials, self.Blocks)
        return self._heightMap


class LightedChunk(ChunkBase):
    def generateHeightMap(self):
        computeChunkHeightMap(self.materials, self.Blocks, self.HeightMap)

    def chunkChanged(self, calcLighting=True):
        """ You are required to call this function after you are done modifying
        the chunk. Pass False for calcLighting if you know your changes will
        not change any lights."""

        self.dirty = True
        self.needsLighting = calcLighting or self.needsLighting
        self.generateHeightMap()
        if calcLighting:
            self.genFastLights()

    def genFastLights(self):
        self.SkyLight[:] = 0
        if self.world.dimNo in (-1, 1):
            return  # no light in nether or the end

        blocks = self.Blocks
        la = self.world.materials.lightAbsorption
        skylight = self.SkyLight
        heightmap = self.HeightMap

        for x, z in itertools.product(xrange(16), xrange(16)):
            skylight[x, z, heightmap[z, x]:] = 15
            lv = 15
            for y in reversed(range(heightmap[z, x])):
                lv -= (la[blocks[x, z, y]] or 1)

                if lv <= 0:
                    break
                skylight[x, z, y] = lv
