package de.rwth_aachen.graphics;

import java.nio.FloatBuffer;

import org.lwjgl.util.vector.Matrix3f;
import org.lwjgl.util.vector.Matrix4f;
import org.lwjgl.util.vector.Vector3f;
import org.lwjgl.util.vector.Vector4f;

/**
 * The camera class provides the possibility to set a target, where it should look at,
 * and an orientation by setting the yaw / pitch / roll parameters.
 */
class Camera
{
    private int width = 640;
    private int height = 480;
    private float fov = 60.0f;
    private float near = 0.1f;
    private float far = 100.0f;
    private Vector4f position = new Vector4f(5.0f, 5.0f, 5.0f, 1.0f);
    private Vector4f target = new Vector4f(0.0f, 0.0f, 0.0f, 1.0f);
    private float pitch = 0.0f;
    private float yaw = 0.0f;
    private float roll = 0.0f;
    private Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f);
    private Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f);
    private Vector3f forward = new Vector3f(0.0f, 0.0f, -1.0f);
    
    /**
     * Construct a camera looking at a specific target.
     * @param width The width of the screen (in pixel).
     * @param height The height of the screen (in pixel).
     * @param fov The field of view (in degrees).
     * @param near The distance of the near plane.
     * @param far The distance of the far plane.
     * @param position The position of the camera in 3D space.
     * @param target The target of the camera in 3D space.
     */
    public Camera(
        int width,
        int height,
        float fov,
        float near,
        float far,
        Vector4f position,
        Vector4f target)
    {
        this.width = width;
        this.height = height;
        this.fov = fov;
        this.near = near;
        this.far = far;
        this.position = position;
        this.target = target;
        updateLocalCoordinateSystemByTarget();
    }
    
    public Camera(Camera other)
    {
        this.width = other.width;
        this.height = other.height;
        this.fov = other.fov;
        this.near = other.near;
        this.far = other.far;
        this.position = new Vector4f(other.position);
        this.target = new Vector4f(other.target);
        this.pitch = other.pitch;
        this.yaw = other.yaw;
        this.roll = other.roll;
        this.right = new Vector3f(other.right);
        this.up = new Vector3f(other.up);
        this.forward = new Vector3f(other.forward);
    }
    
    public Camera clone()
    {
        return new Camera(this);
    }
    
    /**
     * Access the aspect ratio of the screen.
     * @return The aspect ratio (width/height).
     */
    public float getAspectRatio()
    {
        return (float)width / (float)height;
    }
    
    /**
     * Access the vector that points up as seen from the camera point of view.
     * @return The up vector.
     */
    public Vector3f getUpDirection()
    {
        return up;
    }
    
    /**
     * Access the vector that points right as seen from the camera point of view.
     * @return The right vector.
     */
    public Vector3f getRightDirection()
    {
        return right;
    }
    
    /**
     * Access the vector that points forward as seen from the camera point of view.
     * @return The forward vector.
     */
    public Vector3f getForwardDirection()
    {
        return forward;
    }
    
    /**
     * Change the width of the screen.
     * @param width The new width.
     */
    public void setWidth(int width)
    {
        this.width = width;
    }
    
    /**
     * Access the width of the screen.
     * @return The current width.
     */
    public int getWidth()
    {
        return width;
    }
    
    /**
     * Change the height of the screen.
     * @param height The new height.
     */
    public void setHeight(int height)
    {
        this.height = height;
    }
    
    /**
     * Access the height of the screen.
     * @return The current height.
     */
    public int getHeight()
    {
        return height;
    }
    
    /**
     * Change the field of view of the camera.
     * @param fov The new field of view.
     */
    public void setFOV(float fov)
    {
        this.fov = fov;
    }
    
    /**
     * Access the field of view of the camera.
     * @return The current field of view.
     */
    public float getFOV()
    {
        return fov;
    }
    
    /**
     * Change the distance of the near plane.
     * @param near The new distance of the near plane.
     */
    public void setNear(float near)
    {
        this.near = near;
    }
    
    /**
     * Access the distance of the near plane.
     * @return The current distance of the near plane.
     */
    public float getNear()
    {
        return near;
    }
    
    /**
     * Change the distance of the far plane.
     * @param far The new distance of the far plane.
     */
    public void setFar(float far)
    {
        this.far = far;
    }
    
    /**
     * Access the distance of the far plane.
     * @return The current distance of the far plane.
     */
    public float getFar()
    {
        return far;
    }
    
    /**
     * Change the position of the camera. This will update the up,
     * right and forward vectors.
     * @param position The new position of the camera.
     * @param useTarget Look at the target?
     */
    public void setPosition(Vector4f position, boolean useTarget)
    {
        this.position = position;
        if(useTarget)
            updateLocalCoordinateSystemByTarget();
    }
    
    /**
     * Access the position of the camera.
     * @return The current position of the camera.
     */
    public Vector4f getPosition()
    {
        return position;
    }
    
    /**
     * Change the target of the camera. This will update the up,
     * right and forward vectors.
     * @param target The new target of the camera.
     * @param sameOrientation Should the position stay at the same relative position?
     */
    public void setTarget(Vector4f target, boolean sameOrientation)
    {
        if(sameOrientation)
        {
            Vector4f move = Vector4f.sub(target, this.target, null);
            position = Vector4f.add(position, move, null);
        }
        this.target = target;
        if(!sameOrientation)
            updateLocalCoordinateSystemByTarget();
    }
    
    /**
     * Access the target of the camera.
     * @return The current target of the camera.
     */
    public Vector4f getTarget()
    {
        return target;
    }
    
    /**
     * Change the orientation of the camera by pitching around 
     * the target. This will update the up, right and forward 
     * vectors.
     * @param pitch The absolute pitch value.
     */
    public void setPitchAroundTarget(float pitch)
    {
        this.pitch = pitch;
        updateLocalCoordinateSystemByOrientation();
        
        float distance = (float)Math.sqrt(Vector4f.sub(position, target, null).lengthSquared());
        position.set(target.x - distance * forward.x, target.y - distance * forward.y, target.z - distance * forward.z);
    }
    
    /**
     * Change the orientation of the camera by pitching. This 
     * will update the up, right and forward vectors.
     * @param pitch The absolute pitch value.
     */
    public void setPitch(float pitch)
    {
        this.pitch = pitch;
        updateLocalCoordinateSystemByOrientation();
    }
    
    /**
     * Access the pitching of the camera orientation.
     * @return The current pitch angle.
     */
    public float getPitch()
    {
        return pitch;
    }
    
    /**
     * Change the orientation of the camera by yawing around 
     * the target. This will update the up, right and forward 
     * vectors.
     * @param yaw The absolute yaw value.
     */
    public void setYawAroundTarget(float yaw)
    {
        this.yaw = yaw;
        updateLocalCoordinateSystemByOrientation();
        
        float distance = (float)Math.sqrt(Vector4f.sub(position, target, null).lengthSquared());
        position.set(target.x - distance * forward.x, target.y - distance * forward.y, target.z - distance * forward.z);
    }
    
    /**
     * Change the orientation of the camera by yawing. This 
     * will update the up, right and forward vectors.
     * @param yaw The absolute yaw value.
     */
    public void setYaw(float yaw)
    {
        this.yaw = yaw;
        updateLocalCoordinateSystemByOrientation();
    }
    
    /**
     * Access the yawing of the camera orientation.
     * @return The current yaw angle.
     */
    public float getYaw()
    {
        return yaw;	
    }
    
    /**
     * Change the orientation of the camera by rolling. This 
     * will update the up, right and forward vectors.
     * @param roll The absolute roll value.
     */
    public void setRoll(float roll)
    {
        this.roll = roll;
        updateLocalCoordinateSystemByOrientation();
    }
    
    /**
     * Access the rolling of the camera orientation.
     * @return The current roll angle.
     */
    public float getRoll()
    {
        return roll;
    }
    
    /**
     * Access the current projection matrix of the camera.     *
     * @return The columnwise buffer of float values that represents the 
     * camera projection.
     */
    public FloatBuffer getProjectionMatrix()
    {
        Matrix4f projection = new Matrix4f();
        float depthRatio = (float)(1.0 / Math.tan(Math.toRadians(fov / 2.0)));
        projection.m00 = depthRatio / getAspectRatio();
        projection.m11 = depthRatio;
        projection.m22 = (near + far) / (near - far);
        projection.m32 = (2.0f * near * far) / (near - far);
        projection.m23 = -1.0f;
        projection.m33 = 0.0f;
        return getBufferFromMatrix(projection);
    }
    
    /**
     * Access the current view matrix of the camera.
     * @return The columnwise buffer of float values that represents the 
     * camera view.
     */
    public FloatBuffer getViewMatrix()
    {
        Matrix4f view = new Matrix4f();
        view.m00 = right.x;
        view.m01 = up.x;
        view.m02 = -forward.x;
        view.m10 = right.y;
        view.m11 = up.y;
        view.m12 = -forward.y;
        view.m20 = right.z;
        view.m21 = up.z;
        view.m22 = -forward.z;
        view.m30 = -1 * (right.x * position.x + right.y * position.y + right.z * position.z);
        view.m31 = -1 * (up.x * position.x + up.y * position.y  + up.z * position.z);
        view.m32 = forward.x * position.x + forward.y * position.y + forward.z * position.z;
        return getBufferFromMatrix(view);
    }
    
    /**
     * Access the current inverse view matrix of the camera.
     * @return The columnwise buffer of float values that represents the 
     * inverse camera view.
     */
    public FloatBuffer getInverseViewMatrix()
    {
        Matrix4f view = new Matrix4f();
        view.m00 = right.x;
        view.m01 = right.y;
        view.m02 = right.z;
        view.m10 = up.x;
        view.m11 = up.y;
        view.m12 = up.z;
        view.m20 = -forward.x;
        view.m21 = -forward.y;
        view.m22 = -forward.z;
        view.m30 = position.x;
        view.m31 = position.y;
        view.m32 = position.z;
        return getBufferFromMatrix(view);
    }
    
    /**
     * Prepare a 4x4 matrix of lwjgl for loading it.
     * @param m The matrix that should be converted.
     * @return The columnwise buffer of float values.
     */
    private FloatBuffer getBufferFromMatrix(Matrix4f m)
    {
        return FloatBuffer.wrap(new float[] {
            m.m00, m.m01, m.m02, m.m03,
            m.m10, m.m11, m.m12, m.m13,
            m.m20, m.m21, m.m22, m.m23,
            m.m30, m.m31, m.m32, m.m33});
        
    }
    
    /**
     * Updates the orientation and local coordinate system by the given camera position and target.
     */
    private void updateLocalCoordinateSystemByTarget()
    {
        //This is our default camera coordinate system
        Vector3f x = new Vector3f(1.0f, 0.0f, 0.0f);
        Vector3f y = new Vector3f(0.0f, 1.0f, 0.0f);
        Vector3f z = new Vector3f(0.0f, 0.0f, -1.0f);
        
        //Ignore the roll
        roll = 0.0f;
        
        //The forward vector is just the direction from position to target
        forward = new Vector3f(target.x - position.x, target.y - position.y, target.z - position.z).normalise(null);
        
        //Check, is the position and the target lie approx. above each other
        float dot = Vector3f.dot(y, forward);
        if(Math.abs(dot) >= 0.9995f)
        {
            //..if yes, we set default values for that special case
            right.set(1.0f, 0.0f, 0.0f);
            yaw = 0.0f;
            if(dot > 0.0f)
                pitch = 90.0f;
            else
                pitch = -90.0f;
        }
        else
        {
            //..if no, we have to calculate the right vector, yaw and pitch
            
            //Since we assume, that the camera is not rolled, we can just use
            //the orthogonal vector to the y-axis and the forward vector as
            //new right vector.
            right = Vector3f.cross(forward, y, null).normalise(null);
            
            //We project the forward vector to the xz-plane for the calculation
            //of the yaw value, which is just the angle between this projected
            //vector and the -z-axis. Please note, that the angle is not sufficient,
            //since we have to know if the yaw is positiv or negative. This is
            //done by checking on which side of the yz-plane the projected vector
            //lies. This is done by using the dot product between x (normal of
            //yz-plane) and the projected vector.
            Vector3f forwardInXZ = new Vector3f(forward.x, 0.0f, forward.z).normalise(null);
            yaw = (float)Math.toDegrees(Math.acos(Vector3f.dot(z, forwardInXZ)));
            if(Vector3f.dot(x, forwardInXZ) >= 0.0f)
                yaw = -yaw;
            
            //The pitch is then the angle between the y-axis and the forward
            //vector. Please note, that we use asin instead of acos to get a
            //direct mapping to an angle range of [-90, 90] instead of [0, 180]
            pitch = (float)Math.toDegrees(Math.asin(Vector3f.dot(y, forward)));
        }
        
        //The up vector is simply orthogonal to the calculated forward and
        //right vectors.
        up = Vector3f.cross(right, forward, null).normalise(null);
    }
    
    /**
     * Updates the local coordinate system of the camera by the given orientation.
     */
    private void updateLocalCoordinateSystemByOrientation()
    {
        //This is our default camera coordinate system
        Vector3f x = new Vector3f(1.0f, 0.0f, 0.0f);
        Vector3f y = new Vector3f(0.0f, 1.0f, 0.0f);
        Vector3f z = new Vector3f(0.0f, 0.0f, -1.0f);
        
        //Calculate the rotation matrices for yaw, pitch and roll
        Matrix3f rotX = new Matrix3f();
        rotX.m11 = (float) Math.cos(Math.toRadians(pitch));
        rotX.m21 = (float)-Math.sin(Math.toRadians(pitch));
        rotX.m12 = (float) Math.sin(Math.toRadians(pitch));
        rotX.m22 = (float) Math.cos(Math.toRadians(pitch));
        Matrix3f rotY = new Matrix3f();
        rotY.m00 = (float) Math.cos(Math.toRadians(yaw));
        rotY.m20 = (float) Math.sin(Math.toRadians(yaw));
        rotY.m02 = (float)-Math.sin(Math.toRadians(yaw));
        rotY.m22 = (float) Math.cos(Math.toRadians(yaw));
        Matrix3f rotZ = new Matrix3f();
        rotZ.m00 = (float) Math.cos(Math.toRadians(roll));
        rotZ.m10 = (float)-Math.sin(Math.toRadians(roll));
        rotZ.m01 = (float) Math.sin(Math.toRadians(roll));
        rotZ.m11 = (float) Math.cos(Math.toRadians(roll));

        //Calculate the full rotation in the right order:
        //rot => 1. Yaw (y) -> 2. Pitch (x) -> 3. Roll (z)
        Matrix3f rot = Matrix3f.mul(Matrix3f.mul(rotY, rotX, null), rotZ, null);
        
        //Apply the rotation to the camera coordinate system
        Matrix3f.transform(rot, x, right);
        Matrix3f.transform(rot, y, up);
        Matrix3f.transform(rot, z, forward);
    }
}
