package com.srbenoit.render;

import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.awt.event.MouseWheelEvent;
import java.awt.event.MouseWheelListener;
import java.awt.image.BufferedImage;
import java.util.logging.Level;
import com.srbenoit.log.LoggedPanel;

/**
 * A panel that implements a double-buffered offscreen image. Two offscreen images are maintained
 * that are the same size as the panel (this can change if the window is resized). One of these
 * images is passed to a <code>RenderPipeline</code> for rendering, while the other is used to
 * satisfy paint requests. When the rendering is complete, the back buffers are flipped, and a
 * repaint is requested, sending the most recent image to the display.
 *
 * <p>A 3-D program will construct a scene and camera, construct one of these panels, then begin a
 * main loop where geometry is updated and rendering is requested. Rendering requests go through
 * this panel, which calls a render pipeline (maintained internal to this object), passing the
 * passive image. Meanwhile, any requests to draw the window use the active (most recently
 * completed) rendered image.
 */
public class RenderPanel extends LoggedPanel implements ComponentListener, MouseListener,
    MouseMotionListener, MouseWheelListener {

    /** version number for serialization */
    private static final long serialVersionUID = -8751092645837569281L;

    /** object on which to synchronize access to member variables */
    private final Object synch;

    /** the current width of the offscreen images */
    private int width;

    /** the current height of the offscreen images */
    private int height;

    /** distance by which to change camera per wheel move */
    private final double perWheel;

    /** the most recently drawn image */
    private BufferedImage active;

    /** an image to which the next render should be directed */
    private BufferedImage passive;

    /** temporary storage for an incorrectly sized image rendered while size was changing */
    private BufferedImage temp;

    /** the camera that will be used in rendering the scene */
    private Camera camera;

    /** the render pipeline that will to the actual rendering */
    private RenderPipeline pipeline;

    /** the point where a drag began */
    private Point dragStart;

    /**
     * Constructs a new <code>DoubleBufferPanel</code>. This should be called from the AWT event
     * dispatcher thread.
     *
     * @param  initWidth   the initial preferred width
     * @param  initHeight  the initial preferred height
     * @param  theCamera   the camera that will be used in rendering the scene
     */
    public RenderPanel(final int initWidth, final int initHeight, final Camera theCamera) {

        super();

        this.synch = new Object();
        this.perWheel = theCamera.getDistance() / 100;

        setPreferredSize(new Dimension(initWidth, initHeight));
        setSize(initWidth, initHeight);

        this.active = null;
        this.passive = null;
        this.temp = null;
        this.width = 0;
        this.height = 0;
        updateOffscreenImages();

        this.camera = theCamera;
        this.pipeline = new RenderPipeline();
        this.dragStart = null;

        addComponentListener(this);
        addMouseListener(this);
        addMouseMotionListener(this);
        addMouseWheelListener(this);
    }

    /**
     * Renders a scene using a specified camera onto the passive back buffer image, stores the
     * result where it will be used on the next repaint, then requests a repaint. This must NOT be
     * called from the AWT event thread, so that repaints of the window can occur independent of
     * the rendering process.
     *
     * @param  scene  the scene to render
     */
    public void render(final Scene scene) {

        BufferedImage target;

        synchronized (this.synch) {
            target = this.passive;
        }

        if (target != null) {
            this.pipeline.render(scene, this.camera, target);

            synchronized (this.synch) {

                if (this.passive == target) {

                    // Images have not been reallocated, so just flip buffers
                    this.passive = this.active;
                    this.active = target;
                    this.temp = null;
                } else {

                    // A resize has resulted in new images, so place our results in "temp"
                    this.temp = target;
                }
            }
        }

        repaint();
    }

    /**
     * Tests the current size of the panel against the offscreen image sizes, and if needed,
     * creates new offscreen images. This is done in a synchronized block so a completing render
     * cannot stomp an image we create, and this method will not stomp the output of a render.
     */
    private void updateOffscreenImages() {

        int actWidth;
        int actHeight;

        actWidth = getWidth();
        actHeight = getHeight();

        synchronized (this.synch) {

            LOG.log(Level.FINE, "Building offscreen images {0}x{1}",
                new Object[] { actWidth, actHeight });

            if ((actWidth != this.width) || (actHeight != this.height)) {
                this.active = new BufferedImage(actWidth, actHeight, BufferedImage.TYPE_INT_RGB);
                this.passive = new BufferedImage(actWidth, actHeight, BufferedImage.TYPE_INT_RGB);

                this.width = actWidth;
                this.height = actHeight;
            }
        }
    }

    /**
     * Draws the panel contents using the most up-to-date rendered image available.
     *
     * @param  grx  the <code>graphics</code> to which to draw.
     */
    @Override public void paintComponent(final Graphics grx) {

        int actWidth;
        int actHeight;
        double xScale;
        double yScale;
        double scale;
        int scaledWidth;
        int scaledHeight;

        super.paintComponent(grx);

        synchronized (this.synch) {

            if (this.temp == null) {

                if (this.active != null) {
                    grx.drawImage(this.active, 0, 0, null);
                }

            } else {
                actWidth = getWidth();
                actHeight = getHeight();
                xScale = actWidth / this.temp.getWidth();
                yScale = actHeight / this.temp.getHeight();
                scale = (xScale > yScale) ? xScale : yScale;
                scaledWidth = (int) ((this.temp.getWidth() * scale) + 0.5); // at least actWidth
                scaledHeight = (int) ((this.temp.getHeight() * scale) + 0.5); // at least actHeight
                grx.drawImage(this.temp, (actWidth - scaledWidth) / 2,
                    (actHeight - scaledHeight) / 2, scaledWidth, scaledHeight, null);
            }
        }
    }

    /**
     * Handles panel resize events, which require allocation of new offscreen images.
     *
     * @param  evt  the component event
     */
    public void componentResized(final ComponentEvent evt) {

        updateOffscreenImages();
    }

    /**
     * Handles panel move events, which we ignore.
     *
     * @param  evt  the component event
     */
    public void componentMoved(final ComponentEvent evt) {
        // No action
    }

    /**
     * Handles panel shown events, which causes a test for actual size against current size of the
     * offscreen images, and may result in allocation of new offscreen images.
     *
     * <p>NOTE: Rendering is done outside the AWT event loop, but writes to the offscreen images.
     * At the end of a render process, if the rendered image is not the size specified in <code>
     * width</code> and <code>height</code>, we store the image in the <code>temp</code> member
     * variable. Otherwise, the end of the render cycle stores the result as the active image, and
     * clears the <code>temp</code> member. When a repaint is requested, if the <code>temp</code>
     * object has an image, that image is drawn, but scaled to fit the window.
     *
     * @param  evt  the component event
     */
    public void componentShown(final ComponentEvent evt) {

        updateOffscreenImages();
    }

    /**
     * Handles panel hidden events, which we ignore.
     *
     * @param  evt  the component event
     */
    public void componentHidden(final ComponentEvent evt) {
        // No action
    }

    /**
     * Handles mouse click events.
     *
     * @param  evt  the mouse event
     */
    @Override public void mouseClicked(final MouseEvent evt) {
        // No action
    }

    /**
     * Handles mouse press events.
     *
     * @param  evt  the mouse event
     */
    @Override public void mousePressed(final MouseEvent evt) {

        this.dragStart = evt.getPoint();
    }

    /**
     * Handles mouse release events.
     *
     * @param  evt  the mouse event
     */
    @Override public void mouseReleased(final MouseEvent evt) {

        this.dragStart = null;
    }

    /**
     * Handles mouse entered events.
     *
     * @param  evt  the mouse event
     */
    @Override public void mouseEntered(final MouseEvent evt) {
        // No action
    }

    /**
     * Handles mouse exited events.
     *
     * @param  evt  the mouse event
     */
    @Override public void mouseExited(final MouseEvent evt) {
        // No action
    }

    /**
     * Handles mouse drag events.
     *
     * @param  evt  the mouse event
     */
    @Override public void mouseDragged(final MouseEvent evt) {

        Point where;
        int moveX;
        int moveY;

        if (this.dragStart != null) {
            where = evt.getPoint();

            moveX = this.dragStart.x - where.x;
            moveY = this.dragStart.y - where.y;

            if (moveX != 0) {
                this.camera.adjustAzimuthalAngle(moveX / 30.0, true);
            }

            if (moveY != 0) {
                this.camera.adjustPolarAngle(moveY / 30.0, true);
            }

            this.dragStart = where;
        }
    }

    /**
     * Handles mouse move events.
     *
     * @param  evt  the mouse event
     */
    @Override public void mouseMoved(final MouseEvent evt) {
        // No action
    }

    /**
     * Handles mouse wheel events.
     *
     * @param  evt  the mouse wheel event
     */
    public void mouseWheelMoved(final MouseWheelEvent evt) {

        int units;
        double delta;

        units = evt.getUnitsToScroll();
        delta = this.perWheel * units;
        this.camera.adjustDistance(delta, true);
    }
}
