package electrosphere.client.terrain.cells;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.joml.Vector3d;
import org.joml.Vector3i;

import electrosphere.client.terrain.cache.ChunkData;
import electrosphere.client.terrain.cells.DrawCell.DrawCellFace;
import electrosphere.client.terrain.manager.ClientTerrainManager;
import electrosphere.engine.Globals;
import electrosphere.entity.EntityUtils;
import electrosphere.net.parser.net.message.TerrainMessage;
import electrosphere.renderer.shader.ShaderProgram;
import electrosphere.server.terrain.manager.ServerTerrainChunk;

/**
 * Manages the graphical entities for the terrain chunks
 * 
 * 
 * 
Notes for integrating with transvoxel algo:
Different problems to tackle

For all chunks within minimum radius, check if they can be updated and update accordingly <--- we do this currently


For all chunks between minimum radius and first LOD radius, check if we can make a LOD chunk
If we can, make a lod chunk or see if it can be updated
This check will be:
For every position, check if all four positions required for LOD chunk are within lod radius
 if yes, make lod chunk

If we cannot, create a fullres chunk
This check will be:
For every position, check if all four positions required for LOD chunk are within lod radius
 if yes, make lod chunk
 if they are outside the far bound, create lod chunk
 if they are within the near bound, create a fullres chunk
 


 */
public class DrawCellManager {
    
    
    //the center of this cell manager's array in cell space
    int cellX;
    int cellY;
    int cellZ;
    
    //all currently displaying mini cells
    Set<DrawCell> cells = new HashSet<DrawCell>();
    Map<String,DrawCell> keyCellMap = new HashMap<String,DrawCell>();
    
    //status of all position keys
    Set<String> hasNotRequested = new HashSet<String>();
    Set<String> requested = new HashSet<String>();
    Set<String> drawable = new HashSet<String>();
    Set<String> undrawable = new HashSet<String>();
    Set<String> updateable = new HashSet<String>();

    //LOD level of all position keys
    Map<String,Integer> positionLODLevel = new HashMap<String,Integer>();

    //voxel atlas
    VoxelTextureAtlas atlas;
    
    //shader program for drawable cells
    ShaderProgram program;
    
    
    
    //the real-space radius for which we will construct draw cells inside of
    //ie, we check if the draw cell's entity would be inside this radius. If it would, create the draw cell, otherwise don't
    double drawFullModelRadius = 50;

    //the radius we'll draw LODed chunks for
    double drawLODRadius = drawFullModelRadius + ServerTerrainChunk.CHUNK_DIMENSION * (2*2 + 4*4 + 8*8 + 16*16);


    //the number of possible LOD levels
    //1,2,4,8,16
    static final int NUMBER_OF_LOD_LEVELS = 5;

    //the table of lod leve -> radius at which we will look for chunks within this log
    double[] lodLevelRadiusTable = new double[5];
    
    //the radius for which physics meshes are created when draw cells are created
    int physicsRadius = 3;
    
    //ready to start updating?
    boolean update = false;

    //controls whether we try to generate the drawable entities
    //we want this to be false when in server-only mode
    boolean generateDrawables = false;
    
    
    /**
     * DrawCellManager constructor
     * @param commonWorldData The common world data
     * @param clientTerrainManager The client terrain manager
     * @param discreteX The initial discrete position X coordinate
     * @param discreteY The initial discrete position Y coordinate
     */
    public DrawCellManager(ClientTerrainManager clientTerrainManager, int discreteX, int discreteY, int discreteZ){
        cellX = discreteX;
        cellY = discreteY;
        cellZ = discreteZ;
        
        program = Globals.terrainShaderProgram;

        //the first lod level is set by user
        lodLevelRadiusTable[0] = drawFullModelRadius;
        //generate LOD radius table
        for(int i = 1; i < NUMBER_OF_LOD_LEVELS; i++){
            double sizeOfSingleModel = Math.pow(2,i) * ServerTerrainChunk.CHUNK_DIMENSION;
            //size of the radius for this lod level should be three times the size of a model + the previous radius
            //this guarantees we get at least one adapter chunk, one proper chunk, and also that the radius accounts for the previous lod level chunks
            lodLevelRadiusTable[i] = lodLevelRadiusTable[i-1] + sizeOfSingleModel * 3;
        }
        
        physicsRadius = Globals.userSettings.getGameplayPhysicsCellRadius();
        
        invalidateAllCells();

        update = true;
    }
    
    /**
     * Private constructor
     */
    DrawCellManager(){
        
    }

    /**
     * Sets the player's current position in cell-space
     * @param cellPos The cell's position
     */
    public void setPlayerCell(Vector3i cellPos){
        cellX = cellPos.x;
        cellY = cellPos.y;
        cellZ = cellPos.z;
    }
    
    /**
     * Update function that is called if a cell has not been requested
     */
    void updateUnrequestedCell(){
        if(hasNotRequested.size() > 0){
            String targetKey = hasNotRequested.iterator().next();
            hasNotRequested.remove(targetKey);
            Vector3i worldPos = getVectorFromKey(targetKey);

            //
            //Because of the way marching cubes works, we need to request the adjacent chunks so we know how to properly blend between one chunk and the next
            //The following loop-hell does this
            //
            for(int i = 0; i < 2; i++){
                for(int j = 0; j < 2; j++){
                    for(int k = 0; k < 2; k++){
                        Vector3i posToCheck = new Vector3i(worldPos).add(i,j,k);
                        String requestKey = getCellKey(posToCheck.x,posToCheck.y,posToCheck.z);
                        if(
                            posToCheck.x >= 0 &&
                            posToCheck.x < Globals.clientWorldData.getWorldDiscreteSize() &&
                            posToCheck.y >= 0 &&
                            posToCheck.y < Globals.clientWorldData.getWorldDiscreteSize() &&
                            posToCheck.z >= 0 &&
                            posToCheck.z < Globals.clientWorldData.getWorldDiscreteSize() &&
                            !Globals.clientTerrainManager.containsChunkDataAtWorldPoint(posToCheck.x, posToCheck.y, posToCheck.z)
                            ){
                            if(!requested.contains(requestKey)){
                                //client should request chunk data from server for each chunk necessary to create the model
                                Globals.clientConnection.queueOutgoingMessage(TerrainMessage.constructRequestChunkDataMessage(
                                    posToCheck.x,
                                    posToCheck.y,
                                    posToCheck.z
                                ));
                            }
                        }
                    }
                }
            }
            undrawable.add(targetKey);
            requested.add(targetKey);
        }
    }
    
    /**
     * Makes one of the undrawable cells drawable
     */
    void makeCellDrawable(){
        if(undrawable.size() > 0){
            String targetKey = undrawable.iterator().next();
            Vector3i worldPos = getVectorFromKey(targetKey);

            //
            //Checks if all chunk data necessary to generate a mesh is present
            boolean containsNecessaryChunks = true;
            for(int i = 0; i < 2; i++){
                for(int j = 0; j < 2; j++){
                    for(int k = 0; k < 2; k++){
                        Vector3i posToCheck = new Vector3i(worldPos).add(i,j,k);
                        if(worldPos.x >= 0 &&
                        posToCheck.x < Globals.clientWorldData.getWorldDiscreteSize() &&
                        posToCheck.y >= 0 &&
                        posToCheck.y < Globals.clientWorldData.getWorldDiscreteSize() &&
                        posToCheck.z >= 0 &&
                        posToCheck.z < Globals.clientWorldData.getWorldDiscreteSize() &&
                        !containsChunkDataAtWorldPoint(posToCheck.x,posToCheck.y,posToCheck.z)
                        ){
                            containsNecessaryChunks = false;
                        }
                    }
                }
            }
            //
            //if contains data for all chunks necessary to generate visuals
            if(containsNecessaryChunks){

                //update the status of the terrain key
                undrawable.remove(targetKey);
                drawable.add(targetKey);

                //build the cell
                DrawCell cell = DrawCell.generateTerrainCell(
                    worldPos
                );
                cells.add(cell);
                keyCellMap.put(targetKey,cell);
                DrawCellFace higherLODFace = null;
                keyCellMap.get(targetKey).generateDrawableEntity(atlas,0,higherLODFace);
                
                //evaluate for foliage
                Globals.clientFoliageManager.evaluateChunk(worldPos);
            }
        }
    }
    
    /**
     * Updates a cell that can be updated
     */
    void updateCellModel(){
        if(updateable.size() > 0){
            String targetKey = updateable.iterator().next();
            updateable.remove(targetKey);
            Vector3i worldPos = getVectorFromKey(targetKey);
            if(
                worldPos.x >= 0 &&
                worldPos.x < Globals.clientWorldData.getWorldDiscreteSize() &&
                worldPos.y >= 0 &&
                worldPos.y < Globals.clientWorldData.getWorldDiscreteSize() &&
                worldPos.z >= 0 &&
                worldPos.z < Globals.clientWorldData.getWorldDiscreteSize()
                    ){
                keyCellMap.get(targetKey).destroy();
                DrawCellFace higherLODFace = null;
                keyCellMap.get(targetKey).generateDrawableEntity(atlas,0,higherLODFace);
            }
            drawable.add(targetKey);
        }
    }



    
    
    /**
     * Checks if the manager contains a cell position that hasn't had its chunk data requested from the server yet
     * @return true if there is an unrequested cell, false otherwise
     */
    public boolean containsUnrequestedCell(){
        return hasNotRequested.size() > 0;
    }
    
    /**
     * Checks if the manager contains a cell who hasn't been made drawable yet
     * @return true if there is an undrawable cell, false otherwise
     */
    public boolean containsUndrawableCell(){
        return undrawable.size() > 0;
    }
    
    /**
     * Checks if the manager contains a cell who needs to be updated
     * @return true if there is an updateable cell, false otherwise
     */
    public boolean containsUpdateableCell(){
        return updateable.size() > 0;
    }
    
    
    
    /**
     * Transforms a real coordinate into a cell-space coordinate
     * @param input the real coordinate
     * @return the cell coordinate
     */
    public int transformRealSpaceToCellSpace(double input){
        return (int)(input / Globals.clientWorldData.getDynamicInterpolationRatio());
    }
    
    /**
     * Clears the valid set and adds all keys to invalid set
     */
    public void invalidateAllCells(){
        drawable.clear();
        hasNotRequested.clear();
        clearOutOfBoundsCells();
        queueNewCells();
    }
    
    /**
     * Calculates whether the position of the player has changed and if so, invalidates and cleans up cells accordingly
     */
    private void calculateDeltas(){
        //check if any not requested cells no longer need to be requested
        clearOutOfBoundsCells();
        //check if any cells should be added
        queueNewCells();
    }

    /**
     * Clears all cells outside of draw radius
     */
    private void clearOutOfBoundsCells(){
        Set<DrawCell> cellsToRemove = new HashSet<DrawCell>();
        for(DrawCell cell : cells){
            Vector3d realPos = cell.getRealPos();
            if(Globals.playerEntity != null && EntityUtils.getPosition(Globals.playerEntity).distance(realPos) > drawFullModelRadius){
                cellsToRemove.add(cell);
            }
        }
        for(DrawCell cell : cellsToRemove){
            cells.remove(cell);
            String key = getCellKey(cell.worldPos.x, cell.worldPos.y, cell.worldPos.z);
            hasNotRequested.remove(key);
            drawable.remove(key);
            undrawable.remove(key);
            updateable.remove(key);
            keyCellMap.remove(key);
            requested.remove(key);
            cell.destroy();
        }
    }

    /**
     * Queues new cells that are in bounds but not currently accounted for
     */
    private void queueNewCells(){
        if(Globals.playerEntity != null && Globals.clientWorldData != null){
            Vector3d playerPos = EntityUtils.getPosition(Globals.playerEntity);
            for(int x = -(int)drawFullModelRadius; x < drawFullModelRadius; x = x + ChunkData.CHUNK_SIZE){
                for(int y = -(int)drawFullModelRadius; y < drawFullModelRadius; y = y + ChunkData.CHUNK_SIZE){
                    for(int z = -(int)drawFullModelRadius; z < drawFullModelRadius; z = z + ChunkData.CHUNK_SIZE){
                        Vector3d newPos = new Vector3d(playerPos.x + x, playerPos.y + y, playerPos.z + z);
                        Vector3i worldPos = new Vector3i(
                            Globals.clientWorldData.convertRealToChunkSpace(newPos.x),
                            Globals.clientWorldData.convertRealToChunkSpace(newPos.y),
                            Globals.clientWorldData.convertRealToChunkSpace(newPos.z)
                        );
                        Vector3d chunkRealSpace = new Vector3d(
                            Globals.clientWorldData.convertChunkToRealSpace(worldPos.x),
                            Globals.clientWorldData.convertChunkToRealSpace(worldPos.y),
                            Globals.clientWorldData.convertChunkToRealSpace(worldPos.z)
                        );
                        if(
                            playerPos.distance(chunkRealSpace) < drawFullModelRadius &&
                            worldPos.x >= 0 &&
                            worldPos.x < Globals.clientWorldData.getWorldDiscreteSize() &&
                            worldPos.y >= 0 &&
                            worldPos.y < Globals.clientWorldData.getWorldDiscreteSize() &&
                            worldPos.z >= 0 &&
                            worldPos.z < Globals.clientWorldData.getWorldDiscreteSize()
                            ){
                            String key = getCellKey(
                                Globals.clientWorldData.convertRealToChunkSpace(chunkRealSpace.x),
                                Globals.clientWorldData.convertRealToChunkSpace(chunkRealSpace.y),
                                Globals.clientWorldData.convertRealToChunkSpace(chunkRealSpace.z)
                            );
                            if(!keyCellMap.containsKey(key) && !hasNotRequested.contains(key) && !undrawable.contains(key) && !drawable.contains(key) &&
                            !requested.contains(key)){
                                hasNotRequested.add(key);
                            }
                        }
                    }
                }
            }
        }
    }
    
    /**
     * Updates cells that need updating in this manager
     */
    public void update(){
        calculateDeltas();
        if(containsUnrequestedCell() && !containsUndrawableCell()){
            updateUnrequestedCell();
        } else if(containsUndrawableCell()){
            makeCellDrawable();
        } else if(containsUpdateableCell()){
            updateCellModel();
        }
    }

    /**
     * Controls whether the client generates drawable chunks or just physics chunks (ie if running a headless client)
     * @param generate true to generate graphics, false otherwise
     */
    public void setGenerateDrawables(boolean generate){
        this.generateDrawables = generate;
    }

    /**
     * Checks if the terrain cache has a chunk at a given world point
     * @param worldX the x coordinate
     * @param worldY the y coordinate
     * @param worldZ the z coordinate
     * @return true if the chunk data exists, false otherwise
     */
    boolean containsChunkDataAtWorldPoint(int worldX, int worldY, int worldZ){
        if(Globals.clientTerrainManager != null){
            return Globals.clientTerrainManager.containsChunkDataAtWorldPoint(worldX,worldY,worldZ);
        }
        return true;
    }

    /**
     * Gets the chunk data at a given point
     * @param worldX The world position x component of the cell
     * @param worldY The world position y component of the cell
     * @param worldZ The world position z component of the cell
     * @return The chunk data at the specified points
     */
    ChunkData getChunkDataAtPoint(int worldX, int worldY, int worldZ){
        return Globals.clientTerrainManager.getChunkDataAtWorldPoint(worldX,worldY,worldZ);
    }


    /**
     * Gets a unique key for the cell
     * @param worldX The world position x component of the cell
     * @param worldY The world position y component of the cell
     * @param worldZ The world position z component of the cell
     * @return The key
     */
    private String getCellKey(int worldX, int worldY, int worldZ){
        return worldX + "_" + worldY + "_" + worldZ;
    }

    /**
     * Parses a vector3i from the cell key
     * @param key The cell key
     * @return The vector3i containing the components of the cell key
     */
    private Vector3i getVectorFromKey(String key){
        String[] keyComponents = key.split("_");
        return new Vector3i(Integer.parseInt(keyComponents[0]),Integer.parseInt(keyComponents[1]),Integer.parseInt(keyComponents[2]));
    }

    /**
     * Marks a data cell as updateable (can be regenerated with a new model because the underlying data has changed)
     * @param chunkX The chunk x coordinate
     * @param chunkY The chunk y coordinate
     * @param chunkZ The chunk z coordinate
     */
    public void markUpdateable(int chunkX, int chunkY, int chunkZ){
        updateable.add(getCellKey(chunkX, chunkY, chunkZ));
    }


    /**
     * Gets the radius within which full-detail models are drawn
     * @return the radius
     */
    public double getDrawFullModelRadius(){
        return drawFullModelRadius;
    }

    /**
     * Initializes the voxel texture atlas
     */
    public void attachTextureAtlas(VoxelTextureAtlas voxelTextureAtlas){
        atlas = voxelTextureAtlas;
    }


    /**
    transvoxel algorithm spacing management

    want to specify a radius and have it be a circle so we're not wasting resolution on far off corners
    To benefit from the LOD, should have a series of for loops
    First loop iterates over (-largest radius) -> (largest radius) at an interval of the width of the lowest level of detail chunk
    We check all 8 corners of the chunk to see if they all fall within the largest lod concentric circle
    There are a few cases to consider
     - All fall outside the largest LOD radius (ignore)
     - All but one fall outside thel argest LOD radius (ignore)
     - All fall within the largest LOD circle (make LOD chunk)
     - One is within the next LOD radius, others are within the current LOD radius (ignore for this pass)
     - All are within a lower LOD radius (ignore)

    Once we're done with the largest LOD radius, we go to a higher resolution radius and iterate for smaller bounds

    In a middle LOD level, if all corners are outside, but the lower resolution didn't
    already pick it up, still need to generate a chunk at the current resolution

    Detection of this case is tricky
    We could snap the currently considered position to the nearest low-resolution chunk and then bounds check that low resolution chunk for every
    higher resolution position we're considering
    This is probably a bad idea

    We could recurse every time a chunk doesn't work for the current level of detail, then evaluate it for a higher level of detail at this closer value

     */

    //the number of corners to consider for a valid chunk
    static final int NUM_CORNERS_TO_CONSIDER = 8;

    //offsets to get from current position to the actual corner to consider
    int[] cornerOffsetsX = new int[]{0,1,0,1,0,1,0,1,};
    int[] cornerOffsetsY = new int[]{0,0,0,0,1,1,1,1,};
    int[] cornerOffsetsZ = new int[]{0,0,1,1,0,0,1,1,};

    /**
     * Recursive function that does chunk validity checking
     * @param playerChunkPos
     * @param minPoint
     * @param maxPoint
     * @param currentLOD
     */
    public void assesChunkPositionsAtLOD(
        Vector3i playerChunkPos,
        Vector3i minPoint,
        Vector3i maxPoint,
        int currentLOD
    ){
        Vector3i currentChunkPositionConsidered = new Vector3i(playerChunkPos); // The chunk position we're currently considering
        double currentRadius = lodLevelRadiusTable[currentLOD];
        double nextRadius = 0;
        int increment = 1;
        if(currentLOD > 0){
            //only works when the LOD is greater than 0. When it's 0, there is no lower bound on chunk positions to generate
            nextRadius = lodLevelRadiusTable[currentLOD-1];
            increment = (int)Math.pow(2,currentLOD);
        }

        //iterate
        for(int x = minPoint.x; x < maxPoint.x; x = x + increment){
            for(int y = minPoint.y; y < maxPoint.x; y = y + increment){
                for(int z = minPoint.z; z < maxPoint.x; z = z + increment){
                    //we have 8 corners to consider
                    //need to identify which case the current position is
                    int positionCase = 0;
                    for(int j = 0; j < NUM_CORNERS_TO_CONSIDER; j++){
                        currentChunkPositionConsidered.set(
                            x + cornerOffsetsX[j] * increment,
                            y + cornerOffsetsY[j] * increment,
                            z + cornerOffsetsZ[j] * increment
                        );
                        //figure out the case of this corner
                        double distance = currentChunkPositionConsidered.distance(playerChunkPos);
                        if(distance > currentRadius){
                            positionCase = 1;
                            break;
                        } else if(distance <= nextRadius){
                            positionCase = 2;
                            break;
                        }
                    }
                    switch(positionCase){
                        case 0: {
                            //fully within current band, generate chunk
                        } break;
                        case 1: {
                            //partially outside bound, ignore
                        } break;
                        case 2: {
                            //partially inside higher resolution bound, recurse
                            assesChunkPositionsAtLOD(
                                playerChunkPos,
                                new Vector3i(x,y,z),
                                new Vector3i(x+increment,y+increment,z+increment),
                                currentLOD
                            );
                        } break;
                    }
                }
            }
        }
    }

    /**
     * Assess all LOD chunk levels
     */
    public void assesLODChunks(){

        //pre-existing values
        Vector3d playerPosition = EntityUtils.getPosition(Globals.playerEntity);
        Vector3i playerChunkPosition = Globals.clientWorldData.convertRealToWorldSpace(playerPosition);

        //variables used while iterating across chunk positions
        double currentRadius = 0; //the current radius is in units of chunks, even though it's a double (it's discrete, not real)
        double nextRadius = 0; //the next radius is in units of chunks, even though it's a double (it's discrete, not real)
        int increment = 1; //the increment is in units of chunks, not real
        //the offsets from the player's position to
        //the nearest possible spot we could place a chunk of the current LOD at (in units of chunks)
        Vector3i lowerOffsets = new Vector3i(0,0,0);
        Vector3i currentChunkPositionConsidered = new Vector3i(playerChunkPosition); // The chunk position we're currently considering

        //actual logic to search for valid chunks
        for(int i = NUMBER_OF_LOD_LEVELS - 1; i >= 0; i--){
            currentRadius = lodLevelRadiusTable[i];
            nextRadius = 0;
            increment = 1;
            if(i > 0){
                //only works when the LOD is greater than 0. When it's 0, there is no lower bound on chunk positions to generate
                nextRadius = lodLevelRadiusTable[i-1];
                increment = (int)Math.pow(2,i);
            }
            //calculate offsets to get from player's current chunk position to the lower resolution grid for the current LOD
            lowerOffsets.x = playerChunkPosition.x % ((int)increment);
            lowerOffsets.y = playerChunkPosition.y % ((int)increment);
            lowerOffsets.z = playerChunkPosition.z % ((int)increment);

            //iterate
            for(int x = -(int)currentRadius - lowerOffsets.x; x < currentRadius; x = x + increment){
                for(int y = -(int)currentRadius - lowerOffsets.y; y < currentRadius; y = y + increment){
                    for(int z = -(int)currentRadius - lowerOffsets.z; z < currentRadius; z = z + increment){
                        //we have 8 corners to consider
                        //need to identify which case the current position is
                        int positionCase = 0;
                        for(int j = 0; j < NUM_CORNERS_TO_CONSIDER; j++){
                            currentChunkPositionConsidered.set(
                                x + cornerOffsetsX[j] * increment,
                                y + cornerOffsetsY[j] * increment,
                                z + cornerOffsetsZ[j] * increment
                            );
                            //figure out the case of this corner
                            double distance = currentChunkPositionConsidered.distance(playerChunkPosition);
                            if(distance > currentRadius){
                                positionCase = 1;
                                break;
                            } else if(distance <= nextRadius){
                                positionCase = 2;
                                break;
                            }
                        }
                        switch(positionCase){
                            case 0: {
                                //fully within current band, generate chunk
                            } break;
                            case 1: {
                                //partially outside bound, ignore
                            } break;
                            case 2: {
                                //partially inside higher resolution bound, recurse
                            } break;
                        }
                    }
                }
            }
        }
    }
    
    
}
