package com.srbenoit.render;

import com.srbenoit.geom.Point3;
import com.srbenoit.geom.Point3Int;
import com.srbenoit.geom.Transform3;
import com.srbenoit.geom.Vector3Int;

/**
 * A representation of a camera, which manages transformations from world space into view space,
 * clips lines to a view frustum (defined by a view angle, aspect ratio, and the near and far
 * clipping planes), projects into normalized device coordinates, then finally maps into screen
 * space. This class does not do any rendering.
 *
 * <p>The view coordinates are in a frame in which the camera is at the origin looking down the -Z
 * axis (so the origin in world space is mapped to the point (0, 0, -dist). This maps +X
 * coordinates to the right-pointing direction as seen from the camera, and +Y coordinates in the
 * upward-pointing direction (at least in the camera's default position, which is looking at the
 * origin from the +X axis). The key point is the the clip planes have negative Z coordinates, but
 * the clip distances are stored as positive values.
 */
public class Camera {

    /** a view angle (angle between left and right edges of screen) */
    public static final double VIEW_ANGLE = 50 * Math.PI / 180;

    /** precomputed value used in perspective projection */
    public static final double DIST = 1 / Math.tan(VIEW_ANGLE / 2);

    /** commonly used value */
    private static final double TWO_PI = 2 * Math.PI;

    /** commonly used value */
    private static final double HALF_PI = 0.5f * Math.PI;

    /** an object used to synchronize access to offscreen image */
    protected final transient Object synch;

    /** look-at point */
    private final transient Point3 lookAt;

    /** the transformation matrix */
    protected final transient Transform3 xform;

    /** the aspect ratio of the window */
    private transient double aspect = 1;

    /** near clipping plane distance */
    private transient double nearClip;

    /** far clipping plane distance */
    private transient double farClip;

    /** polar angle */
    private transient double polar;

    /** azimuthal angle */
    private transient double azimuthal;

    /** distance from camera to the look-at point */
    private transient double distance;

    /**
     * Constructs a new <code>Camera</code>. The camera will have its look-at point set to the
     * origin, and have its polar and azimuthal angles set to PI/2 and 0, respectively (looking at
     * the origin from the +x axis).
     *
     * <p>The near clipping and far clipping planes will be set to one tenth and ten times the
     * initial viewing distance.
     *
     * @param  initDistance  the initial distance from the look-at point of the camera
     */
    public Camera(final double initDistance) {

        this.synch = new Object();

        this.lookAt = new Point3(); // Default is origin
        this.polar = HALF_PI;
        this.azimuthal = 0;
        this.distance = initDistance;
        this.nearClip = initDistance / 10;
        this.farClip = initDistance * 10;
        this.xform = new Transform3();
    }

    /**
     * Sets the point that the camera looks at and recomputes the transformation matrix.
     *
     * @param  lookAtX  the X coordinate of the look-at point
     * @param  lookAtY  the Y coordinate of the look-at point
     * @param  lookAtZ  the Z coordinate of the look-at point
     */
    public void setLookAt(final double lookAtX, final double lookAtY, final double lookAtZ) {

        synchronized (this.synch) {
            this.lookAt.setPos(lookAtX, lookAtY, lookAtZ);
            computeMatrix();
        }
    }

    /**
     * Gets the point that the camera looks at.
     *
     * @return  the look-at point
     */
    public Point3 getLookAt() {

        synchronized (this.synch) {
            return new Point3(this.lookAt);
        }
    }

    /**
     * Sets the aspect ratio of the view port and recomputes the transformation matrix.
     *
     * @param  aspectRatio  the aspect ratio (width / height)
     */
    public void setAspect(final double aspectRatio) {

        synchronized (this.synch) {
            this.aspect = aspectRatio;
            computeMatrix();
        }
    }

    /**
     * Gets the aspect ratio of the view port.
     *
     * @return  the aspect ratio (width / height)
     */
    public double getAspect() {

        synchronized (this.synch) {
            return this.aspect;
        }
    }

    /**
     * Sets the distance from the camera to the near clip plane. This value must be greater than
     * zero to avoid singularities in the projection to screen space.
     *
     * @param  nearClipDist  the new near clip distance
     */
    public void setNearClip(final double nearClipDist) {

        synchronized (this.synch) {
            this.nearClip = nearClipDist;
        }
    }

    /**
     * Gets the distance from the camera to the near clip plane.
     *
     * @return  the near clip distance
     */
    public double getNearClip() {

        synchronized (this.synch) {
            return this.nearClip;
        }
    }

    /**
     * Sets the distance from the camera to the far clip plane. This value must be greater than
     * zero to avoid singularities in the projection to screen space.
     *
     * @param  farClipDist  the new far clip distance
     */
    public void setFarClip(final double farClipDist) {

        synchronized (this.synch) {
            this.farClip = farClipDist;
        }
    }

    /**
     * Gets the distance from the camera to the near clip plane.
     *
     * @return  the far clip distance
     */
    public double getFarClip() {

        synchronized (this.synch) {
            return this.farClip;
        }
    }

    /**
     * Sets the polar angle and recomputes the transformation matrix.
     *
     * @param  angle         the new polar angle
     * @param  updateMatrix  <code>true</code> to recompute the matrix, <code>false</code> to leave
     *                       the matrix in place (used where there are multiple adjustments -
     *                       update the matrix only after the last adjustment)
     */
    public void setPolarAngle(final double angle, final boolean updateMatrix) {

        synchronized (this.synch) {
            this.polar = angle;

            if (updateMatrix) {
                computeMatrix();
            }
        }
    }

    /**
     * Adjusts the polar angle by some amount. The polar angle is range limited - attempts to
     * adjust it above Pi or below negative Pi will result in the value stopping at Pi or negative
     * Pi, respectively (it does not "wrap around").
     *
     * @param  delta         the amount by which to adjust the angle (may be positive or negative)
     * @param  updateMatrix  <code>true</code> to recompute the matrix, <code>false</code> to leave
     *                       the matrix in place (used where there are multiple adjustments -
     *                       update the matrix only after the last adjustment)
     */
    public void adjustPolarAngle(final double delta, final boolean updateMatrix) {

        synchronized (this.synch) {
            this.polar += delta;

            if (this.polar < 0) {
                this.polar = 0;
            }

            if (this.polar > Math.PI) {
                this.polar = Math.PI;
            }

            if (updateMatrix) {
                computeMatrix();
            }
        }
    }

    /**
     * Sets the azimuthal angle and recomputes the transformation matrix.
     *
     * @param  angle         the new azimuthal angle
     * @param  updateMatrix  <code>true</code> to recompute the matrix, <code>false</code> to leave
     *                       the matrix in place (used where there are multiple adjustments -
     *                       update the matrix only after the last adjustment)
     */
    public void setAzimuthalAngle(final double angle, final boolean updateMatrix) {

        synchronized (this.synch) {
            this.azimuthal = angle;

            if (updateMatrix) {
                computeMatrix();
            }
        }
    }

    /**
     * Adjusts the azimuthal angle by some amount. The azimuthal angle "wraps around" if you
     * increase or decrease it beyond its limits.
     *
     * @param  delta         the amount by which to adjust the angle (may be positive or negative)
     * @param  updateMatrix  <code>true</code> to recompute the matrix, <code>false</code> to leave
     *                       the matrix in place (used where there are multiple adjustments -
     *                       update the matrix only after the last adjustment)
     */
    public void adjustAzimuthalAngle(final double delta, final boolean updateMatrix) {

        synchronized (this.synch) {
            this.azimuthal += delta;

            while (this.azimuthal < 0) {
                this.azimuthal += TWO_PI;
            }

            while (this.azimuthal >= TWO_PI) {
                this.azimuthal -= TWO_PI;
            }

            if (updateMatrix) {
                computeMatrix();
            }
        }
    }

    /**
     * Gets the camera distance.
     *
     * @return  the distance
     */
    public double getDistance() {

        synchronized (this.synch) {
            return this.distance;
        }
    }

    /**
     * Sets the camera distance and recomputes the transformation matrix.
     *
     * @param  dist          the new distance
     * @param  updateMatrix  <code>true</code> to recompute the matrix, <code>false</code> to leave
     *                       the matrix in place (used where there are multiple adjustments -
     *                       update the matrix only after the last adjustment)
     */
    public void setDistance(final double dist, final boolean updateMatrix) {

        synchronized (this.synch) {

            if (dist < this.nearClip) {
                this.distance = this.nearClip;
            } else {
                this.distance = dist;
            }

            if (updateMatrix) {

                // Updating the matrix is easy so just do it.
                this.xform.set(2, 3, -this.distance);
            }
        }
    }

    /**
     * Adjusts the distance by some amount. The distance can grow without limit but must always be
     * at least the near clip distance. Attempts to adjust distance downward beyond the near clip
     * distance will result in distance stopping at that distance.
     *
     * @param  delta         the amount by which to adjust the distance (may be positive or
     *                       negative)
     * @param  updateMatrix  <code>true</code> to recompute the matrix, <code>false</code> to leave
     *                       the matrix in place (used where there are multiple adjustments -
     *                       update the matrix only after the last adjustment)
     */
    public void adjustDistance(final double delta, final boolean updateMatrix) {

        synchronized (this.synch) {
            this.distance += delta;

            if (this.distance < this.nearClip) {
                this.distance = this.nearClip;
            }

            if (updateMatrix) {

                // Updating the matrix is easy so just do it.
                this.xform.set(2, 3, -this.distance);
            }
        }
    }

    /**
     * Applies the transformation to a tuple to convert world coordinates into view coordinates.
     *
     * @param  source  the tuple to transform
     * @param  dest    the tuple into which to place transformed coordinates
     */
    public void transformPoint(final Point3Int source, final Point3Int dest) {

        synchronized (this.synch) {
            this.xform.transformPoint(source, dest);
        }
    }

    /**
     * Applies the transformation to a tuple to convert world coordinates into view coordinates.
     *
     * @param  source  the tuple to transform
     * @param  dest    the tuple into which to place transformed coordinates
     */
    public void transformVec(final Vector3Int source, final Vector3Int dest) {

        synchronized (this.synch) {
            this.xform.transformVec(source, dest);
        }
    }

    /**
     * Perform the perspective transformation to take points from the view frame into normalized
     * device coordinates.
     *
     * <p>The view frame has its origin at the eye point, and the camera is looking down the Z axis
     * in the negative direction. X increases to the right, Y increases upward.
     *
     * <p>In normalized device coordinates, points within the viewing angle have coordinates with
     * -1 < x < 1 and -1 < y < 1. The Z coordinate stores a scaling factor that can be applied to
     * the size of objects (the thickness of a line, for instance) due to its distance from the
     * viewer.
     *
     * @param  point  the point to map
     */
    public void toNormalizedDeviceCoordinates(final Point3Int point) {

        synchronized (this.synch) {
            point.setPos(-DIST * point.getPosX() / (this.aspect * point.getPosZ()),
                -DIST * point.getPosY() / point.getPosZ(), -DIST / point.getPosZ());
        }
    }

    /**
     * Scales the normalized device coordinates to the screen and centers them in the window.
     *
     * @param  width   the screen width
     * @param  height  the screen height
     * @param  vec     the vector to map
     */
    public void vecToScreen(final int width, final int height, final Vector3Int vec) {

        synchronized (this.synch) {
            vec.setVecX((width + (vec.getVecX() * width)) * 0.5);
            vec.setVecY((height - (vec.getVecY() * height)) * 0.5);
        }
    }

    /**
     * Scales the normalized device coordinates to the screen and centers them in the window.
     *
     * @param  width   the screen width
     * @param  height  the screen height
     * @param  point   the point to map
     */
    public void pointToScreen(final int width, final int height, final Point3Int point) {

        synchronized (this.synch) {
            point.setPosX((width + (point.getPosX() * width)) * 0.5);
            point.setPosY((height - (point.getPosY() * height)) * 0.5);
        }
    }

    /**
     * Performs the complete transformation from world to view coordinates, then to normalized
     * device coordinates, then to integer screen coordinates.
     *
     * @param  source  the tuple to transform
     * @param  width   the screen width
     * @param  height  the screen height
     * @param  point   the tuple into which to place transformed coordinates
     */
    public void transformToScreen(final Point3Int source, final int width, final int height,
        final Point3Int point) {

        synchronized (this.synch) {
            this.xform.transformPoint(source, point);
            point.setPos(-DIST * point.getPosX() / (this.aspect * point.getPosZ()),
                -DIST * point.getPosY() / point.getPosZ(), -DIST / point.getPosZ());
            point.setPosX((int) (width + (point.getPosX() * width)) >> 1);
            point.setPosY((int) (height - (point.getPosY() * height)) >> 1);
        }
    }

    /**
     * Clips a line to the view frustum, testing whether the line falls completely outside the
     * frustum and can be culled. Clipping is done in the view frame (before converting to
     * normalized device coordinates), since it is possible to divide by zero when converting an
     * unclipped line to normalized device coordinates.
     *
     * @param   end1  on entry, the first end of the unclipped line; on exit, the first end of the
     *                clipped line
     * @param   end2  on entry, the second end of the unclipped line; on exit, the second end of
     *                the clipped line
     * @return  <code>true</code> if any portion of the line was within the view frustum; <code>
     *          false</code> if the line can be culled
     */
    public boolean clipLine(final Point3 end1, final Point3 end2) {

        double end1Z;
        double end2Z;
        double lambda;
        boolean isVisible;

        end1Z = end1.getPosZ();
        end2Z = end2.getPosZ();

        if ((end1Z <= this.nearClip) || (end2Z <= this.nearClip)) {

            if (end1Z > this.nearClip) {

                // New start is intersection of line with near clip plane
                lambda = (end1Z - this.nearClip) / (end1Z - end2Z);
                end1.setPos(end1.getPosX() - (lambda * (end1.getPosX() - end2.getPosX())),
                    end1.getPosY() - (lambda * (end1.getPosY() - end2.getPosY())), this.nearClip);
            } else if (end2Z > this.nearClip) {

                // New end is intersection of line with near clip plane
                lambda = (end2Z - this.nearClip) / (end2Z - end1Z);
                end2.setPos(end2.getPosX() - (lambda * (end2.getPosX() - end1.getPosX())),
                    end2.getPosY() - (lambda * (end2.getPosY() - end1.getPosY())), this.nearClip);
            }

            isVisible = true;
        } else {
            isVisible = false;
        }

        return isVisible;
    }

    /**
     * Computes the camera world-to-view transformation.
     */
    private void computeMatrix() {

        double sinPolar;
        double cosPolar;
        double sinAzimuth;
        double cosAzimuth;

        synchronized (this.synch) {
            sinPolar = Math.sin(this.polar);
            cosPolar = Math.cos(this.polar);
            sinAzimuth = Math.sin(this.azimuthal);
            cosAzimuth = Math.cos(this.azimuthal);

            this.xform.set(0, 0, -sinAzimuth);
            this.xform.set(1, 0, cosAzimuth);
            this.xform.set(2, 0, 0);

            this.xform.set(0, 1, -cosPolar * cosAzimuth);
            this.xform.set(1, 1, -cosPolar * sinAzimuth);
            this.xform.set(2, 1, sinPolar);

            this.xform.set(0, 2, sinPolar * cosAzimuth);
            this.xform.set(1, 2, sinPolar * sinAzimuth);
            this.xform.set(2, 2, cosPolar);

            this.xform.set(0, 3, (this.distance * sinPolar * cosAzimuth) + this.lookAt.getPosX());
            this.xform.set(1, 3, (this.distance * sinPolar * sinAzimuth) + this.lookAt.getPosY());
            this.xform.set(2, 3, (this.distance * cosPolar) + this.lookAt.getPosZ());

            this.xform.invert();
        }
    }
}
