/*
 * Copyright (c) 2009-2012 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.blender.objects;

import java.nio.Buffer;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.nio.ShortBuffer;
import java.util.Collection;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.jme3.light.Light;
import com.jme3.math.FastMath;
import com.jme3.math.Matrix4f;
import com.jme3.math.Transform;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.scene.CameraNode;
import com.jme3.scene.Geometry;
import com.jme3.scene.LightNode;
import com.jme3.scene.Mesh.Mode;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.Spatial.CullHint;
import com.jme3.scene.VertexBuffer.Type;
import com.jme3.scene.plugins.blender.AbstractBlenderHelper;
import com.jme3.scene.plugins.blender.BlenderContext;
import com.jme3.scene.plugins.blender.BlenderContext.LoadedDataType;
import com.jme3.scene.plugins.blender.animations.AnimationHelper;
import com.jme3.scene.plugins.blender.cameras.CameraHelper;
import com.jme3.scene.plugins.blender.constraints.ConstraintHelper;
import com.jme3.scene.plugins.blender.curves.CurvesHelper;
import com.jme3.scene.plugins.blender.file.BlenderFileException;
import com.jme3.scene.plugins.blender.file.DynamicArray;
import com.jme3.scene.plugins.blender.file.Pointer;
import com.jme3.scene.plugins.blender.file.Structure;
import com.jme3.scene.plugins.blender.lights.LightHelper;
import com.jme3.scene.plugins.blender.meshes.MeshHelper;
import com.jme3.scene.plugins.blender.meshes.TemporalMesh;
import com.jme3.scene.plugins.blender.modifiers.Modifier;
import com.jme3.scene.plugins.blender.modifiers.ModifierHelper;
import com.jme3.util.TempVars;

/**
 * A class that is used in object calculations.
 * 
 * @author Marcin Roguski (Kaelthas)
 */
public class ObjectHelper extends AbstractBlenderHelper {
    private static final Logger LOGGER               = Logger.getLogger(ObjectHelper.class.getName());

    public static final String  OMA_MARKER           = "oma";
    public static final String  ARMATURE_NODE_MARKER = "armature-node";

    /**
     * This constructor parses the given blender version and stores the result.
     * Some functionalities may differ in different blender versions.
     * 
     * @param blenderVersion
     *            the version read from the blend file
     * @param blenderContext
     *            the blender context
     */
    public ObjectHelper(String blenderVersion, BlenderContext blenderContext) {
        super(blenderVersion, blenderContext);
    }

    /**
     * This method reads the given structure and createn an object that
     * represents the data.
     * 
     * @param objectStructure
     *            the object's structure
     * @param blenderContext
     *            the blender context
     * @return blener's object representation or null if its type is excluded from loading
     * @throws BlenderFileException
     *             an exception is thrown when the given data is inapropriate
     */
    public Object toObject(Structure objectStructure, BlenderContext blenderContext) throws BlenderFileException {
        Object loadedResult = blenderContext.getLoadedFeature(objectStructure.getOldMemoryAddress(), LoadedDataType.FEATURE);
        if (loadedResult != null) {
            return loadedResult;
        }

        LOGGER.fine("Loading blender object.");
        if ("ID".equals(objectStructure.getType())) {
            Node object = (Node) this.loadLibrary(objectStructure);
            if (object.getParent() != null) {
                LOGGER.log(Level.FINEST, "Detaching object {0}, loaded from external file, from its parent.", object);
                object.getParent().detachChild(object);
            }
            return object;
        }
        int type = ((Number) objectStructure.getFieldValue("type")).intValue();
        ObjectType objectType = ObjectType.valueOf(type);
        LOGGER.log(Level.FINE, "Type of the object: {0}.", objectType);

        int lay = ((Number) objectStructure.getFieldValue("lay")).intValue();
        if ((lay & blenderContext.getBlenderKey().getLayersToLoad()) == 0) {
            LOGGER.fine("The layer this object is located in is not included in loading.");
            return null;
        }

        blenderContext.pushParent(objectStructure);
        String name = objectStructure.getName();
        LOGGER.log(Level.FINE, "Loading obejct: {0}", name);

        int restrictflag = ((Number) objectStructure.getFieldValue("restrictflag")).intValue();
        boolean visible = (restrictflag & 0x01) != 0;

        Pointer pParent = (Pointer) objectStructure.getFieldValue("parent");
        Object parent = blenderContext.getLoadedFeature(pParent.getOldMemoryAddress(), LoadedDataType.FEATURE);
        if (parent == null && pParent.isNotNull()) {
            Structure parentStructure = pParent.fetchData().get(0);
            parent = this.toObject(parentStructure, blenderContext);
        }

        Transform t = this.getTransformation(objectStructure, blenderContext);
        LOGGER.log(Level.FINE, "Importing object of type: {0}", objectType);
        Node result = null;
        try {
            switch (objectType) {
                case LATTICE:
                case METABALL:
                case TEXT:
                case WAVE:
                    LOGGER.log(Level.WARNING, "{0} type is not supported but the node will be returned in order to keep parent - child relationship.", objectType);
                case EMPTY:
                case ARMATURE:
                    // need to use an empty node to properly create
                    // parent-children relationships between nodes
                    result = new Node(name);
                    break;
                case MESH:
                    result = new Node(name);
                    MeshHelper meshHelper = blenderContext.getHelper(MeshHelper.class);
                    Pointer pMesh = (Pointer) objectStructure.getFieldValue("data");
                    List<Structure> meshesArray = pMesh.fetchData();
                    TemporalMesh temporalMesh = meshHelper.toTemporalMesh(meshesArray.get(0), blenderContext);
                    if (temporalMesh != null) {
                        result.attachChild(temporalMesh);
                    }
                    break;
                case SURF:
                case CURVE:
                    result = new Node(name);
                    Pointer pCurve = (Pointer) objectStructure.getFieldValue("data");
                    if (pCurve.isNotNull()) {
                        CurvesHelper curvesHelper = blenderContext.getHelper(CurvesHelper.class);
                        Structure curveData = pCurve.fetchData().get(0);
                        TemporalMesh curvesTemporalMesh = curvesHelper.toCurve(curveData, blenderContext);
                        if (curvesTemporalMesh != null) {
                            result.attachChild(curvesTemporalMesh);
                        }
                    }
                    break;
                case LAMP:
                    Pointer pLamp = (Pointer) objectStructure.getFieldValue("data");
                    if (pLamp.isNotNull()) {
                        LightHelper lightHelper = blenderContext.getHelper(LightHelper.class);
                        List<Structure> lampsArray = pLamp.fetchData();
                        Light light = lightHelper.toLight(lampsArray.get(0), blenderContext);
                        if (light == null) {
                            // probably some light type is not supported, just create a node so that we can maintain child-parent relationship for nodes
                            result = new Node(name);
                        } else {
                            result = new LightNode(name, light);
                        }
                    }
                    break;
                case CAMERA:
                    Pointer pCamera = (Pointer) objectStructure.getFieldValue("data");
                    if (pCamera.isNotNull()) {
                        CameraHelper cameraHelper = blenderContext.getHelper(CameraHelper.class);
                        List<Structure> camerasArray = pCamera.fetchData();
                        Camera camera = cameraHelper.toCamera(camerasArray.get(0), blenderContext);
                        if (camera == null) {
                            // just create a node so that we can maintain child-parent relationship for nodes
                            result = new Node(name);
                        } else {
                            result = new CameraNode(name, camera);
                        }
                    }
                    break;
                default:
                    LOGGER.log(Level.WARNING, "Unsupported object type: {0}", type);
            }

            if (result != null) {
                LOGGER.fine("Storing loaded feature in blender context and applying markers (those will be removed before the final result is released).");
                Long oma = objectStructure.getOldMemoryAddress();
                blenderContext.addLoadedFeatures(oma, LoadedDataType.STRUCTURE, objectStructure);
                blenderContext.addLoadedFeatures(oma, LoadedDataType.FEATURE, result);

                blenderContext.addMarker(OMA_MARKER, result, objectStructure.getOldMemoryAddress());
                if (objectType == ObjectType.ARMATURE) {
                    blenderContext.addMarker(ARMATURE_NODE_MARKER, result, Boolean.TRUE);
                }

                result.setLocalTransform(t);
                result.setCullHint(visible ? CullHint.Always : CullHint.Inherit);
                if (parent instanceof Node) {
                    ((Node) parent).attachChild(result);
                }

                LOGGER.fine("Reading and applying object's modifiers.");
                ModifierHelper modifierHelper = blenderContext.getHelper(ModifierHelper.class);
                Collection<Modifier> modifiers = modifierHelper.readModifiers(objectStructure, blenderContext);
                for (Modifier modifier : modifiers) {
                    modifier.apply(result, blenderContext);
                }

                if (result.getChildren() != null && result.getChildren().size() > 0) {
                    if (result.getChildren().size() == 1 && result.getChild(0) instanceof TemporalMesh) {
                        LOGGER.fine("Converting temporal mesh into jme geometries.");
                        ((TemporalMesh) result.getChild(0)).toGeometries();
                    }

                    LOGGER.fine("Applying proper scale to the geometries.");
                    for (Spatial child : result.getChildren()) {
                        if (child instanceof Geometry) {
                            this.flipMeshIfRequired((Geometry) child, child.getWorldScale());
                        }
                    }
                }

                // I prefer do compute bounding box here than read it from the file
                result.updateModelBound();

                LOGGER.fine("Applying animations to the object if such are defined.");
                AnimationHelper animationHelper = blenderContext.getHelper(AnimationHelper.class);
                animationHelper.applyAnimations(result, blenderContext.getBlenderKey().getAnimationMatchMethod());

                LOGGER.fine("Loading constraints connected with this object.");
                ConstraintHelper constraintHelper = blenderContext.getHelper(ConstraintHelper.class);
                constraintHelper.loadConstraints(objectStructure, blenderContext);

                LOGGER.fine("Loading custom properties.");
                if (blenderContext.getBlenderKey().isLoadObjectProperties()) {
                    Properties properties = this.loadProperties(objectStructure, blenderContext);
                    // the loaded property is a group property, so we need to get
                    // each value and set it to Spatial
                    if (properties != null && properties.getValue() != null) {
                        this.applyProperties(result, properties);
                    }
                }
            }
        } finally {
            blenderContext.popParent();
        }
        return result;
    }

    /**
     * The method flips the mesh if the scale is mirroring it. Mirroring scale has either 1 or all 3 factors negative.
     * If two factors are negative then there is no mirroring because a rotation and translation can be found that will
     * lead to the same transform when all scales are positive.
     * 
     * @param geometry
     *            the geometry that is being flipped if necessary
     * @param scale
     *            the scale vector of the given geometry
     */
    private void flipMeshIfRequired(Geometry geometry, Vector3f scale) {
        float s = scale.x * scale.y * scale.z;

        if (s < 0 && geometry.getMesh() != null) {// negative s means that the scale is mirroring the object
            FloatBuffer normals = geometry.getMesh().getFloatBuffer(Type.Normal);
            if (normals != null) {
                for (int i = 0; i < normals.limit(); i += 3) {
                    if (scale.x < 0) {
                        normals.put(i, -normals.get(i));
                    }
                    if (scale.y < 0) {
                        normals.put(i + 1, -normals.get(i + 1));
                    }
                    if (scale.z < 0) {
                        normals.put(i + 2, -normals.get(i + 2));
                    }
                }
            }

            if (geometry.getMesh().getMode() == Mode.Triangles) {// there is no need to flip the indexes for lines and points
                LOGGER.finer("Flipping index order in triangle mesh.");
                Buffer indexBuffer = geometry.getMesh().getBuffer(Type.Index).getData();
                for (int i = 0; i < indexBuffer.limit(); i += 3) {
                    if (indexBuffer instanceof ShortBuffer) {
                        short index = ((ShortBuffer) indexBuffer).get(i + 1);
                        ((ShortBuffer) indexBuffer).put(i + 1, ((ShortBuffer) indexBuffer).get(i + 2));
                        ((ShortBuffer) indexBuffer).put(i + 2, index);
                    } else {
                        int index = ((IntBuffer) indexBuffer).get(i + 1);
                        ((IntBuffer) indexBuffer).put(i + 1, ((IntBuffer) indexBuffer).get(i + 2));
                        ((IntBuffer) indexBuffer).put(i + 2, index);
                    }
                }
            }
        }
    }

    /**
     * Checks if the first given OMA points to a parent of the second one.
     * The parent need not to be the direct one. This method should be called when we are sure
     * that both of the features are alred loaded because it does not check it.
     * The OMA's should point to a spatials, otherwise the function will throw ClassCastException.
     * @param supposedParentOMA
     *            the OMA of the node that we suppose might be a parent of the second one
     * @param spatialOMA
     *            the OMA of the scene's node
     * @return <b>true</b> if the first given OMA points to a parent of the second one and <b>false</b> otherwise
     */
    public boolean isParent(Long supposedParentOMA, Long spatialOMA) {
        Spatial supposedParent = (Spatial) blenderContext.getLoadedFeature(supposedParentOMA, LoadedDataType.FEATURE);
        Spatial spatial = (Spatial) blenderContext.getLoadedFeature(spatialOMA, LoadedDataType.FEATURE);

        Spatial parent = spatial.getParent();
        while (parent != null) {
            if (parent.equals(supposedParent)) {
                return true;
            }
            parent = parent.getParent();
        }
        return false;
    }

    /**
     * This method calculates local transformation for the object. Parentage is
     * taken under consideration.
     * 
     * @param objectStructure
     *            the object's structure
     * @return objects transformation relative to its parent
     */
    public Transform getTransformation(Structure objectStructure, BlenderContext blenderContext) {
        TempVars tempVars = TempVars.get();

        Matrix4f parentInv = tempVars.tempMat4;
        Pointer pParent = (Pointer) objectStructure.getFieldValue("parent");
        if (pParent.isNotNull()) {
            Structure parentObjectStructure = (Structure) blenderContext.getLoadedFeature(pParent.getOldMemoryAddress(), LoadedDataType.STRUCTURE);
            this.getMatrix(parentObjectStructure, "obmat", fixUpAxis, parentInv).invertLocal();
        } else {
            parentInv.loadIdentity();
        }

        Matrix4f globalMatrix = this.getMatrix(objectStructure, "obmat", fixUpAxis, tempVars.tempMat42);
        Matrix4f localMatrix = parentInv.multLocal(globalMatrix);

        this.getSizeSignums(objectStructure, tempVars.vect1);

        localMatrix.toTranslationVector(tempVars.vect2);
        localMatrix.toRotationQuat(tempVars.quat1);
        localMatrix.toScaleVector(tempVars.vect3);

        Transform t = new Transform(tempVars.vect2, tempVars.quat1.normalizeLocal(), tempVars.vect3.multLocal(tempVars.vect1));
        tempVars.release();
        return t;
    }

    /**
     * The method gets the signs of the scale factors and stores them properly in the given vector.
     * @param objectStructure
     *            the object's structure
     * @param store
     *            the vector where the result will be stored
     */
    @SuppressWarnings("unchecked")
    private void getSizeSignums(Structure objectStructure, Vector3f store) {
        DynamicArray<Number> size = (DynamicArray<Number>) objectStructure.getFieldValue("size");
        if (fixUpAxis) {
            store.x = Math.signum(size.get(0).floatValue());
            store.y = Math.signum(size.get(2).floatValue());
            store.z = Math.signum(size.get(1).floatValue());
        } else {
            store.x = Math.signum(size.get(0).floatValue());
            store.y = Math.signum(size.get(1).floatValue());
            store.z = Math.signum(size.get(2).floatValue());
        }
    }

    /**
     * This method returns the matrix of a given name for the given structure.
     * It takes up axis into consideration.
     * 
     * The method that moves the matrix from Z-up axis to Y-up axis space is as follows:
     * - load the matrix directly from blender (it has the Z-up axis orientation)
     * - switch the second and third rows in the matrix
     * - switch the second and third column in the matrix
     * - multiply the values in the third row by -1
     * - multiply the values in the third column by -1
     * 
     * The result matrix is now in Y-up axis orientation.
     * The procedure was discovered by experimenting but it looks like it's working :)
     * The previous procedure transformet the loaded matrix into component (loc, rot, scale),
     * switched several values and pu the back into the matrix.
     * It worked fine until models with negative scale are used.
     * The current method is not touched by that flaw.
     * 
     * @param structure
     *            the structure with matrix data
     * @param matrixName
     *            the name of the matrix
     * @param fixUpAxis
     *            tells if the Y axis is a UP axis
     * @param store
     *            the matrix where the result will pe placed
     * @return the required matrix
     */
    @SuppressWarnings("unchecked")
    private Matrix4f getMatrix(Structure structure, String matrixName, boolean fixUpAxis, Matrix4f store) {
        DynamicArray<Number> obmat = (DynamicArray<Number>) structure.getFieldValue(matrixName);
        // the matrix must be square
        int rowAndColumnSize = Math.abs((int) Math.sqrt(obmat.getTotalSize()));
        for (int i = 0; i < rowAndColumnSize; ++i) {
            for (int j = 0; j < rowAndColumnSize; ++j) {
                float value = obmat.get(j, i).floatValue();
                if (Math.abs(value) <= FastMath.FLT_EPSILON) {
                    value = 0;
                }
                store.set(i, j, value);
            }
        }
        if (fixUpAxis) {
            // first switch the second and third row
            for (int i = 0; i < 4; ++i) {
                float temp = store.get(1, i);
                store.set(1, i, store.get(2, i));
                store.set(2, i, temp);
            }

            // then switch the second and third column
            for (int i = 0; i < 4; ++i) {
                float temp = store.get(i, 1);
                store.set(i, 1, store.get(i, 2));
                store.set(i, 2, temp);
            }

            // multiply the values in the third row by -1
            store.m20 *= -1;
            store.m21 *= -1;
            store.m22 *= -1;
            store.m23 *= -1;

            // multiply the values in the third column by -1
            store.m02 *= -1;
            store.m12 *= -1;
            store.m22 *= -1;
            store.m32 *= -1;
        }

        return store;
    }

    /**
     * This method returns the matrix of a given name for the given structure.
     * It takes up axis into consideration.
     * 
     * @param structure
     *            the structure with matrix data
     * @param matrixName
     *            the name of the matrix
     * @param fixUpAxis
     *            tells if the Y axis is a UP axis
     * @return the required matrix
     */
    public Matrix4f getMatrix(Structure structure, String matrixName, boolean fixUpAxis) {
        return this.getMatrix(structure, matrixName, fixUpAxis, new Matrix4f());
    }

    private static enum ObjectType {
        EMPTY(0), MESH(1), CURVE(2), SURF(3), TEXT(4), METABALL(5), LAMP(10), CAMERA(11), WAVE(21), LATTICE(22), ARMATURE(25);

        private int blenderTypeValue;

        private ObjectType(int blenderTypeValue) {
            this.blenderTypeValue = blenderTypeValue;
        }

        public static ObjectType valueOf(int blenderTypeValue) throws BlenderFileException {
            for (ObjectType type : ObjectType.values()) {
                if (type.blenderTypeValue == blenderTypeValue) {
                    return type;
                }
            }
            throw new BlenderFileException("Unknown type value: " + blenderTypeValue);
        }
    }
}
