/*
 * #%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 org.gitools.ui.platform.os.OSProperties;

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;

/**
 * A general purpose image viewer component. It contains a scroll pane and manages the size of the image in accordance
 * with the {@link #setResizeStrategy(ResizeStrategy) resize strategy} property. It can also provide a popup menu
 * containing generally useful viewing controls and functions; this default popup can be disabled by passing
 * {@code defaultPopupMenu = null} to the constructor.
 * <p/>
 * The Swing component that can be added to the GUI is obtained by calling
 * {@link #getComponent()}.
 * <p/>
 * ImageViewer supports {@link #setStatusBar(StatusBar) status bars}: arbitrary
 * components that can be added to the viewer and are displayed below the image.
 * <p/>
 * {@link #addOverlay Overlays} can also be added to the viewer; for details, see
 * the the documentation of the {@link Overlay} class.
 *
 * @author Kazó Csaba
 * @see StatusBar
 * @see Overlay
 */
public final class ImageViewer {

    private int ctrlMask = OSProperties.get().getCtrlMask();

    private final LayeredImageView view;
    private ImageComponent theImage;
    private final JScrollPane scroller;
    private JPanel panel;
    private StatusBar statusBar;
    private boolean statusBarVisible = false;
    private PropertyChangeSupport propertyChangeSupport;
    private Synchronizer synchronizer;

    /**
     * Creates a new image viewer. Initially it will be empty, and it will have a default popup menu.
     */
    public ImageViewer() {
        this(null);
    }

    /**
     * Creates a new image viewer displaying the specified image. TThe viewer will have a default popup menu.
     *
     * @param image the image to display; if {@code null} then no image is displayed
     */
    public ImageViewer(BufferedImage image) {
        this(image, true);
    }

    /**
     * Creates a new image viewer displaying the specified image.
     *
     * @param image            the image to display; if <code>null</code> then no image is displayed
     * @param defaultPopupMenu if {@code true}, then a default popup menu will be created and registered for the viewer
     * @see #setImage(java.awt.image.BufferedImage)
     */
    public ImageViewer(BufferedImage image, boolean defaultPopupMenu) {
        propertyChangeSupport = new PropertyChangeSupport(this);
        panel = new JPanel(new BorderLayout());
        scroller = new JScrollPane() {

            @Override
            protected JViewport createViewport() {
                return new JViewport() {

                    @Override
                    protected LayoutManager createLayoutManager() {
                        return new CustomViewportLayout(ImageViewer.this);
                    }

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

                };
            }

            @Override
            public boolean isValidateRoot() {
                return false;
            }

        };
        synchronizer = new Synchronizer(this);
        theImage = new ImageComponent(this, propertyChangeSupport);
        view = new LayeredImageView(theImage);
        scroller.setViewportView(view.getComponent());
        scroller.setWheelScrollingEnabled(true);
        theImage.setImage(image);

        panel.add(scroller, BorderLayout.CENTER);

        setStatusBar(new DefaultStatusBar());

        if (defaultPopupMenu) {
            theImage.addMouseListener(new MouseAdapter() {
                JPopupMenu popup;

                private void showPopup(MouseEvent e) {
                    e.consume();
                    if (popup == null) popup = new DefaultViewerPopup(ImageViewer.this);
                    Point p = panel.getPopupLocation(e);
                    if (p == null) {
                        p = e.getPoint();
                    }
                    popup.show(e.getComponent(), p.x, p.y);
                }

                @Override
                public void mousePressed(MouseEvent e) {
                    if (e.isPopupTrigger()) {
                        showPopup(e);
                    }
                }

                @Override
                public void mouseReleased(MouseEvent e) {
                    if (e.isPopupTrigger()) {
                        showPopup(e);
                    }
                }
            });
        }

        theImage.addMouseWheelListener(new MouseAdapter() {
            @Override
            public void mouseWheelMoved(MouseWheelEvent e) {
                int unitsToScroll = e.getUnitsToScroll();

                int modifiers = e.getModifiers();
                boolean ctrlDown = ((modifiers & ctrlMask) != 0);

                if (ctrlDown) {
                    double zoom = theImage.getCurrentZoom();
                    double factor = (zoom / 20.0) * (double) -unitsToScroll;
                    zoom = zoom + factor;
                    zoom = (zoom < 0.1 ? 0.1 : zoom);
                    zoom = (zoom > 10 ? 10 : zoom);
                    setResizeStrategy(ResizeStrategy.CUSTOM_ZOOM);
                    setZoomFactor(zoom);
                } else {
                    scroller.dispatchEvent(e);
                }
            }
        });

    }

    /**
     * Sets the status bar component for this image viewer. The new status bar is made
     * visible only if the statusBarVisible property is true. If <code>statusBar</code> is
     * <code>null</code> this method removes any existing status bar.
     *
     * @param statusBar the new status bar component to set
     * @throws IllegalArgumentException if the status bar has already been added to a
     *                                  different image viewer
     */
    public void setStatusBar(StatusBar statusBar) {
        if (statusBar == this.statusBar) return;
        if (statusBar.getImageViewer() != null)
            throw new IllegalArgumentException("Status bar already added to an image viewer");
        StatusBar oldStatusBar = this.statusBar;
        if (oldStatusBar != null) {
            panel.remove(oldStatusBar.getComponent());
            oldStatusBar.setImageViewer(null);
        }
        this.statusBar = statusBar;
        if (this.statusBar != null) {
            this.statusBar.setImageViewer(this);
            if (statusBarVisible) {
                panel.add(this.statusBar.getComponent(), BorderLayout.SOUTH);
                panel.revalidate();
                panel.repaint();
            }
        }
        propertyChangeSupport.firePropertyChange("statusBar", oldStatusBar, statusBar);
    }

    /**
     * Returns the status bar currently associated with this viewer.
     *
     * @return the current status bar, or {@code null} if the viewer has no status bar
     */
    public StatusBar getStatusBar() {
        return statusBar;
    }

    /**
     * Sets whether the status bar is visible. The status bar is hidden by default.
     *
     * @param statusBarVisible true, if the status bar should be visible; false otherwise
     */
    public void setStatusBarVisible(boolean statusBarVisible) {
        if (this.statusBarVisible == statusBarVisible) return;
        if (statusBar != null) {
            if (statusBarVisible)
                panel.add(statusBar.getComponent(), BorderLayout.SOUTH);
            else
                panel.remove(statusBar.getComponent());
            panel.revalidate();
            panel.repaint();
        }
        boolean prev = this.statusBarVisible;
        this.statusBarVisible = statusBarVisible;
        synchronizer.statusBarVisibilityChanged(this);
        propertyChangeSupport.firePropertyChange("statusBarVisible", prev, statusBarVisible);
    }

    /**
     * Returns whether the status bar is set to be visible. The status bar is hidden by default.
     *
     * @return the statusBarVisible property
     */
    public boolean isStatusBarVisible() {
        return statusBarVisible;
    }

    /**
     * Returns the image viewer component that can be displayed.
     *
     * @return the image viewer component
     */
    public JComponent getComponent() {
        return panel;
    }

    /**
     * Sets the image displayed by the viewer. If the argument is the same object as the image currently being displayed,
     * then this method will trigger a refresh. If you modify the image shown by the viewer, use this function to notify
     * the component and cause it to update.
     *
     * @param image the new image to display; if <code>null</code> then no image is displayed
     */
    public void setImage(BufferedImage image) {
        theImage.setImage(image);
    }

    /**
     * Returns the currently displayed image.
     *
     * @return the current image, or <code>null</code> if no image is displayed
     */
    public BufferedImage getImage() {
        return theImage.getImage();
    }

    /**
     * Sets the resize strategy this viewer should use. The default is {@link ResizeStrategy#SHRINK_TO_FIT}.
     *
     * @param resizeStrategy the new resize strategy
     */
    public void setResizeStrategy(ResizeStrategy resizeStrategy) {
        theImage.setResizeStrategy(resizeStrategy);
    }

    /**
     * Returns the current resize strategy. The default is {@link ResizeStrategy#SHRINK_TO_FIT}.
     *
     * @return the current resize strategy
     */
    public ResizeStrategy getResizeStrategy() {
        return theImage.getResizeStrategy();
    }

    /**
     * Sets whether the image should be resized with nearest neighbor interpolation when it is expanded.
     * The default is {@code false}.
     *
     * @param pixelatedZoom the new value of the pixelatedZoom property
     */
    public void setPixelatedZoom(boolean pixelatedZoom) {
        theImage.setPixelatedZoom(pixelatedZoom);
    }

    /**
     * Returns the current pixelated zoom setting. The default is {@code false}.
     *
     * @return the current pixelated zoom setting
     */
    public boolean isPixelatedZoom() {
        return theImage.isPixelatedZoom();
    }

    /**
     * Returns the current interpolation type. The default is {@link java.awt.RenderingHints#VALUE_INTERPOLATION_BICUBIC}.
     *
     * @return the interpolation type
     * @see #setInterpolationType(Object)
     */
    public Object getInterpolationType() {
        return theImage.getInterpolationType();
    }

    /**
     * Sets the interpolation type to use when resizing images. See {@link java.awt.RenderingHints#KEY_INTERPOLATION}
     * for details. The default value is {@code RenderingHints.VALUE_INTERPOLATION_BICUBIC}.
     * <p/>
     * The allowed values are:
     * <ul>
     * <li>{@link java.awt.RenderingHints#VALUE_INTERPOLATION_NEAREST_NEIGHBOR}
     * <li>{@link java.awt.RenderingHints#VALUE_INTERPOLATION_BILINEAR}
     * <li>{@link java.awt.RenderingHints#VALUE_INTERPOLATION_BICUBIC} (default)
     * </ul>
     * Changing the interpolation type to bilinear or nearest neighbor improves painting performance when the image
     * needs to be resized.
     * <p/>
     * Note: when the {@code pixelatedZoom} property is set to true and the image is enlarged, then the nearest
     * neighbor method is used regardless of this setting.
     *
     * @param type the interpolation type to use when resizing images
     * @throws IllegalArgumentException if the parameter is not one of the allowed values
     */
    public void setInterpolationType(Object type) {
        theImage.setInterpolationType(type);
    }

    /**
     * Returns the zoom factor used when resize strategy is CUSTOM_ZOOM. The default value is 1.
     *
     * @return the custom zoom factor
     */
    public double getZoomFactor() {
        return theImage.getZoomFactor();
    }

    /**
     * Sets the zoom factor to use when the resize strategy is CUSTOM_ZOOM. The default value is 1.
     * <p/>
     * Note that calling this function does not change the current resize strategy.
     *
     * @param newZoomFactor the new zoom factor for the CUSTOM_ZOOM strategy
     * @throws IllegalArgumentException if {@code newZoomFactor} is not a positive number
     */
    public void setZoomFactor(double newZoomFactor) {
        theImage.setZoomFactor(newZoomFactor);
    }

    /**
     * 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() {
        return theImage.getImageTransform();
    }

    /**
     * Adds an overlay as the specified layer.
     *
     * @param overlay the overlay to add
     * @param layer   the layer to add the overlay to; higher layers are on top of lower layers;
     *                the image resides in layer 0
     */
    public void addOverlay(Overlay overlay, int layer) {
        view.addOverlay(overlay, layer);
    }

    /**
     * Adds an overlay to layer 1.
     *
     * @param overlay the overlay to add
     */
    public void addOverlay(Overlay overlay) {
        addOverlay(overlay, 1);
    }

    /**
     * Removes an overlay from the image viewer.
     *
     * @param overlay the overlay to remove
     * @throws IllegalArgumentException if the overlay is not in the image viewer
     */
    public void removeOverlay(Overlay overlay) {
        view.removeOverlay(overlay);
    }

    /**
     * Adds the specified mouse listener to receive mouse events from
     * the image component of this image viewer. If listener <code>l</code>
     * is <code>null</code>, no exception is thrown and no action is performed.
     *
     * @param l the mouse listener
     */
    public void addMouseListener(MouseListener l) {
        theImage.addMouseListener(l);
    }

    /**
     * Removes the specified mouse listener so that it no longer receives
     * mouse motion events from the image component of this image viewer. This method
     * performs no function, nor does it throw an exception, if the listener specified
     * by the argument was not previously added to this component. If listener
     * <code>l</code> is <code>null</code>, no exception is thrown and no action is
     * performed.
     *
     * @param l the mouse motion listener
     */
    public void removeMouseListener(MouseListener l) {
        theImage.removeMouseListener(l);
    }

    /**
     * Adds the specified mouse motion listener to receive mouse events from
     * the image component of this image viewer. If listener <code>l</code>
     * is <code>null</code>, no exception is thrown and no action is performed.
     *
     * @param l the mouse listener
     */
    public void addMouseMotionListener(MouseMotionListener l) {
        theImage.addMouseMotionListener(l);
    }

    /**
     * Removes the specified mouse motion listener so that it no longer receives
     * mouse motion events from the image component of this image viewer. This method
     * performs no function, nor does it throw an exception, if the listener specified
     * by the argument was not previously added to this component. If listener
     * <code>l</code> is <code>null</code>, no exception is thrown and no action is
     * performed.
     *
     * @param l the mouse motion listener
     */
    public void removeMouseMotionListener(MouseMotionListener l) {
        theImage.removeMouseMotionListener(l);
    }

    /**
     * Adds the specified image mouse motion listener to this viewer. The listener is notified as the mouse
     * moves over pixels of the image. If listener <code>l</code>
     * is {@code null}, no exception is thrown and no action is performed.
     *
     * @param l the image mouse motion listener
     */
    public void addImageMouseMotionListener(ImageMouseMotionListener l) {
        theImage.addImageMouseMoveListener(l);
    }

    /**
     * Removes the specified image mouse motion listener so that it no longer receives
     * mouse motion events from the image component of this image viewer. This method
     * performs no function, nor does it throw an exception, if the listener specified
     * by the argument was not previously added to this component. If listener
     * <code>l</code> is {@code null}, no exception is thrown and no action is
     * performed.
     *
     * @param l the mouse motion listener
     */
    public void removeImageMouseMotionListener(ImageMouseMotionListener l) {
        theImage.removeImageMouseMoveListener(l);
    }

    /**
     * Adds the specified image mouse listener to this viewer. The listener is notified as mouse buttons are clicked
     * over pixels of the image. If listener <code>l</code>
     * is {@code null}, no exception is thrown and no action is performed.
     *
     * @param l the image mouse motion listener
     */
    public void addImageMouseClickListener(ImageMouseClickListener l) {
        theImage.addImageMouseClickListener(l);
    }

    /**
     * Removes the specified image mouse listener so that it no longer receives
     * mouse click events from the image component of this image viewer. This method
     * performs no function, nor does it throw an exception, if the listener specified
     * by the argument was not previously added to this component. If the listener
     * <code>l</code> is {@code null}, no exception is thrown and no action is
     * performed.
     *
     * @param l the mouse motion listener
     */
    public void removeImageMouseClickListener(ImageMouseClickListener l) {
        theImage.removeImageMouseClickListener(l);
    }

    /**
     * Adds a {@code PropertyChangeListener} to the listener list.
     * The same listener object may be added more than once, and will be
     * called as many times as it is added. If the listener is {@code null},
     * no exception is thrown and no action is taken.
     *
     * @param l the listener to be added
     */
    public void addPropertyChangeListener(PropertyChangeListener l) {
        propertyChangeSupport.addPropertyChangeListener(l);
    }

    /**
     * Remove a {@code PropertyChangeListener} from the listener list.
     * This removes a listener that was registered for all properties.
     * If the listener was added more than once, it will be notified
     * one less time after being removed. If the listener is {@code null},
     * or was never added, no exception is thrown and no action is taken.
     *
     * @param l the listener to remove
     */
    public void removePropertyChangeListener(PropertyChangeListener l) {
        propertyChangeSupport.removePropertyChangeListener(l);
    }

    /**
     * Adds a {@code PropertyChangeListener} for a specific property. The listener
     * will be invoked only when a call on firePropertyChange names that specific property.
     * The same listener object may be added more than once. For each property,
     * the listener will be invoked the number of times it was added for that property.
     * If the property name or the listener is null, no exception is thrown and no action is taken.
     *
     * @param name the name of the property to listen on
     * @param l    the listener to add
     */
    public void addPropertyChangeListener(String name, PropertyChangeListener l) {
        propertyChangeSupport.addPropertyChangeListener(name, l);
    }

    /**
     * Remove a {@code PropertyChangeListener} from the listener list.
     * This removes a PropertyChangeListener that was registered for all properties.
     * If the listener was added more than once,
     * it will be notified one less time after being removed. If the listener is {@code null},
     * or was never added, no exception is thrown and no action is taken.
     *
     * @param name the name of the property that was listened on
     * @param l    the listener to remove
     */
    public void removePropertyChangeListener(String name, PropertyChangeListener l) {
        propertyChangeSupport.removePropertyChangeListener(name, l);
    }

    /**
     * Returns the scroll pane of the image viewer.
     *
     * @return the scroll pane
     */
    JScrollPane getScrollPane() {
        return scroller;
    }

    Synchronizer getSynchronizer() {
        return synchronizer;
    }

    void setSynchronizer(Synchronizer newSync) {
        synchronizer = newSync;
    }

    /**
     * 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) {
        return theImage.pointToPixel(p, clipToImage);
    }
}
