package sx.lambda.voxel.world.chunk;

import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Mesh;
import com.badlogic.gdx.graphics.g3d.*;
import com.badlogic.gdx.graphics.g3d.attributes.BlendingAttribute;
import com.badlogic.gdx.graphics.g3d.attributes.FloatAttribute;
import com.badlogic.gdx.graphics.g3d.attributes.TextureAttribute;
import com.badlogic.gdx.graphics.g3d.model.Node;
import com.badlogic.gdx.graphics.g3d.model.NodePart;
import com.badlogic.gdx.graphics.g3d.utils.MeshBuilder;
import com.badlogic.gdx.graphics.g3d.utils.ModelBuilder;
import com.badlogic.gdx.math.MathUtils;
import sx.lambda.voxel.RadixClient;
import sx.lambda.voxel.api.RadixAPI;
import sx.lambda.voxel.api.events.render.EventChunkRender;
import sx.lambda.voxel.block.Block;
import sx.lambda.voxel.block.NormalBlockRenderer;
import sx.lambda.voxel.block.Side;
import sx.lambda.voxel.client.render.meshing.GreedyMesher;
import sx.lambda.voxel.util.Vec3i;
import sx.lambda.voxel.world.IWorld;
import sx.lambda.voxel.world.biome.Biome;
import sx.lambda.voxel.world.chunk.BlockStorage.CoordinatesOutOfBoundsException;

import java.util.List;

public class Chunk implements IChunk {

    private static final int MAX_LIGHT_LEVEL = 15;

    private final transient GreedyMesher mesher;
    private final int size;
    private final int height;
    /**
     * Block storage, in pieces 16 high
     *
     * Done this way so that piece full of air don't take up more memory than they have to
     */
    private final FlatBlockStorage[] blockStorage;
    /**
     * Map of light levels (ints 0-15) to brightness multipliers
     */
    private final float[] lightLevelMap = new float[MAX_LIGHT_LEVEL+1];
    private final transient IWorld parentWorld;
    private final Biome biome;
    private transient MeshBuilder meshBuilder;
    private transient ModelBuilder modelBuilder;
    private transient Model opaqueModel, translucentModel;
    private transient ModelInstance opaqueModelInstance, translucentModelInstance;
    private final Vec3i startPosition;
    private int highestPoint;
    private transient boolean sunlightChanging;
    private transient boolean sunlightChanged;
    private boolean setup;
    private boolean cleanedUp;
    private boolean lighted;

    private List<GreedyMesher.Face> translucentFaces;
    private List<GreedyMesher.Face> opaqueFaces;
    private boolean meshing, meshed, meshWhenDone;

    public Chunk(IWorld world, Vec3i startPosition, Biome biome, boolean local) {
        this.parentWorld = world;
        this.startPosition = startPosition;
        this.biome = biome;
        this.size = world.getChunkSize();
        this.height = world.getHeight();

        this.blockStorage = new FlatBlockStorage[MathUtils.ceilPositive((float)this.height/16)];

        for (int i = 0; i <= MAX_LIGHT_LEVEL; i++) {
            int reduction = MAX_LIGHT_LEVEL - i;
            lightLevelMap[i] = (float) Math.pow(0.8, reduction);
        }

        if (RadixClient.getInstance() != null) {// We're a client
            mesher = new GreedyMesher(this, RadixClient.getInstance().getSettingsManager().getVisualSettings().getPerCornerLight().getValue());
        } else {
            mesher = null;
        }

        if(local)
            highestPoint = world.getChunkGen().generate(startPosition, this);
    }

    @Override
    public void rerender() {
        if (cleanedUp)
            return;

        boolean neighborSunlightChanging = false;
        for(int x = startPosition.x - 16; x <= startPosition.x + 16; x += 16) {
            for(int z = startPosition.z - 16; z <= startPosition.z + 16; z += 16) {
                IChunk c = getWorld().getChunk(x, z);
                if(c != null && c.waitingOnLightFinish()) {
                    neighborSunlightChanging = true;
                }
            }
        }
        if(neighborSunlightChanging)
            return;

        if (!setup) {
            meshBuilder = new MeshBuilder();
            modelBuilder = new ModelBuilder();

            setup = true;
        }

        sunlightChanged = false;

        if(meshing) {
            meshWhenDone = true;
        } else {
            meshing = true;
            parentWorld.addToMeshQueue(this::updateFaces);
        }
    }

    @Override
    public void render(ModelBatch batch) {
        if (cleanedUp) return;

        if(!meshing && meshed) {
            getWorld().addToChunkUploadQueue(this::updateModelInstances);
            meshed = false;
        }

        if (sunlightChanged && !sunlightChanging || (!meshing && !meshed && meshWhenDone)) {
            meshWhenDone = false;
            rerender();
        }

        if(opaqueModelInstance != null) {
            batch.render(opaqueModelInstance);
        }
    }

    @Override
    public void renderTranslucent(ModelBatch batch) {
        if(translucentModelInstance != null) {
            batch.render(translucentModelInstance);
        }
    }

    @Override
    public void eachBlock(EachBlockCallee callee) {
        for (int y = 0; y < height; y++) {
            for (int z = 0; z < size; z++) {
                BlockStorage storage = blockStorage[y / 16];
                for (int x = 0; x < size; x++) {
                    try {
                        Block blk = storage.getBlock(x, y & 0xF, z);
                        callee.call(blk, x, y, z);
                    } catch (CoordinatesOutOfBoundsException ex) {
                        ex.printStackTrace();
                    }
                }
            }
        }
    }

    private void addNeighborsToLightQueues(int x, int y, int z) {// X Y and Z are relative coords, not world coords
        assert x >= 0 && x < size && z >= 0 && z < size && y >= 0 && y < height;

        int cx = x;
        int cz = z;
        x += startPosition.x;
        z += startPosition.z;

        Side[] sides = Side.values();
        for(Side s : sides) {
            int sx = x; // Side x coord
            int sy = y; // Side y coord
            int sz = z; // Side z coord
            int scx = cx; // Chunk-relative side x coord
            int scz = cz; // Chunk-relative side z coord
            IChunk sChunk = this;

            // Offset values based on side
            switch(s) {
                case TOP:
                    sy += 1;
                    break;
                case BOTTOM:
                    sy -= 1;
                    break;
                case WEST:
                    sx -= 1;
                    scx -= 1;
                    break;
                case EAST:
                    sx += 1;
                    scx += 1;
                    break;
                case NORTH:
                    sz += 1;
                    scz += 1;
                    break;
                case SOUTH:
                    sz -= 1;
                    scz -= 1;
                    break;
            }
            if(sy < 0)
                continue;
            if(sy > height-1)
                continue;

            // Select the correct chunk
            if(scz < 0) {
                scz += size;
                sChunk = parentWorld.getChunk(sx, sz);
            } else if(scz > size-1) {
                scz -= size;
                sChunk = parentWorld.getChunk(sx, sz);
            }
            if(scx < 0) {
                scx += size;
                sChunk = parentWorld.getChunk(sx, sz);
            } else if(scx > size-1) {
                scx -= size;
                sChunk = parentWorld.getChunk(sx, sz);
            }

            if(sChunk == null)
                continue;

            try {
                int sSunlight = sChunk.getSunlight(scx, sy, scz);
                int sBlocklight = sChunk.getBlocklight(scx, sy, scz);
                if (sSunlight > 0 || sBlocklight > 0) {
                    Block sBlock = sChunk.getBlock(scx, sy, scz);
                    if (sBlock == null || sBlock.doesLightPassThrough() || !sBlock.decreasesLight()) {
                        if (sSunlight > 0)
                            parentWorld.addToSunlightQueue(sx, sy, sz);
                        if (sBlocklight > 0)
                            parentWorld.addToBlocklightQueue(sx, sy, sz);
                    }
                }
            } catch (CoordinatesOutOfBoundsException ex) {
                ex.printStackTrace();
            }
        }
    }

    @Override
    //TODO remove entirely in favor of setBlock(0 ?
    public void removeBlock(int x, int y, int z) throws CoordinatesOutOfBoundsException {
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF; // storage relative y
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null)
            return;
        storage.setBlock(x, sy, z, null);
        storage.setId(x, sy, z, 0);
        storage.setMeta(x, sy, z, 0);
        storage.setSunlight(x, sy, z, 0);
        storage.setBlocklight(x, sy, z, 0);
        // TODO XXX LIGHTING add to block light removal queue

        if(x == size - 1) {
            getWorld().rerenderChunk(getWorld().getChunk(getStartPosition().x + size, getStartPosition().z));
        } else if(x == 0) {
            getWorld().rerenderChunk(getWorld().getChunk(getStartPosition().x - size, getStartPosition().z));
        }
        if(z == size - 1) {
            getWorld().rerenderChunk(getWorld().getChunk(getStartPosition().x, getStartPosition().z + size));
        } else if(z == 0) {
            getWorld().rerenderChunk(getWorld().getChunk(getStartPosition().x, getStartPosition().z - size));
        }

        getWorld().rerenderChunk(this);

        this.addNeighborsToLightQueues(x, y, z);
    }

    @Override
    public void setBlock(int block, int x, int y, int z) throws CoordinatesOutOfBoundsException {
        setBlock(block, x, y, z, true);
    }

    @Override
    public void setBlock(int block, int x, int y, int z, boolean updateSunlight) throws CoordinatesOutOfBoundsException {
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        if(block == 0) {
            removeBlock(x, y, z);
            return;
        }

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null)
            storage = blockStorage[storageIndex] = new FlatBlockStorage(size, 16, size);

        int oldBlock = storage.getId(x, sy, z);
        Block blk = RadixAPI.instance.getBlock(block);
        int newBlocklightVal = blk.getLightValue();
        storage.setBlocklight(x, sy, z, newBlocklightVal);
        if(oldBlock > 0) {
            Block oldBlk = RadixAPI.instance.getBlock(oldBlock);
            int oldBlocklightVal = oldBlk.getLightValue();
            if(newBlocklightVal > oldBlocklightVal) {
                parentWorld.addToBlocklightQueue(startPosition.x + x, startPosition.y + y, startPosition.z + z);
            } else if(oldBlocklightVal > newBlocklightVal) {
                // TODO XXX LIGTHTING add to blocklight removal queue
            }
        } else {
            if(newBlocklightVal > 0) {
                parentWorld.addToBlocklightQueue(startPosition.x + x, startPosition.y + y, startPosition.z + z);
            }
        }

        storage.setId(x, sy, z, block);
        storage.setBlock(x, sy, z, blk);
        highestPoint = Math.max(highestPoint, y);

        if(updateSunlight)
            getWorld().addToSunlightRemovalQueue(x + startPosition.x, y + startPosition.y, z + startPosition.z);
    }

    @Override
    public void setMeta(short meta, int x, int y, int z) throws CoordinatesOutOfBoundsException {
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null)
            storage = blockStorage[storageIndex] = new FlatBlockStorage(size, 16, size);

        storage.setMeta(x, sy, z, meta);
    }

    @Override
    public short getMeta(int x, int y, int z) throws CoordinatesOutOfBoundsException {
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null)
            return 0;

        return storage.getMeta(x, sy, z);
    }

    @Override
    public float getLightLevel(int x, int y, int z) throws CoordinatesOutOfBoundsException {
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null)
            return 0;

        int sunlight = storage.getSunlight(x, sy, z);
        int blocklight = storage.getBlocklight(x, sy, z);
        return lightLevelMap[MathUtils.clamp(sunlight+blocklight, 0, MAX_LIGHT_LEVEL)];
    }

    @Override
    public Vec3i getStartPosition() {
        return this.startPosition;
    }

    @Override
    public int getHighestPoint() {
        return highestPoint;
    }

    private void updateModelInstances() {
        if(opaqueFaces != null) {
            if(opaqueModel != null)
                opaqueModel.dispose();

            Mesh opaqueMesh = mesher.meshFaces(opaqueFaces, meshBuilder);
            modelBuilder.begin();
            modelBuilder.part(String.format("c-%d,%d", startPosition.x, startPosition.z), opaqueMesh, GL20.GL_TRIANGLES,
                    new Material(TextureAttribute.createDiffuse(NormalBlockRenderer.getBlockMap())));
            opaqueModel = modelBuilder.end();

            opaqueModelInstance = new ModelInstance(opaqueModel) {
                @Override
                public Renderable getRenderable(final Renderable out, final Node node,
                                                final NodePart nodePart) {
                    super.getRenderable(out, node, nodePart);
                    if(RadixClient.getInstance().isWireframe()) {
                        out.primitiveType = GL20.GL_LINES;
                    } else {
                        out.primitiveType = GL20.GL_TRIANGLES;
                    }
                    return out;
                }
            };

            opaqueFaces = null;
        }

        if(translucentFaces != null) {
            if(translucentModel != null)
                translucentModel.dispose();

            Mesh translucentMesh = mesher.meshFaces(translucentFaces, meshBuilder);
            modelBuilder.begin();
            modelBuilder.part(String.format("c-%d,%d-t", startPosition.x, startPosition.z), translucentMesh, GL20.GL_TRIANGLES,
                    new Material(TextureAttribute.createDiffuse(NormalBlockRenderer.getBlockMap()),
                            new BlendingAttribute(),
                            FloatAttribute.createAlphaTest(0.25f)));
            translucentModel = modelBuilder.end();

            translucentModelInstance = new ModelInstance(translucentModel) {
                @Override
                public Renderable getRenderable(final Renderable out, final Node node,
                                                final NodePart nodePart) {
                    super.getRenderable(out, node, nodePart);
                    if(RadixClient.getInstance().isWireframe()) {
                        out.primitiveType = GL20.GL_LINES;
                    } else {
                        out.primitiveType = GL20.GL_TRIANGLES;
                    }
                    return out;
                }
            };

            translucentFaces = null;
        }
    }

    private void updateFaces() {
        opaqueFaces = mesher.getFaces(block -> !block.isTranslucent());
        translucentFaces = mesher.getFaces(Block::isTranslucent);
        meshing = false;
        meshed = true;
        RadixAPI.instance.getEventManager().push(new EventChunkRender(Chunk.this));
    }

    @Override
    public void dispose() {
        if(opaqueModel != null)
            opaqueModel.dispose();
        if(translucentModel != null)
            translucentModel.dispose();
        cleanedUp = true;
    }

    @Override
    public void setSunlight(int x, int y, int z, int level) throws CoordinatesOutOfBoundsException {
        assert level >= 0 && level <= MAX_LIGHT_LEVEL;
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null) {
            if(level < MAX_LIGHT_LEVEL) {
                storage = blockStorage[storageIndex] = new FlatBlockStorage(size, 16, size);
            } else {
                return;
            }
        }

        storage.setSunlight(x, sy, z, level);

        sunlightChanging = true;
        sunlightChanged = true;
    }

    @Override
    public int getSunlight(int x, int y, int z) throws CoordinatesOutOfBoundsException {
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null)
            return MAX_LIGHT_LEVEL;

        return storage.getSunlight(x, sy, z);
    }

    @Override
    public void setBlocklight(int x, int y, int z, int level) throws CoordinatesOutOfBoundsException {
        assert level >= 0 && level <= MAX_LIGHT_LEVEL;
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null) {
            if(level < MAX_LIGHT_LEVEL) {
                storage = blockStorage[storageIndex] = new FlatBlockStorage(size, 16, size);
            } else {
                return;
            }
        }

        storage.setBlocklight(x, sy, z, level);
    }

    @Override
    public int getBlocklight(int x, int y, int z) throws CoordinatesOutOfBoundsException {
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null)
            return MAX_LIGHT_LEVEL;

        return storage.getBlocklight(x, sy, z);
    }

    @Override
    public int getBlockId(int x, int y, int z) throws CoordinatesOutOfBoundsException {
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null)
            return 0;

        return storage.getId(x, sy, z);
    }

    @Override
    public Block getBlock(int x, int y, int z) throws CoordinatesOutOfBoundsException {
        if(x < 0 || x >= size || z < 0 || z >= size || y < 0 || y >= height)
            throw new CoordinatesOutOfBoundsException();

        int storageIndex = y / 16;
        int sy = y & 0xF;
        BlockStorage storage = blockStorage[storageIndex];
        if(storage == null)
            return null;

        return storage.getBlock(x, sy, z);
    }

    @Override
    public void finishChangingSunlight() {
        sunlightChanging = false;
    }

    @Override
    public boolean waitingOnLightFinish() {
        return sunlightChanging;
    }

    @Override
    public boolean hasInitialSun() {
        return lighted;
    }

    @Override
    public void finishAddingSun() {
        this.lighted = true;
    }

    @Override
    public Biome getBiome() {
        return this.biome;
    }

    @Override
    public IWorld getWorld() {
        return this.parentWorld;
    }

    @Override
    public int getMaxLightLevel() {
        return MAX_LIGHT_LEVEL;
    }

    @Override
    public float getBrightness(int lightLevel) {
        if(lightLevel > getMaxLightLevel())
            return 1;
        if(lightLevel < 0)
            return 0;
        return lightLevelMap[lightLevel];
    }

    @Override
    public int hashCode() {
        int hash = 7;
        hash = 71 * hash + this.startPosition.x;
        hash = 71 * hash + this.startPosition.z;
        return hash;
    }

    public void setHighestPoint(int y) {
        this.highestPoint = y;
    }

    /**
     * Get the underlying block storage.
     *
     * Don't use this to set data unless you know what you're doing (ex. loading a chunk initially)
     */
    public FlatBlockStorage[] getBlockStorage() {
        return blockStorage;
    }

}