/*
 * Copyright (c) 2009-2015 jMonkeyEngine
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 * * Redistributions of source code must retain the above copyright
 *   notice, this list of conditions and the following disclaimer.
 *
 * * Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 *
 * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
 *   may be used to endorse or promote products derived from this software
 *   without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
 * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package com.jme3.scene.plugins.fbx.node;

import com.jme3.animation.AnimControl;
import com.jme3.animation.Skeleton;
import com.jme3.animation.SkeletonControl;
import com.jme3.asset.AssetManager;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Matrix4f;
import com.jme3.math.Quaternion;
import com.jme3.math.Transform;
import com.jme3.math.Vector3f;
import com.jme3.renderer.queue.RenderQueue.Bucket;
import com.jme3.renderer.queue.RenderQueue.ShadowMode;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.Spatial.CullHint;
import com.jme3.scene.debug.SkeletonDebugger;
import com.jme3.scene.plugins.fbx.anim.FbxAnimCurveNode;
import com.jme3.scene.plugins.fbx.anim.FbxCluster;
import com.jme3.scene.plugins.fbx.anim.FbxLimbNode;
import com.jme3.scene.plugins.fbx.anim.FbxSkinDeformer;
import com.jme3.scene.plugins.fbx.file.FbxElement;
import com.jme3.scene.plugins.fbx.material.FbxImage;
import com.jme3.scene.plugins.fbx.material.FbxMaterial;
import com.jme3.scene.plugins.fbx.material.FbxTexture;
import com.jme3.scene.plugins.fbx.mesh.FbxMesh;
import com.jme3.scene.plugins.fbx.obj.FbxObject;
import com.jme3.util.IntMap;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

public class FbxNode extends FbxObject<Spatial> {

    private static final Logger logger = Logger.getLogger(FbxNode.class.getName());
    
    private static enum InheritMode {
        /**
         * Apply parent scale after child rotation.
         * This is the only mode correctly supported by jME3.
         */
        ScaleAfterChildRotation,
        
        /**
         * Apply parent scale before child rotation.
         * Not supported by jME3, will cause distortion with
         * non-uniform scale. No way around it.
         */
        ScaleBeforeChildRotation,
        
        /**
         * Do not apply parent scale at all.
         * Not supported by jME3, will cause distortion.
         * Could be worked around by via: 
         * <code>jmeChildScale = jmeParentScale / fbxChildScale</code>
         */
        NoParentScale
    }
    
    private InheritMode inheritMode = InheritMode.ScaleAfterChildRotation;

    protected FbxNode parent;
    protected List<FbxNode> children = new ArrayList<FbxNode>();
    protected List<FbxMaterial> materials = new ArrayList<FbxMaterial>();
    protected Map<String, Object> userData = new HashMap<String, Object>();
    protected Map<String, List<FbxAnimCurveNode>> propertyToAnimCurveMap = new HashMap<String, List<FbxAnimCurveNode>>();
    protected FbxNodeAttribute nodeAttribute;
    protected double visibility = 1.0;
    
    /**
     * For FBX nodes that contain a skeleton (i.e. FBX limbs).
     */
    protected Skeleton skeleton;
    
    protected final Transform jmeWorldNodeTransform = new Transform();
    protected final Transform jmeLocalNodeTransform = new Transform();

    // optional - used for limbs / bones / skeletons
    protected Transform jmeWorldBindPose;
    protected Transform jmeLocalBindPose;
    
    // used for debugging only
    protected Matrix4f cachedWorldBindPose;
    
    public FbxNode(AssetManager assetManager, String sceneFolderName) {
        super(assetManager, sceneFolderName);
    }
    
    public Transform computeFbxLocalTransform() {
        // TODO: implement the actual algorithm, which is this:
        // Render Local Translation =
        //      Inv Scale Pivot * Lcl Scale * Scale Pivot * Scale Offset * Inv Rota Pivot * Post Rotation * Rotation * Pre Rotation * Rotation Pivot * Rotation Offset * Translation
        
        // LclTranslation, 
        // LclRotation, 
        // PreRotation, 
        // PostRotation, 
        // RotationPivot, 
        // RotationOffset, 
        // LclScaling, 
        // ScalingPivot, 
        // ScalingOffset
        
        Matrix4f scaleMat = new Matrix4f();
        scaleMat.setScale(jmeLocalNodeTransform.getScale());
        
        Matrix4f rotationMat = new Matrix4f();
        rotationMat.setRotationQuaternion(jmeLocalNodeTransform.getRotation());
        
        Matrix4f translationMat = new Matrix4f();
        translationMat.setTranslation(jmeLocalNodeTransform.getTranslation());
        
        Matrix4f result = new Matrix4f();
        result.multLocal(scaleMat).multLocal(rotationMat).multLocal(translationMat);
        
        Transform t = new Transform();
        t.fromTransformMatrix(result);
        
        return t;
    }
    
    public void setWorldBindPose(Matrix4f worldBindPose) {
        if (cachedWorldBindPose != null) {
            if (!cachedWorldBindPose.equals(worldBindPose)) {
                throw new UnsupportedOperationException("Bind poses don't match");
            }
        }
        
        cachedWorldBindPose = worldBindPose;
        
        this.jmeWorldBindPose = new Transform();
        this.jmeWorldBindPose.setTranslation(worldBindPose.toTranslationVector());
        this.jmeWorldBindPose.setRotation(worldBindPose.toRotationQuat());
        this.jmeWorldBindPose.setScale(worldBindPose.toScaleVector());
        
        System.out.println("\tBind Pose for " + getName());
        System.out.println(jmeWorldBindPose);
        
        float[] angles = new float[3];
        jmeWorldBindPose.getRotation().toAngles(angles);
        System.out.println("Angles: " + angles[0] * FastMath.RAD_TO_DEG + ", " + 
                                        angles[1] * FastMath.RAD_TO_DEG + ", " + 
                                        angles[2] * FastMath.RAD_TO_DEG);
    }
    
    public void updateWorldTransforms(Transform jmeParentNodeTransform, Transform parentBindPose) {
        Transform fbxLocalTransform = computeFbxLocalTransform();
        jmeLocalNodeTransform.set(fbxLocalTransform);
        
        if (jmeParentNodeTransform != null) {
            jmeParentNodeTransform = jmeParentNodeTransform.clone();
            switch (inheritMode) {
                case NoParentScale:
                case ScaleAfterChildRotation:
                case ScaleBeforeChildRotation:
                    jmeWorldNodeTransform.set(jmeLocalNodeTransform);
                    jmeWorldNodeTransform.combineWithParent(jmeParentNodeTransform);
                    break;
            }
        } else {
            jmeWorldNodeTransform.set(jmeLocalNodeTransform);
        }
        
        if (jmeWorldBindPose != null) {
            jmeLocalBindPose = new Transform();
            
            // Need to derive local bind pose from world bind pose
            // (this is to be expected for FBX limbs)
            jmeLocalBindPose.set(jmeWorldBindPose);
            jmeLocalBindPose.combineWithParent(parentBindPose.invert());
            
            // Its somewhat odd for the transforms to differ ...
            System.out.println("Bind Pose for: " + getName());
            if (!jmeLocalBindPose.equals(jmeLocalNodeTransform)) {
                System.out.println("Local Bind: " + jmeLocalBindPose);
                System.out.println("Local Trans: " + jmeLocalNodeTransform);
            }
            if (!jmeWorldBindPose.equals(jmeWorldNodeTransform)) {
                System.out.println("World Bind: " + jmeWorldBindPose);
                System.out.println("World Trans: " + jmeWorldNodeTransform);
            }
        } else {
            // World pose derived from local transforms
            // (this is to be expected for FBX nodes)
            jmeLocalBindPose = new Transform();
            jmeWorldBindPose = new Transform();
            
            jmeLocalBindPose.set(jmeLocalNodeTransform);
            if (parentBindPose != null) {
                jmeWorldBindPose.set(jmeLocalNodeTransform);
                jmeWorldBindPose.combineWithParent(parentBindPose);
            } else {
                jmeWorldBindPose.set(jmeWorldNodeTransform);
            }
        }
        
        for (FbxNode child : children) {
            child.updateWorldTransforms(jmeWorldNodeTransform, jmeWorldBindPose);
        }
    }
    
    @Override
    public void fromElement(FbxElement element) {
        super.fromElement(element);
        
        Vector3f localTranslation = new Vector3f();
        Quaternion localRotation = new Quaternion();
        Vector3f localScale = new Vector3f(Vector3f.UNIT_XYZ);
        Quaternion preRotation = new Quaternion();
        
        for (FbxElement e2 : element.getFbxProperties()) {
            String propName = (String) e2.properties.get(0);
            String type = (String) e2.properties.get(3);
            if (propName.equals("Lcl Translation")) {
                double x = (Double) e2.properties.get(4);
                double y = (Double) e2.properties.get(5);
                double z = (Double) e2.properties.get(6);
                localTranslation.set((float) x, (float) y, (float) z); //.divideLocal(unitSize);
            } else if (propName.equals("Lcl Rotation")) {
                double x = (Double) e2.properties.get(4);
                double y = (Double) e2.properties.get(5);
                double z = (Double) e2.properties.get(6);
                localRotation.fromAngles((float) x * FastMath.DEG_TO_RAD, (float) y * FastMath.DEG_TO_RAD, (float) z * FastMath.DEG_TO_RAD);
            } else if (propName.equals("Lcl Scaling")) {
                double x = (Double) e2.properties.get(4);
                double y = (Double) e2.properties.get(5);
                double z = (Double) e2.properties.get(6);
                localScale.set((float) x, (float) y, (float) z); //.multLocal(unitSize);
            } else if (propName.equals("PreRotation")) {
                double x = (Double) e2.properties.get(4);
                double y = (Double) e2.properties.get(5);
                double z = (Double) e2.properties.get(6);
                preRotation.set(FbxNodeUtil.quatFromBoneAngles((float) x * FastMath.DEG_TO_RAD, (float) y * FastMath.DEG_TO_RAD, (float) z * FastMath.DEG_TO_RAD));
            } else if (propName.equals("InheritType")) {
                int inheritType = (Integer) e2.properties.get(4);
                inheritMode = InheritMode.values()[inheritType];
            } else if (propName.equals("Visibility")) {
                visibility = (Double) e2.properties.get(4);
            } else if (type.contains("U")) {
                String userDataKey = (String) e2.properties.get(0);
                String userDataType = (String) e2.properties.get(1);
                Object userDataValue;
                
                if (userDataType.equals("KString")) {
                    userDataValue = (String) e2.properties.get(4);
                } else if (userDataType.equals("int")) {
                    userDataValue = (Integer) e2.properties.get(4);
                } else if (userDataType.equals("double")) {
                    // NOTE: jME3 does not support doubles in UserData.
                    //       Need to convert to float.
                    userDataValue = ((Double) e2.properties.get(4)).floatValue();
                } else if (userDataType.equals("Vector")) {
                    float x = ((Double) e2.properties.get(4)).floatValue();
                    float y = ((Double) e2.properties.get(5)).floatValue();
                    float z = ((Double) e2.properties.get(6)).floatValue();
                    userDataValue = new Vector3f(x, y, z);
                } else {
                    logger.log(Level.WARNING, "Unsupported user data type: {0}. Ignoring.", userDataType);
                    continue;
                }
                
                userData.put(userDataKey, userDataValue);
            }
        }
        
        // Create local transform
        // TODO: take into account Maya-style transforms (pre / post rotation ..)
        jmeLocalNodeTransform.setTranslation(localTranslation);
        jmeLocalNodeTransform.setRotation(localRotation);
        jmeLocalNodeTransform.setScale(localScale);
        
        if (element.getChildById("Vertices") != null) {
            // This is an old-style FBX 6.1
            // Meshes could be embedded inside the node..
            
            // Inject the mesh into ourselves..
            FbxMesh mesh = new FbxMesh(assetManager, sceneFolderName);
            mesh.fromElement(element);
            connectObject(mesh);
        }
    }
    
    private Spatial tryCreateGeometry(int materialIndex, Mesh jmeMesh, boolean single) {
        // Map meshes without material indices to material 0.
        if (materialIndex == -1) {
            materialIndex = 0;
        }
        
        Material jmeMat;
        if (materialIndex >= materials.size()) {
            // Material index does not exist. Create default material.
            jmeMat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
            jmeMat.setReceivesShadows(true);
        } else {
            FbxMaterial fbxMat = materials.get(materialIndex);
            jmeMat = fbxMat.getJmeObject();
        }
        
        String geomName = getName();
        if (single) {
            geomName += "-submesh";
        } else {
            geomName += "-mat-" + materialIndex + "-submesh";
        }
        Spatial spatial = new Geometry(geomName, jmeMesh);
        spatial.setMaterial(jmeMat);
        if (jmeMat.isTransparent()) {
            spatial.setQueueBucket(Bucket.Transparent);
        }
        if (jmeMat.isReceivesShadows()) {
            spatial.setShadowMode(ShadowMode.Receive);
        }
        spatial.updateModelBound();
        return spatial;
    }
    
    /**
     * If this geometry node is deformed by a skeleton, this
     * returns the node containing the skeleton.
     * 
     * In jME3, a mesh can be deformed by a skeleton only if it is 
     * a child of the node containing the skeleton. However, this
     * is not a requirement in FBX, so we have to modify the scene graph
     * of the loaded model to adjust for this.
     * This happens automatically in 
     * {@link #createScene(com.jme3.scene.plugins.fbx.node.FbxNode)}.
     * 
     * @return The model this node would like to be a child of, or null
     * if no preferred parent.
     */
    public FbxNode getPreferredParent() {
        if (!(nodeAttribute instanceof FbxMesh)) {
            return null;
        }
        
        FbxMesh fbxMesh = (FbxMesh) nodeAttribute;
        FbxSkinDeformer deformer = fbxMesh.getSkinDeformer();
        FbxNode preferredParent = null;
        
        if (deformer != null) {
            for (FbxCluster cluster : deformer.getJmeObject()) {
                FbxLimbNode limb = cluster.getLimb();
                if (preferredParent == null) {
                    preferredParent = limb.getSkeletonHolder();
                } else if (preferredParent != limb.getSkeletonHolder()) {
                    logger.log(Level.WARNING, "A mesh is being deformed by multiple skeletons. "
                                            + "Only one skeleton will work, ignoring other skeletons.");
                }
            }
        }
        
        return preferredParent;
    }
    
    @Override
    public Spatial toJmeObject() {
        Spatial spatial;
        
        if (nodeAttribute instanceof FbxMesh) {
            FbxMesh fbxMesh = (FbxMesh) nodeAttribute;
            IntMap<Mesh> jmeMeshes = fbxMesh.getJmeObject();
            
            if (jmeMeshes == null || jmeMeshes.size() == 0) {
                // No meshes found on FBXMesh (??)
                logger.log(Level.WARNING, "No meshes could be loaded. Creating empty node.");
                spatial = new Node(getName() + "-node");
            } else {
                // Multiple jME3 geometries required for a single FBXMesh.
                String nodeName;
                if (children.isEmpty()) {
                    nodeName = getName() + "-mesh";
                } else {
                    nodeName = getName() + "-node";
                }
                Node node = new Node(nodeName);
                boolean singleMesh = jmeMeshes.size() == 1;
                for (IntMap.Entry<Mesh> meshInfo : jmeMeshes) {
                    node.attachChild(tryCreateGeometry(meshInfo.getKey(), meshInfo.getValue(), singleMesh));
                }
                spatial = node;
            }
        } else {
            if (nodeAttribute != null) {
                // Just specifies that this is a "null" node.
                nodeAttribute.getJmeObject();
            }
            
            // TODO: handle other node attribute types.
            //       right now everything we don't know about gets converted
            //       to jME3 Node.
            spatial = new Node(getName() + "-node");
        }
        
        if (!children.isEmpty()) {
            // Check uniform scale.
            // Although, if inheritType is 0 (eInheritRrSs)
            // it might not be a problem.
            Vector3f localScale = jmeLocalNodeTransform.getScale();
            if (!FastMath.approximateEquals(localScale.x, localScale.y) || 
                !FastMath.approximateEquals(localScale.x, localScale.z)) {
                logger.log(Level.WARNING, "Non-uniform scale detected on parent node. " +
                                          "The model may appear distorted.");
            }
        }
        
        spatial.setLocalTransform(jmeLocalNodeTransform);
        
        if (visibility == 0.0) {
            spatial.setCullHint(CullHint.Always);
        }
        
        for (Map.Entry<String, Object> userDataEntry : userData.entrySet()) {
            spatial.setUserData(userDataEntry.getKey(), userDataEntry.getValue());
        }
        
        return spatial;
    }
    
    /**
     * Create jME3 Skeleton objects on the scene. 
     * 
     * Goes through the scene graph and finds limbs that are 
     * attached to FBX nodes, then creates a Skeleton on the node
     * based on the child limbs.
     * 
     * Must be called prior to calling 
     * {@link #createScene(com.jme3.scene.plugins.fbx.node.FbxNode)}.
     * 
     * @param fbxNode The root FBX node.
     */
    public static void createSkeletons(FbxNode fbxNode) {
        boolean createSkeleton = false;
        for (FbxNode fbxChild : fbxNode.children) {
            if (fbxChild instanceof FbxLimbNode) {
                createSkeleton = true;
            } else {
                createSkeletons(fbxChild);
            }
        }
        if (createSkeleton) {
            if (fbxNode.skeleton != null) {
                throw new UnsupportedOperationException();
            }
            fbxNode.skeleton = FbxLimbNode.createSkeleton(fbxNode);
            System.out.println("created skeleton: " + fbxNode.skeleton);
        }
    }
    
    private static void relocateSpatial(Spatial spatial, 
                                        Transform originalWorldTransform, Transform newWorldTransform) {
        Transform localTransform = new Transform();
        localTransform.set(originalWorldTransform);
        localTransform.combineWithParent(newWorldTransform.invert());
        spatial.setLocalTransform(localTransform);
    }
    
    public static Spatial createScene(FbxNode fbxNode) {
        Spatial jmeSpatial = fbxNode.getJmeObject();
        
        if (jmeSpatial instanceof Node) {
            // Attach children to Node
            Node jmeNode = (Node) jmeSpatial;
            for (FbxNode fbxChild : fbxNode.children) {
                if (!(fbxChild instanceof FbxLimbNode)) {
                    createScene(fbxChild);
                    
                    FbxNode preferredParent = fbxChild.getPreferredParent();
                    Spatial jmeChild = fbxChild.getJmeObject();
                    if (preferredParent != null) {
                        System.out.println("Preferred parent for " + fbxChild + " is " + preferredParent);
                        
                        Node jmePreferredParent = (Node) preferredParent.getJmeObject();
                        relocateSpatial(jmeChild, fbxChild.jmeWorldNodeTransform, 
                                                  preferredParent.jmeWorldNodeTransform);
                        jmePreferredParent.attachChild(jmeChild);
                    } else {
                        jmeNode.attachChild(jmeChild);
                    }
                }
            }
        }
        
        if (fbxNode.skeleton != null) { 
            jmeSpatial.addControl(new AnimControl(fbxNode.skeleton));
            jmeSpatial.addControl(new SkeletonControl(fbxNode.skeleton));
            
            SkeletonDebugger sd = new SkeletonDebugger("debug", fbxNode.skeleton);
            Material mat = new Material(fbxNode.assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
            mat.getAdditionalRenderState().setWireframe(true);
            mat.getAdditionalRenderState().setDepthTest(false);
            mat.setColor("Color", ColorRGBA.Green);
            sd.setMaterial(mat);
            
            ((Node)jmeSpatial).attachChild(sd);
        }
        
        return jmeSpatial;
    }
    
//    public SceneLoader.Limb toLimb() {
//        SceneLoader.Limb limb = new SceneLoader.Limb();
//        limb.name = getName();
//        Quaternion rotation = preRotation.mult(localRotation);
//        limb.bindTransform = new Transform(localTranslation, rotation, localScale);
//        return limb;
//    }
    
    public Skeleton getJmeSkeleton() {
        return skeleton;
    }
    
    public List<FbxNode> getChildren() { 
        return children;
    }
    
    @Override
    public void connectObject(FbxObject object) {
        if (object instanceof FbxNode) {
            // Scene Graph Object
            FbxNode childNode = (FbxNode) object;
            if (childNode.parent != null) {
                throw new IllegalStateException("Cannot attach " + childNode
                                              + " to " + this + ". It is already "
                                              + "attached to " + childNode.parent);
            }
            childNode.parent = this;
            children.add(childNode);
        } else if (object instanceof FbxNodeAttribute) {
            // Node Attribute
            if (nodeAttribute != null) { 
                throw new IllegalStateException("An FBXNodeAttribute (" + nodeAttribute + ")" +
                                                " is already attached to " + this + ". " +
                                                "Only one attribute allowed per node.");
            }
            
            nodeAttribute = (FbxNodeAttribute) object;
            if (nodeAttribute instanceof FbxNullAttribute) {
                nodeAttribute.getJmeObject();
            }
        } else if (object instanceof FbxMaterial) {
            materials.add((FbxMaterial) object);
        } else if (object instanceof FbxImage || object instanceof FbxTexture) {
            // Ignore - attaching textures to nodes is legacy feature.
        } else {
            unsupportedConnectObject(object);
        }
    }

    @Override
    public void connectObjectProperty(FbxObject object, String property) {
        // Only allowed to connect local transform properties to object
        // (FbxAnimCurveNode)
        if (object instanceof FbxAnimCurveNode) {
            FbxAnimCurveNode curveNode = (FbxAnimCurveNode) object;
            if (property.equals("Lcl Translation")
                    || property.equals("Lcl Rotation")
                    || property.equals("Lcl Scaling")) {
                
                List<FbxAnimCurveNode> curveNodes = propertyToAnimCurveMap.get(property);
                if (curveNodes == null) {
                    curveNodes = new ArrayList<FbxAnimCurveNode>();
                    curveNodes.add(curveNode);
                    propertyToAnimCurveMap.put(property, curveNodes);
                }
                curveNodes.add(curveNode);
                
                // Make sure the curve knows about it animating
                // this node as well. 
                curveNode.addInfluencedNode(this, property);
            } else {
                logger.log(Level.WARNING, "Animating the property ''{0}'' is not "
                                        + "supported. Ignoring.", property);
            }
        } else {
            unsupportedConnectObjectProperty(object, property);
        }
    }
    
}
