package hu.kazocsaba.imageviewer;

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.LayoutManager;
import java.awt.Point;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import javax.swing.JComponent;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JViewport;

/**
 * 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.
 * @see StatusBar
 * @see Overlay
 * @author Kazó Csaba
 */
public final class ImageViewer {
	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(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());
		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);
					}
				}
			});

		}
	}
	/**
	 * 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);
	}
}
