package electrosphere.renderer.actor;

import electrosphere.engine.Globals;
import electrosphere.entity.state.AnimationPriorities;
import electrosphere.game.data.common.TreeDataAnimation;
import electrosphere.game.data.creature.type.bonegroups.BoneGroup;
import electrosphere.logger.LoggerInterface;
import electrosphere.renderer.OpenGLState;
import electrosphere.renderer.RenderPipelineState;
import electrosphere.renderer.model.Bone;
import electrosphere.renderer.model.Model;
import electrosphere.renderer.texture.Texture;

import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

import org.joml.AxisAngle4f;
import org.joml.Matrix4d;
import org.joml.Matrix4f;
import org.joml.Quaterniond;
import org.joml.Sphered;
import org.joml.Vector3d;
import org.joml.Vector3f;
import org.joml.Vector4d;

/**
 * An actor
 * Container for a model that keeps track of:
 * - Animation state
 * - Mesh overrides
 * - Shader overrides
 * - Texture overrides
 * - Bone overrides
 * - Static Morphs (think scaling something, but from creatures.json)
 */
public class Actor {

    /**
     * Returned when the current time is requested of an animation that the actor is not playing
     */
    public static final int INVALID_ANIMATION = -1;

    //the model path of the model backing the actor
    String modelPath;

    //used for statically binding textures in pipelines that dont bind textures on a per-mesh level
    //ie when in the ui, this can be set to bind a texture at the actor level
    String textureOverride;

    //scales the time that animations are played at
    float animationScalar = 1.0f;

    //The stack of animations being applied to a given actor
    Set<ActorAnimationMask> animationQueue = new TreeSet<ActorAnimationMask>();

    //Mask for overwriting meshes in a given actor
    ActorMeshMask meshMask = new ActorMeshMask();

    //optional overrides for specific shaders
    List<ActorShaderMask> shaderMasks = new LinkedList<ActorShaderMask>();

    //optional overrides for textures
    Map<String,ActorTextureMask> textureMap = null;

    //bone rotators
    Map<String,ActorBoneRotator> boneRotators = new HashMap<String,ActorBoneRotator>();

    //static morph for this specific actor
    ActorStaticMorph staticMorph;

    //The list of bone groups
    List<BoneGroup> boneGroups;

    //Controls whether the actor should obey frustum culling
    boolean frustumCull = true;
    
    public Actor(String modelPath){
        this.modelPath = modelPath;
    }
    
    List<ActorAnimationMask> toRemoveMasks = new LinkedList<ActorAnimationMask>();
    public void incrementAnimationTime(double deltaTime){
        toRemoveMasks.clear();
        for(ActorAnimationMask mask : animationQueue){
            mask.setTime(mask.getTime() + deltaTime * animationScalar);
            if(mask.getTime() > mask.getDuration()){
                toRemoveMasks.add(mask);
            }
        }
        for(ActorAnimationMask mask : toRemoveMasks){
            animationQueue.remove(mask);
        }
    }
    
    /**
     * Gets the current time of the given animation that is being played on this actor
     * @param animation The animation's name
     * @return The time into the animation, -1 if the animation is not being played
     */
    public double getAnimationTime(String animation){
        ActorAnimationMask mask = this.getAnimationMask(animation);
        if(mask != null){
            return mask.time;
        }
        return INVALID_ANIMATION;
    }

    /**
     * Checks if an animation is being played on any meshes
     * @param animationName The animation name
     * @return true if the animation is being played on any meshes, false if the provided animation name is null or the animation is not being played on any meshes
     */
    public boolean isPlayingAnimation(String animationName){
        if(animationName == null){
            return false;
        }
        for(ActorAnimationMask mask : animationQueue){
            if(mask.getAnimationName().equals(animationName)){
                return true;
            }
        }
        return false;
    }

    /**
     * Checks if an animation is being played on any meshes
     * @param animationData The animation data
     * @return true if the animation is being played on any meshes, false if the provided animation name is null or the animation is not being played on any meshes
     */
    public boolean isPlayingAnimation(TreeDataAnimation animationData){
        if(animationData == null){
            return false;
        }
        for(ActorAnimationMask mask : animationQueue){
            if(mask.getAnimationName().contains(animationData.getNameFirstPerson())){
                return true;
            }
            if(mask.getAnimationName().contains(animationData.getNameThirdPerson())){
                return true;
            }
        }
        return false;
    }

    public boolean isPlayingAnimation(){
        return animationQueue.size() > 0;
    }

    public void stopAnimation(String animationName){
        List<ActorAnimationMask> toRemove = new LinkedList<ActorAnimationMask>();
        for(ActorAnimationMask mask : animationQueue){
            if(mask.getAnimationName().contains(animationName)){
                toRemove.add(mask);
            }
        }
        for(ActorAnimationMask mask : toRemove){
            animationQueue.remove(mask);
        }
    }
    
    /**
     * Plays an animation provided as a string with a given priority
     * @param animationName The name of the animation
     * @param priority The priority of the animation
     */
    public void playAnimation(String animationName, int priority){
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null && model.getAnimation(animationName) != null){
            double length = model.getAnimation(animationName).duration;
            ActorAnimationMask animMask = new ActorAnimationMask(priority, animationName, 0, length);
            for(Bone bone : model.getBones()){
                animMask.addBone(bone.boneID);
            }
            toRemoveMasks.clear();
            for(ActorAnimationMask currentMask : animationQueue){
                if(currentMask.getPriority() == animMask.getPriority()){
                    toRemoveMasks.add(currentMask);
                    break;
                }
            }
            for(ActorAnimationMask currentMask : toRemoveMasks){
                animationQueue.remove(currentMask);
            }
            animationQueue.add(animMask);
        }
    }

    /**
     * Plays animation data
     * @param animation The animation data
     * @param isThirdPerson true if is third person, false if is first person
     */
    public void playAnimation(TreeDataAnimation animation, boolean isThirdPerson){

        //Get the animation's name
        String animationName = "";
        if(isThirdPerson){
            animationName = animation.getNameThirdPerson();
        } else {
            animationName = animation.getNameFirstPerson();
        }

        //Get the animation's priority
        int priority = AnimationPriorities.getValue(AnimationPriorities.DEFAULT);
        if(animation.getPriority() != null){
            priority = animation.getPriority();
        }
        if(animation.getPriorityCategory() != null){
            priority = AnimationPriorities.getValue(animation.getPriorityCategory());
        }

        //Gets the mask
        List<String> boneMask = null;
        if(animation.getBoneGroups() != null && this.boneGroups != null){
            boneMask = new LinkedList<String>();
            for(String boneGroupName : animation.getBoneGroups()){
                BoneGroup group = null;

                for(BoneGroup currentGroup : this.boneGroups){
                    if(currentGroup.getId().equals(boneGroupName)){
                        group = currentGroup;
                        break;
                    }
                }

                if(group != null && isThirdPerson == true && group.getBoneNamesThirdPerson() != null && group.getBoneNamesThirdPerson().size() > 0){
                    boneMask.addAll(group.getBoneNamesThirdPerson());
                }
                if(group != null && isThirdPerson == false && group.getBoneNamesFirstPerson() != null &&  group.getBoneNamesFirstPerson().size() > 0){
                    boneMask.addAll(group.getBoneNamesFirstPerson());
                }
            }
        } else if(this.boneGroups == null){
            LoggerInterface.loggerRenderer.WARNING("Trying to play animation on pose actor that uses bone groups, but the actor's bone group isn't defined!");
        }


        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null && model.getAnimation(animationName) != null){

            //get data from the actual animation in the model
            double length = model.getAnimation(animationName).duration;

            //construct the animation mask
            ActorAnimationMask animMask;
            if(boneMask == null){
                animMask = new ActorAnimationMask(
                    priority,
                    animationName,
                    length
                );
            } else {
                animMask = new ActorAnimationMask(
                    priority,
                    animationName,
                    length,
                    boneMask
                );
            }

            //if a mask wasn't defined, apply this mask to all animations
            if(boneMask == null){
                for(Bone bone : model.getBones()){
                    animMask.addBone(bone.boneID);
                }
            }


            //clear existing masks that are lower priority
            toRemoveMasks.clear();
            for(ActorAnimationMask currentMask : animationQueue){
                if(currentMask.getPriority() == animMask.getPriority()){
                    toRemoveMasks.add(currentMask);
                    break;
                }
            }
            for(ActorAnimationMask currentMask : toRemoveMasks){
                animationQueue.remove(currentMask);
            }
            animationQueue.add(animMask);
        }
    }

    /**
     * Interrupts an animation, thereby causing it to stop playing
     * @param animation The animation to interrupt
     */
    public void interruptAnimation(TreeDataAnimation animation, boolean isThirdPerson){
        //Get the animation's name
        String animationName = "";
        if(isThirdPerson){
            animationName = animation.getNameThirdPerson();
        } else {
            animationName = animation.getNameFirstPerson();
        }

        //Get the animation's priority
        int priority = AnimationPriorities.getValue(AnimationPriorities.DEFAULT);
        if(animation.getPriority() != null){
            priority = animation.getPriority();
        }
        if(animation.getPriorityCategory() != null){
            priority = AnimationPriorities.getValue(animation.getPriorityCategory());
        }

        toRemoveMasks.clear();
        for(ActorAnimationMask mask : this.animationQueue){
            if(mask.animationName == animationName && mask.priority == priority){
                toRemoveMasks.add(mask);
            }
        }
        for(ActorAnimationMask currentMask : toRemoveMasks){
            animationQueue.remove(currentMask);
        }
    }

    public void playAnimationWithMask(String animationName, int priority, List<String> boneMask){
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null){
            double length = model.getAnimation(animationName).duration;
            ActorAnimationMask animMask = new ActorAnimationMask(priority, animationName, 0, length, boneMask);
            toRemoveMasks.clear();
            for(ActorAnimationMask currentMask : animationQueue){
                if(currentMask.getPriority() == animMask.getPriority()){
                    toRemoveMasks.add(currentMask);
                    break;
                }
            }
            for(ActorAnimationMask currentMask : toRemoveMasks){
                animationQueue.remove(currentMask);
            }
            animationQueue.add(animMask);
        }
    }

    /**
     * Gets the animation queue
     * @return The animation queue
     */
    public Set<ActorAnimationMask> getAnimationQueue(){
        return animationQueue;
    }

    /**
     * Applies the animation masks in this actor to the provided model
     * @param model The model
     */
    void applyAnimationMasks(Model model){
        List<String> bonesUsed = new LinkedList<String>();
        List<String> currentAnimationMask = new LinkedList<String>();
        for(ActorAnimationMask mask : animationQueue){
            currentAnimationMask.clear();
            for(String currentBone : mask.boneMask){
                if(!bonesUsed.contains(currentBone)){
                    bonesUsed.add(currentBone);
                    currentAnimationMask.add(currentBone);
                }
            }
            model.applyAnimationMask(mask.getAnimationName(), mask.getTime(), currentAnimationMask);
        }
    }

    void calculateNodeTransforms(Model model){
        model.updateNodeTransform(boneRotators,staticMorph);
    }
    
    public void setAnimationScalar(float animationScalar) {
        this.animationScalar = animationScalar;
    }

    /**
     * Gets the animation mask for a given animation
     * @param animationName The animation's name
     * @return The animation mask if the actor is playing the animation, null otherwise
     */
    public ActorAnimationMask getAnimationMask(String animationName){
        for(ActorAnimationMask mask : this.getAnimationQueue()){
            if(mask.animationName.equals(animationName)){
                return mask;
            } else if(mask.animationName.equalsIgnoreCase(animationName)){
                LoggerInterface.loggerEngine.WARNING("Animation mask failed to find, but there is an animation with a very similar name! " + animationName + " vs " + mask.animationName);
            }
        }
        return null;
    }
    
    /**
     * Applies spatial data on the actor, this includes the model matrix as well as the real world position
     * @param modelMatrix The model matrix
     * @param worldPos The world position of the model
     */
    public void applySpatialData(Matrix4d modelMatrix, Vector3d worldPos){
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null){
            model.setModelMatrix(modelMatrix);
            model.setWorldPos(worldPos);
        }
    }
    
    /**
     * Draws an actor
     * @param renderPipelineState The render pipeline state to draw within
     */
    public void draw(RenderPipelineState renderPipelineState, OpenGLState openGLState){
        Model model = Globals.assetManager.fetchModel(modelPath);
        boolean hasDrawn = false;
        if(model != null && isWithinFrustumBox(renderPipelineState,model,frustumCull)){
            applyAnimationMasks(model);
            meshMask.processMeshMaskQueue();
            model.setMeshMask(meshMask);
            model.setTextureMask(textureMap);
            // if(!meshMask.isEmpty()){
            // }
            // if(animation != null){
            //     model.playAnimation(animation);
            //     model.incrementTime(0.001);
            //     model.incrementTime(animationTime);
            //     if(model.currentAnimation == null){
            //         playingAnimation = false;
            //     }
            // }
            for(ActorShaderMask shaderMask : shaderMasks){
                if(shaderMask.getModelName().equals(modelPath)){
                    model.getShaderMask().put(shaderMask.getMeshName(),shaderMask);
                }
            }
            calculateNodeTransforms(model);
            if(textureOverride != null){
                Texture overrideTextureObject = Globals.assetManager.fetchTexture(textureOverride);
                if(overrideTextureObject != null){
                    overrideTextureObject.bind(openGLState);
                    hasDrawn = true;
                    model.draw(renderPipelineState,openGLState);
                }
            }
            if(!hasDrawn){
                model.draw(renderPipelineState,openGLState);
            }
            model.getShaderMask().clear();
            model.setTextureMask(null);
        }
    }
    
    public void drawUI(){
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null){
            model.drawUI();
        }
    }
    
    public String getModelPath(){
        return modelPath;
    }
    
    /**
     * Gets the position of a bone in local space
     * @param boneName The name of the bone
     * @return The vector3f containing the position of the bone, or a vector of (0,0,0) if the model lookup fails
     * //TODO: refactor to make failure more transparent (both for model not existing and bone not existing)
     */
    public Vector3f getBonePosition(String boneName){
        Vector3f rVal = new Vector3f();
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null){
            applyAnimationMasks(model);
            calculateNodeTransforms(model);
            Bone currentBone = model.getBoneMap().get(boneName);
            if(currentBone != null){
                Vector4d result = new Matrix4d(currentBone.getFinalTransform()).transform(currentBone.getMOffset().invert().transform(new Vector4d(rVal.x,rVal.y,rVal.z,1)));
                rVal.x = (float)result.x;
                rVal.y = (float)result.y;
                rVal.z = (float)result.z;
            } else {
                String message = "Trying to get position of bone that does not exist on model!\n" +
                "boneName: " + boneName;
                throw new IllegalArgumentException(message);
            }
        }
        if(!Double.isFinite(rVal.x)){
            throw new IllegalStateException("Bone position that is not finite!");
        }
        return rVal;
    }
    
    /**
     * Gets the rotation of a bone in local space
     * @param boneName The name of the bone
     * @return The Quaterniond containing the rotation of the bone, or an identity Quaterniond if the lookup fails
     */
    public Quaterniond getBoneRotation(String boneName){
        Quaterniond rVal = new Quaterniond();
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null){
            applyAnimationMasks(model);
            calculateNodeTransforms(model);
            Bone currentBone = model.getBoneMap().get(boneName);
            if(currentBone != null){
                AxisAngle4f axisAngle = new AxisAngle4f();
                new Matrix4f(currentBone.getFinalTransform()).getRotation(axisAngle);
                Quaterniond rotation = new Quaterniond(axisAngle);
                rVal.set(rotation);
            } else {
                String message = "Trying to get rotation of bone that does not exist on model!\n" +
                "boneName: " + boneName;
                throw new IllegalArgumentException(message);
            }
        }
        if(!Double.isFinite(rVal.x)){
            throw new IllegalStateException("Bone rotation that is not finite!");
        }
        return rVal;
    }
    
    public Matrix4d getBoneTransform(String boneName){
        Matrix4d rVal = new Matrix4d();
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null){
            applyAnimationMasks(model);
            calculateNodeTransforms(model);
            Bone currentBone = model.getBoneMap().get(boneName);
            if(currentBone != null){
                rVal = currentBone.getFinalTransform();
            } else {
                throw new IllegalArgumentException("Trying to get rotation of bone that does not exist on model!");
            }
        }
        if(!Double.isFinite(rVal.m00())){
            throw new IllegalStateException("Bone rotation that is not finite!");
        }
        return rVal;
    }

    /**
     * Gets the list of all bones
     * @return the list of all bones
     */
    public List<Bone> getBoneValues(){
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null){
            applyAnimationMasks(model);
            calculateNodeTransforms(model);
            return model.getBones();
        }
        return null;
    }

    /**
     * Gets the value of a single bone
     * @return the bone
     */
    public Bone getBone(String boneName){
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null){
            return model.getBoneMap().get(boneName);
        }
        return null;
    }

    public boolean modelIsLoaded(){
        Model model = Globals.assetManager.fetchModel(modelPath);
        if(model != null){
            return true;
        } else {
            return false;
        }
    }
    
    public void setTextureOverride(String override){
        textureOverride = override;
    }
    
    public ActorMeshMask getMeshMask(){
        return meshMask;
    }

    /**
     * Masks a shader with another shader
     * @param mesh The mesh to apply the mask on
     * @param vertexShader the vertex shader to apply
     * @param fragmentShader the fragment shader to apply
     */
    public void maskShader(String mesh, String vertexShader, String fragmentShader){
        shaderMasks.add(new ActorShaderMask(this.modelPath, mesh, vertexShader, null, fragmentShader));
    }

    /**
     * Masks a shader with another shader
     * @param mesh the mesh to apply the mask on
     * @param vertexShader the vertex shader to apply
     * @param geometryShader the geometry shader to apply
     * @param fragmentShader the fragment shader to apply
     */
    public void maskShader(String mesh, String vertexShader, String geometryShader, String fragmentShader){
        shaderMasks.add(new ActorShaderMask(this.modelPath, mesh, vertexShader, geometryShader, fragmentShader));
    }

    /**
     * Unmasks a shader
     * @param mesh the mesh to unmask
     */
    public void unmaskShader(String mesh){
        throw new UnsupportedOperationException("Not implemented yet");
    }

    public void addTextureMask(ActorTextureMask textureMask){
        if(textureMap == null){
            textureMap = new HashMap<String,ActorTextureMask>();
        }
        textureMap.put(textureMask.getMeshName(),textureMask);
    }


    public void addBoneRotator(String bone, ActorBoneRotator rotator){
        boneRotators.put(bone, rotator);
    }

    public ActorBoneRotator getBoneRotator(String bone){
        return boneRotators.get(bone);
    }

    public void setActorStaticMorph(ActorStaticMorph staticMorph){
        this.staticMorph = staticMorph;
    }

    public ActorStaticMorph getStaticMorph(){
        return this.staticMorph;
    }
    
    //set should frustum cull
    public void setFrustumCull(boolean frustumCull){
        this.frustumCull = frustumCull;
    }

    //get should frustum cull
    public boolean getFrustumCull(){
        return frustumCull;
    }

    /**
     * Sets the bone groups in the actor
     * @param boneGroups The bone groups
     */
    public void setBoneGroups(List<BoneGroup> boneGroups){
        this.boneGroups = boneGroups;
    }

    

    /**
     * Checks if a given model is within the render pipeline state's frustum box
     * @param renderPipelineState The render pipeline state
     * @param model The model
     * @param frustumCull Controls whether the frustum cull should actually be executed or not
     * @return true if it is within the box, false otherwise
     */
    static boolean isWithinFrustumBox(RenderPipelineState renderPipelineState, Model model, boolean frustumCull){
        if(!frustumCull){
            return true;
        }
        Sphered sphere = model.getBoundingSphere();
        Vector3d modelPosition = model.getModelMatrix().getTranslation(new Vector3d());
        return renderPipelineState.getFrustumIntersection().testSphere((float)(sphere.x + modelPosition.x), (float)(sphere.y + modelPosition.y), (float)(sphere.z + modelPosition.z), (float)sphere.r);
    }


}
