/**
 * This software is released as part of the Pumpernickel project.
 * 
 * All com.pump resources in the Pumpernickel project are distributed under the
 * MIT License:
 * https://raw.githubusercontent.com/mickleness/pumpernickel/master/License.txt
 * 
 * More information about the Pumpernickel project is available here:
 * https://mickleness.github.io/pumpernickel/
 */
package com.pump.swing;

import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Component;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.MouseInfo;
import java.awt.Point;
import java.awt.PointerInfo;
import java.awt.TexturePaint;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.ContainerEvent;
import java.awt.event.ContainerListener;
import java.awt.event.MouseEvent;
import java.awt.image.BufferedImage;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;

import javax.swing.JComponent;
import javax.swing.JTextArea;
import javax.swing.SwingUtilities;
import javax.swing.border.EmptyBorder;
import javax.swing.event.MouseInputAdapter;

import com.pump.plaf.PlafPaintUtils;

/**
 * A panel that offers a magnified view of another <code>Component</code> based
 * on the mouse location.
 */
public class MagnificationPanel extends JComponent {
	private static final long serialVersionUID = 1L;

	public static final String PIXELATED_KEY = MagnificationPanel.class
			.getName() + ".pixelated";

	MouseInputAdapter mouseListener = new MouseInputAdapter() {

		@Override
		public void mouseClicked(MouseEvent e) {
			refresh();
		}

		@Override
		public void mouseDragged(MouseEvent e) {
			refresh();
		}

		@Override
		public void mouseEntered(MouseEvent e) {
			refresh();
		}

		@Override
		public void mouseExited(MouseEvent e) {
			refresh();
		}

		@Override
		public void mouseMoved(MouseEvent e) {
			refresh();
		}

		@Override
		public void mousePressed(MouseEvent e) {
			refresh();
		}

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

	int pixelSize;
	Component zoomedComponent;
	BufferedImage scratchImage;
	JTextArea textArea = new JTextArea();

	TexturePaint checkers;

	/**
	 * 
	 * @param zoomedComponent
	 *            the component to zoom in on.
	 * @param visiblePixelWidth
	 *            the number of pixels to show horizontally (this is used to
	 *            compute the preferred size)
	 * @param visiblePixelHeight
	 *            the number of pixels to show vertically (this is used to
	 *            compute the preferred size)
	 * @param pixelSize
	 *            the magnification of each pixel (4, 8, etc.)
	 */
	public MagnificationPanel(Component zoomedComponent, int visiblePixelWidth,
			int visiblePixelHeight, int pixelSize) {
		this.pixelSize = pixelSize;
		this.zoomedComponent = zoomedComponent;
		addMouseListeners(zoomedComponent);
		addMouseListeners(this);
		setPreferredSize(new Dimension(visiblePixelWidth * pixelSize,
				visiblePixelHeight * pixelSize));

		addPropertyChangeListener(PIXELATED_KEY, new PropertyChangeListener() {
			public void propertyChange(PropertyChangeEvent evt) {
				repaint();
			}
		});

		textArea.setEditable(false);
		textArea.setFocusable(false);
		textArea.setWrapStyleWord(true);
		textArea.setLineWrap(true);
		textArea.setFont(new Font("Default", 0, 16));

		addComponentListener(new ComponentAdapter() {
			@Override
			public void componentResized(ComponentEvent e) {
				int componentWidth = getWidth();
				int componentHeight = getHeight();
				int imageWidth = (int) Math.ceil(((double) componentWidth)
						/ ((double) MagnificationPanel.this.pixelSize));
				int imageHeight = (int) Math.ceil(((double) componentHeight)
						/ ((double) MagnificationPanel.this.pixelSize));
				int oldImageWidth = scratchImage == null ? 0 : scratchImage
						.getWidth();
				int oldImageHeight = scratchImage == null ? 0 : scratchImage
						.getHeight();
				if (imageWidth > oldImageWidth || imageHeight > oldImageHeight) {
					scratchImage = new BufferedImage(imageWidth, imageHeight,
							BufferedImage.TYPE_INT_ARGB);
					refresh();
				}
			}
		});

		textArea.setBorder(new EmptyBorder(20, 20, 20, 20));
		setLayout(new GridBagLayout());
		GridBagConstraints c = new GridBagConstraints();
		c.gridx = 0;
		c.gridy = 0;
		c.fill = GridBagConstraints.BOTH;
		add(textArea, c);

		checkers = PlafPaintUtils.getCheckerBoard(Math.max(4, pixelSize / 2),
				Color.white, new Color(238, 238, 238));
	}

	private ContainerListener containerListener = new ContainerListener() {

		public void componentAdded(ContainerEvent e) {
			addMouseListeners(e.getComponent());
		}

		public void componentRemoved(ContainerEvent e) {
			removeMouseListeners(e.getComponent());
		}
	};

	/**
	 * Add MouseListeners to a component and its children. If children have a
	 * MouseListener for mouse-moved events, then this component won't otherwise
	 * receive mouse-moved events (and therefore it won't repaint correctly).
	 * <p>
	 * A common instance of this problem is simply the presence of tooltips
	 * (which require a MouseListener).
	 */
	private void addMouseListeners(Component c) {
		c.addMouseMotionListener(mouseListener);
		c.addMouseListener(mouseListener);
		if (c instanceof Container) {
			Container c2 = (Container) c;
			c2.addContainerListener(containerListener);
			for (Component child : c2.getComponents()) {
				addMouseListeners(child);
			}
		}
	}

	private void removeMouseListeners(Component c) {
		c.removeMouseMotionListener(mouseListener);
		c.removeMouseListener(mouseListener);
		if (c instanceof Container) {
			Container c2 = (Container) c;
			c2.removeContainerListener(containerListener);
			for (Component child : c2.getComponents()) {
				addMouseListeners(child);
			}
		}
	}

	/**
	 * Set instructional text that is displayed when no image data is in use.
	 */
	public void setInstruction(String text) {
		if (text == null)
			text = "";
		textArea.setText(text);
	}

	/**
	 * Return true if the zoomed view is meant to be pixelated.
	 */
	public boolean isPixelated() {
		Boolean b = (Boolean) getClientProperty(PIXELATED_KEY);
		if (b == null)
			return true;
		return b;
	}

	/**
	 * Define whether the zoomed view is meant to be pixelated. By default this
	 * attribute is true, but if false then the component is painted through an
	 * AffineTransform to achieve the zoom. If the underlying component uses
	 * vector graphics, then this panel will demonstrate that. (If the
	 * underlying component uses pixelated images, then this property will make
	 * no difference.)
	 */
	public void setPixelated(boolean b) {
		putClientProperty(PIXELATED_KEY, b);
	}

	protected Point getMouseLoc() {
		// I observed a null PointerInfo after unplugging a second monitor
		PointerInfo pointerInfo = MouseInfo.getPointerInfo();
		if (pointerInfo == null)
			return null;
		Point p = pointerInfo.getLocation();
		SwingUtilities.convertPointFromScreen(p, zoomedComponent);
		return p;
	}

	/** Regenerate the zoomed image data and repaint this panel. */
	public void refresh() {
		if (scratchImage == null)
			return;
		Point mouseLoc = getMouseLoc();
		if (mouseLoc == null)
			return;

		Graphics2D g = scratchImage.createGraphics();
		g.setComposite(AlphaComposite.Clear);
		g.fillRect(0, 0, scratchImage.getWidth(), scratchImage.getHeight());
		g.setComposite(AlphaComposite.SrcOver);
		g.translate(-mouseLoc.x + scratchImage.getWidth() / 2, -mouseLoc.y
				+ scratchImage.getHeight() / 2);
		zoomedComponent.paint(g);

		boolean inside = mouseLoc.x >= 0 && mouseLoc.y >= 0
				&& mouseLoc.x < zoomedComponent.getWidth()
				&& mouseLoc.y < zoomedComponent.getHeight();
		textArea.setVisible(!inside);

		repaint();
	}

	@Override
	protected void paintComponent(Graphics g0) {
		super.paintComponent(g0);

		if (!textArea.isVisible()) {
			Graphics2D g = (Graphics2D) g0.create();
			g.setPaint(checkers);
			g.fillRect(0, 0, getWidth(), getHeight());
			if (isPixelated()) {
				if (scratchImage != null)
					g.drawImage(scratchImage, 0, 0, scratchImage.getWidth()
							* pixelSize, scratchImage.getHeight() * pixelSize,
							0, 0, scratchImage.getWidth(),
							scratchImage.getHeight(), null);
			} else {
				g.scale(pixelSize, pixelSize);
				Point mouseLoc = getMouseLoc();
				if (mouseLoc == null)
					return;

				g.translate(-mouseLoc.x + scratchImage.getWidth() / 2,
						-mouseLoc.y + scratchImage.getHeight() / 2);

				boolean resetToDoubleBuffered = false;
				if (zoomedComponent instanceof JComponent) {
					resetToDoubleBuffered = ((JComponent) zoomedComponent)
							.isDoubleBuffered();
					((JComponent) zoomedComponent).setDoubleBuffered(false);
				}
				zoomedComponent.paint(g);
				if (resetToDoubleBuffered) {
					((JComponent) zoomedComponent).setDoubleBuffered(true);
				}
			}
			g.dispose();
		} else {
			g0.setColor(Color.white);
			g0.fillRect(0, 0, getWidth(), getHeight());
		}
	}
}