/*
 * #%L
 * gitools-ui-platform
 * %%
 * Copyright (C) 2013 Universitat Pompeu Fabra - Biomedical Genomics group
 * %%
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as
 * published by the Free Software Foundation, either version 3 of the 
 * License, or (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public 
 * License along with this program.  If not, see
 * <http://www.gnu.org/licenses/gpl-3.0.html>.
 * #L%
 */
package org.gitools.ui.platform.imageviewer;

import javax.swing.*;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.MouseInputListener;
import java.applet.Applet;
import java.awt.*;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.MouseEvent;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.util.ArrayList;
import java.util.List;

/**
 * The component that displays the image itself.
 *
 * @author Kazó Csaba
 */
class ImageComponent extends JComponent {
    private ResizeStrategy resizeStrategy = ResizeStrategy.SHRINK_TO_FIT;
    private BufferedImage image;
    private boolean pixelatedZoom = false;
    private Object interpolationType = RenderingHints.VALUE_INTERPOLATION_BICUBIC;
    private double zoomFactor = 1;
    private final List<ImageMouseMotionListener> moveListeners = new ArrayList<>(4);
    private final List<ImageMouseClickListener> clickListeners = new ArrayList<>(4);
    private final MouseEventTranslator mouseEventTranslator = new MouseEventTranslator();
    private final PaintManager paintManager = new PaintManager();

    /* Handles repositioning the scroll pane when the image is resized so that the same area remains visible. */
    class Rescroller {
        private Point2D preparedCenter = null;

        void prepare() {
            if (image != null && hasSize()) {
                Rectangle viewRect = viewer.getScrollPane().getViewport().getViewRect();
                preparedCenter = new Point2D.Double(viewRect.getCenterX(), viewRect.getCenterY());
                try {
                    getImageTransform().inverseTransform(preparedCenter, preparedCenter);
                } catch (NoninvertibleTransformException e) {
                    throw new Error(e);
                }
            }
        }

        void rescroll() {
            if (preparedCenter != null) {
                Dimension viewSize = viewer.getScrollPane().getViewport().getExtentSize();
                getImageTransform().transform(preparedCenter, preparedCenter);
                Rectangle view = new Rectangle((int) Math.round(preparedCenter.getX() - viewSize.width / 2.0), (int) Math.round(preparedCenter.getY() - viewSize.height / 2.0), viewSize.width, viewSize.height);
                preparedCenter = null;
                scrollRectToVisible(view);
            }
        }

    }

    private Rescroller rescroller = new Rescroller();

    private final PropertyChangeSupport propertyChangeSupport;
    private final ImageViewer viewer;

    public ImageComponent(ImageViewer viewer, PropertyChangeSupport propertyChangeSupport) {
        this.viewer = viewer;
        this.propertyChangeSupport = propertyChangeSupport;
        mouseEventTranslator.register(this);
        setOpaque(true);
        viewer.getScrollPane().getViewport().addChangeListener(new ChangeListener() {

            @Override
            public void stateChanged(ChangeEvent e) {
                /*
                 * Here the viewer might not have a size yet, because we might be before the first layout.
				 * But that's alright. As soon as we get our size, the viewport will send another state change.
				 */
                if (hasSize())
                    mouseEventTranslator.correctionalFire();
            }
        });
    }

    private boolean hasSize() {
        return getWidth() > 0 && getHeight() > 0;
    }

    @Override
    public Dimension getMaximumSize() {
        return new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE);
    }

    public void addImageMouseMoveListener(ImageMouseMotionListener l) {
        if (l != null)
            moveListeners.add(l);
    }

    public void removeImageMouseMoveListener(ImageMouseMotionListener l) {
        if (l != null)
            moveListeners.remove(l);
    }

    public void addImageMouseClickListener(ImageMouseClickListener l) {
        if (l != null)
            clickListeners.add(l);
    }

    public void removeImageMouseClickListener(ImageMouseClickListener l) {
        if (l != null)
            clickListeners.remove(l);
    }

    public void setImage(BufferedImage newImage) {
        BufferedImage oldImage = image;
        image = newImage;
        paintManager.notifyChanged();
        if (oldImage != newImage &&
                (oldImage == null || newImage == null || oldImage.getWidth() != newImage.getWidth() ||
                        oldImage.getHeight() != newImage.getHeight()))
            revalidate();
        repaint();
        propertyChangeSupport.firePropertyChange("image", oldImage, newImage);
    }

    public BufferedImage getImage() {
        return image;
    }

    /**
     * Preforms all necessary actions to ensure that the viewer is resized to its proper size. It does that by invoking
     * {@code validate()} on the viewer's validateRoot. It also issues a {@code repaint()}.
     */
    private void resizeNow() {
        invalidate();
        // find the validate root; adapted from the package-private SwingUtilities.getValidateRoot
        Container root = null;
        Container c = this;
        for (; c != null; c = c.getParent()) {
            if (!c.isDisplayable() || c instanceof CellRendererPane) {
                return;
            }
            if (c.isValidateRoot()) {
                root = c;
                break;
            }
        }

        if (root == null) return;

        for (; c != null; c = c.getParent()) {
            if (!c.isDisplayable() || !c.isVisible()) {
                return;
            }
            if (c instanceof Window || c instanceof Applet) {
                break;
            }
        }

        if (c == null) return;

        root.validate();
        repaint();
    }

    public void setResizeStrategy(ResizeStrategy resizeStrategy) {
        if (resizeStrategy == this.resizeStrategy)
            return;
        rescroller.prepare();
        ResizeStrategy oldResizeStrategy = this.resizeStrategy;
        this.resizeStrategy = resizeStrategy;
        boolean canRescroll = viewer.getSynchronizer().resizeStrategyChangedCanIRescroll(viewer);
        resizeNow();

        if (canRescroll) {
            rescroller.rescroll();
            viewer.getSynchronizer().doneRescrolling(viewer);
        }
        propertyChangeSupport.firePropertyChange("resizeStrategy", oldResizeStrategy, resizeStrategy);
    }

    public ResizeStrategy getResizeStrategy() {
        return resizeStrategy;
    }

    public void setInterpolationType(Object type) {
        if (interpolationType == type)
            return;
        if (
                type != RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR &&
                        type != RenderingHints.VALUE_INTERPOLATION_BILINEAR &&
                        type != RenderingHints.VALUE_INTERPOLATION_BICUBIC)
            throw new IllegalArgumentException("Invalid interpolation type; use one of the RenderingHints constants");
        Object old = this.interpolationType;
        this.interpolationType = type;
        viewer.getSynchronizer().interpolationTypeChanged(viewer);
        paintManager.notifyChanged();
        repaint();
        propertyChangeSupport.firePropertyChange("interpolationType", old, type);
    }

    public Object getInterpolationType() {
        return interpolationType;
    }

    public void setPixelatedZoom(boolean pixelatedZoom) {
        if (pixelatedZoom == this.pixelatedZoom)
            return;
        this.pixelatedZoom = pixelatedZoom;
        viewer.getSynchronizer().pixelatedZoomChanged(viewer);
        paintManager.notifyChanged();
        repaint();
        propertyChangeSupport.firePropertyChange("pixelatedZoom", !pixelatedZoom, pixelatedZoom);
    }

    public boolean isPixelatedZoom() {
        return pixelatedZoom;
    }

    /**
     * Returns the zoom factor used when resize strategy is CUSTOM_ZOOM.
     */
    public double getZoomFactor() {
        return zoomFactor;
    }

    /**
     * Sets the zoom factor to use when the resize strategy is CUSTOM_ZOOM.
     * <p/>
     * Note that calling this function does not change the current resize strategy.
     *
     * @throws IllegalArgumentException if {@code newZoomFactor} is not a positive number
     */
    public void setZoomFactor(double newZoomFactor) {
        if (zoomFactor == newZoomFactor) return;
        if (newZoomFactor <= 0 || Double.isInfinite(newZoomFactor) || Double.isNaN(newZoomFactor))
            throw new IllegalArgumentException("Invalid zoom factor: " + newZoomFactor);
        if (getResizeStrategy() == ResizeStrategy.CUSTOM_ZOOM) {
            rescroller.prepare();
        }
        double oldZoomFactor = zoomFactor;
        zoomFactor = newZoomFactor;
        boolean canRescroll = viewer.getSynchronizer().zoomFactorChangedCanIRescroll(viewer);
        if (getResizeStrategy() == ResizeStrategy.CUSTOM_ZOOM) {
            resizeNow();
            // do not rescroll if we're following another viewer; the scrolling will be synchronized later
            if (canRescroll) {
                rescroller.rescroll();
                viewer.getSynchronizer().doneRescrolling(viewer);
            }
        } else {
            // no rescrolling is necessary, actually
            if (canRescroll)
                viewer.getSynchronizer().doneRescrolling(viewer);
        }
        propertyChangeSupport.firePropertyChange("zoomFactor", oldZoomFactor, newZoomFactor);
    }

    @Override
    public Dimension getPreferredSize() {
        if (image == null) {
            return new Dimension();
        } else if (resizeStrategy == ResizeStrategy.CUSTOM_ZOOM) {
            return new Dimension((int) Math.ceil(image.getWidth() * zoomFactor), (int) Math.ceil(image.getHeight() * zoomFactor));
        } else
            return new Dimension(image.getWidth(), image.getHeight());
    }

    /**
     * Returns the image pixel that is under the given point.
     *
     * @param p a point in component coordinate system
     * @return the corresponding image pixel, or <code>null</code> if the point is outside the image
     */
    public Point pointToPixel(Point p) {
        return pointToPixel(p, true);
    }

    /**
     * Returns the image pixel corresponding to the given point. If the <code>clipToImage</code>
     * parameter is <code>false</code>, then the function will return an appropriately positioned
     * pixel on an infinite plane, even if the point is outside the image bounds. If
     * <code>clipToImage</code> is <code>true</code> then the function will return <code>null</code>
     * for such positions, and any non-null return value will be a valid image pixel.
     *
     * @param p           a point in component coordinate system
     * @param clipToImage whether the function should return <code>null</code> for positions outside
     *                    the image bounds
     * @return the corresponding image pixel
     * @throws IllegalStateException if there is no image set or if the size of the viewer is 0 (for example because
     *                               it is not in a visible component)
     */
    public Point pointToPixel(Point p, boolean clipToImage) {
        Point2D.Double fp = new Point2D.Double(p.x + .5, p.y + .5);
        try {
            getImageTransform().inverseTransform(fp, fp);
        } catch (NoninvertibleTransformException ex) {
            throw new Error("Image transformation not invertible");
        }
        p.x = (int) Math.floor(fp.x);
        p.y = (int) Math.floor(fp.y);
        if (clipToImage && (p.x < 0 || p.y < 0 || p.x >= image.getWidth() || p.y >= image.getHeight())) {
            return null;
        }
        return p;
    }

    @Override
    protected void paintComponent(Graphics g) {
        paintManager.paintComponent(g);
    }

    /**
     * Returns the transformation that is applied to the image. Most commonly the transformation
     * is the concatenation of a uniform scale and a translation.
     * <p/>
     * The <code>AffineTransform</code>
     * instance returned by this method should not be modified.
     *
     * @return the transformation applied to the image before painting
     * @throws IllegalStateException if there is no image set or if the size of the viewer is 0 (for example because
     *                               it is not in a visible component)
     */
    public AffineTransform getImageTransform() {
        if (getImage() == null) throw new IllegalStateException("No image");
        if (!hasSize()) throw new IllegalStateException("Viewer size is zero");
        double currentZoom = getCurrentZoom();
        AffineTransform tr = new AffineTransform();
        tr.setToTranslation((getWidth() - image.getWidth() * currentZoom) / 2.0, (getHeight() - image.getHeight() * currentZoom) / 2.0);
        tr.scale(currentZoom, currentZoom);
        return tr;
    }

    public double getCurrentZoom() {

        double currentZoom;

        switch (resizeStrategy) {
            case NO_RESIZE:
                currentZoom = 1;
                break;
            case SHRINK_TO_FIT:
                currentZoom = Math.min(getSizeRatio(), 1);
                break;
            case RESIZE_TO_FIT:
                currentZoom = getSizeRatio();
                break;
            case CUSTOM_ZOOM:
                currentZoom = zoomFactor;
                break;
            default:
                throw new Error("Unhandled resize strategy");
        }

        return currentZoom;
    }

    private double getSizeRatio() {
        return Math.min(getWidth() / (double) image.getWidth(), getHeight() / (double) image.getHeight());
    }

    /**
     * Helper class that generates ImageMouseEvents by translating normal mouse events onto
     * the image.
     */
    private class MouseEventTranslator implements MouseInputListener, PropertyChangeListener {
        /**
         * This flag is true if the mouse cursor is inside the bounds of the image.
         */
        private boolean on = false;
        /**
         * The last position reported. This is used to avoid multiple successive image mouse motion events
         * with the same position.
         */
        private Point lastPosition = null;

        /**
         * Sets up this translator.
         */
        private void register(ImageComponent ic) {
            ic.addMouseListener(this);
            ic.addMouseMotionListener(this);
            ic.propertyChangeSupport.addPropertyChangeListener(this);
            ic.addComponentListener(new ComponentAdapter() {

                @Override
                public void componentResized(ComponentEvent e) {
                    correctionalFire();
                }

            });
        }

        private void handleMouseAt(Point position, MouseEvent event) {
            if (image == null) {
                if (on) {
                    on = false;
                    fireMouseExit();
                }
            } else {
                if (position != null) position = pointToPixel(position);
                if (position == null) {
                    if (on) {
                        on = false;
                        fireMouseExit();
                    }
                } else {
                    if (!on) {
                        on = true;
                        lastPosition = null;
                        fireMouseEnter(position.x, position.y, event);
                    }
                    if (!position.equals(lastPosition)) {
                        lastPosition = position;
                        fireMouseAtPixel(position.x, position.y, event);
                    }
                }
            }
        }

        @Override
        public void mouseClicked(MouseEvent e) {
            if (image == null || !on) return;
            Point p = pointToPixel(e.getPoint());
            if (p != null) {
                fireMouseClickedAtPixel(p.x, p.y, e);
            }
        }

        @Override
        public void mouseEntered(MouseEvent e) {
            if (image != null) {
                Point p = pointToPixel(e.getPoint());
                if (p != null) {
                    on = true;
                    fireMouseEnter(p.x, p.y, e);
                    fireMouseAtPixel(p.x, p.y, e);
                }
            }
        }

        @Override
        public void mouseExited(MouseEvent e) {
            if (on) {
                on = false;
                fireMouseExit();
            }
        }

        @Override
        public void mouseMoved(MouseEvent e) {
            handleMouseAt(e.getPoint(), e);
        }

        @Override
        public void mouseDragged(MouseEvent e) {
            if (image == null) return;
            Point p = pointToPixel(e.getPoint(), false);
            fireMouseDrag(p.x, p.y, e);
        }

        @Override
        public void propertyChange(PropertyChangeEvent evt) {
            if (
                    "image".equals(evt.getPropertyName()) ||
                            "resizeStrategy".equals(evt.getPropertyName()) ||
                            (getResizeStrategy() == ResizeStrategy.CUSTOM_ZOOM && "zoomFactor".equals(evt.getPropertyName()))) {
                correctionalFire();
            }
        }

        /**
         * Fires a motion event based on the current cursor position. Use this method if something other than mouse motion
         * changed where the cursor is relative to the image.
         */
        private void correctionalFire() {
            /**
             * We use our parent, LayeredImageView, to locate the mouse. If the viewer has an overlay, then
             * ImageComponent.getMousePosition will return null because the mouse is over the overlay and not the image
             * component.
             */
            handleMouseAt(getParent().getMousePosition(true), null);
        }

        private void fireMouseAtPixel(int x, int y, MouseEvent ev) {
            ImageMouseEvent e = null;
            for (ImageMouseMotionListener imageMouseMoveListener : moveListeners) {
                if (e == null)
                    e = new ImageMouseEvent(viewer, image, x, y, ev);
                imageMouseMoveListener.mouseMoved(e);
            }
        }

        private void fireMouseClickedAtPixel(int x, int y, MouseEvent ev) {
            ImageMouseEvent e = null;
            for (ImageMouseClickListener imageMouseClickListener : clickListeners) {
                if (e == null)
                    e = new ImageMouseEvent(viewer, image, x, y, ev);
                imageMouseClickListener.mouseClicked(e);
            }
        }

        private void fireMouseEnter(int x, int y, MouseEvent ev) {
            ImageMouseEvent e = null;
            for (ImageMouseMotionListener imageMouseMoveListener : moveListeners) {
                if (e == null)
                    e = new ImageMouseEvent(viewer, image, x, y, ev);
                imageMouseMoveListener.mouseEntered(e);
            }
        }

        private void fireMouseExit() {
            ImageMouseEvent e = null;
            for (ImageMouseMotionListener imageMouseMoveListener : moveListeners) {
                if (e == null)
                    e = new ImageMouseEvent(viewer, image, -1, -1, null);
                imageMouseMoveListener.mouseExited(e);
            }
        }

        private void fireMouseDrag(int x, int y, MouseEvent ev) {
            ImageMouseEvent e = null;
            for (ImageMouseMotionListener imageMouseMoveListener : moveListeners) {
                if (e == null)
                    e = new ImageMouseEvent(viewer, image, x, y, ev);
                imageMouseMoveListener.mouseDragged(e);
            }
        }

        @Override
        public void mousePressed(MouseEvent e) {
        }

        @Override
        public void mouseReleased(MouseEvent e) {
        }
    }

    /**
     * Helper class that manages the actual painting.
     */
    private class PaintManager {
        BufferedImage cachedImage = null;
        boolean cachedImageChanged = false;
        AffineTransform cachedTransform;

        private void doPaint(Graphics2D gg, AffineTransform imageTransform) {
            gg.setColor(getBackground());
            gg.fillRect(0, 0, getWidth(), getHeight());

            gg.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
            if (pixelatedZoom && imageTransform.getScaleX() >= 1)
                gg.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
            else
                gg.setRenderingHint(RenderingHints.KEY_INTERPOLATION, interpolationType);

            gg.drawImage(image, imageTransform, ImageComponent.this);
        }

        private void ensureCachedValid(AffineTransform imageTransform) {
            boolean cacheValid;

            // create the image if necessary; if the existing one is sufficiently large, use it
            if (cachedImage == null || cachedImage.getWidth() < getWidth() || cachedImage.getHeight() < getHeight()) {
                cachedImage = getGraphicsConfiguration().createCompatibleImage(getWidth(), getHeight());
                cacheValid = false;
            } else {
                cacheValid = cachedTransform.equals(imageTransform) && !cachedImageChanged;
            }


            if (!cacheValid) {
                Graphics2D gg = cachedImage.createGraphics();
                doPaint(gg, imageTransform);
                gg.dispose();
                cachedImageChanged = false;
                cachedTransform = new AffineTransform(imageTransform);
            }
        }

        /**
         * Called when a property which affects how the component is painted changes. This invalidates the cache and causes
         * it to be redrawn upon the next paint request.
         */
        public void notifyChanged() {
            cachedImageChanged = true;
        }

        public void paintComponent(Graphics g) {
            if (image == null) {
                Graphics2D gg = (Graphics2D) g.create();
                gg.setColor(getBackground());
                gg.fillRect(0, 0, getWidth(), getHeight());
                gg.dispose();
                return;
            }

            AffineTransform imageTransform = getImageTransform();

            if (imageTransform.getScaleX() < 1 && interpolationType != RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR) {
				/* 
				* We're shrinking the image; instead of letting the Graphics object do it every time, we do it and cache
				* the result.
				*/
                ensureCachedValid(imageTransform);
                g.drawImage(cachedImage, 0, 0, ImageComponent.this);
            } else {
                // draw the image directly
                Graphics2D gg = (Graphics2D) g.create();
                doPaint(gg, imageTransform);
                gg.dispose();
            }

        }
    }
}
