/* Alloy Analyzer 4 -- Copyright (c) 2006-2009, Felix Chang
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
 * (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
 * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
 * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
 * OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

package edu.mit.csail.sdg.alloy4graph;

import static java.awt.event.InputEvent.BUTTON1_MASK;
import static java.awt.event.InputEvent.BUTTON3_MASK;
import static java.awt.event.InputEvent.CTRL_MASK;
import static java.awt.Color.WHITE;
import static java.awt.Color.BLACK;
import java.awt.Color;
import java.awt.Component;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseMotionAdapter;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JRadioButton;
import javax.swing.JTextField;
import javax.swing.JViewport;
import javax.swing.border.EmptyBorder;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import edu.mit.csail.sdg.alloy4.OurDialog;
import edu.mit.csail.sdg.alloy4.OurPDFWriter;
import edu.mit.csail.sdg.alloy4.OurPNGWriter;
import edu.mit.csail.sdg.alloy4.OurUtil;
import edu.mit.csail.sdg.alloy4.Util;

/**
 * This class displays the graph.
 * <p>
 * <b>Thread Safety:</b> Can be called only by the AWT event thread.
 */

public final strictfp class GraphViewer extends JPanel {

	/** This ensures the class can be serialized reliably. */
	private static final long	serialVersionUID	= 0;

	/** The graph that we are displaying. */
	private final Graph			graph;

	/** The current amount of zoom. */
	private double				scale				= 1d;

	/**
	 * The currently hovered GraphNode or GraphEdge or group, or null if there
	 * is none.
	 */
	private Object				highlight			= null;

	/**
	 * The currently selected GraphNode or GraphEdge or group, or null if there
	 * is none.
	 */
	private Object				selected			= null;

	/**
	 * The button that initialized the drag-and-drop; this value is undefined
	 * when we're not currently doing drag-and-drop.
	 */
	private int					dragButton			= 0;

	/** The right-click context menu associated with this JPanel. */
	public final JPopupMenu		pop					= new JPopupMenu();

	/** Locates the node or edge at the given (X,Y) location. */
	private Object alloyFind(int mouseX, int mouseY) {
		return graph.find(scale, mouseX, mouseY);
	}

	/**
	 * Returns the annotation for the node or edge at location x,y (or null if
	 * none)
	 */
	public Object alloyGetAnnotationAtXY(int mouseX, int mouseY) {
		Object obj = alloyFind(mouseX, mouseY);
		if (obj instanceof GraphNode)
			return ((GraphNode) obj).uuid;
		if (obj instanceof GraphEdge)
			return ((GraphEdge) obj).uuid;
		return null;
	}

	/**
	 * Returns the annotation for the currently selected node/edge (or null if
	 * none)
	 */
	public Object alloyGetSelectedAnnotation() {
		if (selected instanceof GraphNode)
			return ((GraphNode) selected).uuid;
		if (selected instanceof GraphEdge)
			return ((GraphEdge) selected).uuid;
		return null;
	}

	/**
	 * Returns the annotation for the currently highlighted node/edge (or null
	 * if none)
	 */
	public Object alloyGetHighlightedAnnotation() {
		if (highlight instanceof GraphNode)
			return ((GraphNode) highlight).uuid;
		if (highlight instanceof GraphEdge)
			return ((GraphEdge) highlight).uuid;
		return null;
	}

	/** Stores the mouse positions needed to calculate drag-and-drop. */
	private int oldMouseX = 0, oldMouseY = 0, oldX = 0, oldY = 0;

	/** Repaint this component. */
	public void alloyRepaint() {
		Container c = getParent();
		while (c != null) {
			if (c instanceof JViewport)
				break;
			else
				c = c.getParent();
		}
		setSize((int) (graph.getTotalWidth() * scale), (int) (graph.getTotalHeight() * scale));
		if (c != null) {
			c.invalidate();
			c.repaint();
			c.validate();
		} else {
			invalidate();
			repaint();
			validate();
		}
	}

	/** Construct a GraphViewer that displays the given graph. */
	public GraphViewer(final Graph graph) {
		OurUtil.make(this, BLACK, WHITE, new EmptyBorder(0, 0, 0, 0));
		setBorder(null);
		this.scale = graph.defaultScale;
		this.graph = graph;
		graph.layout();
		final JMenuItem zoomIn = new JMenuItem("Zoom In");
		final JMenuItem zoomOut = new JMenuItem("Zoom Out");
		final JMenuItem zoomToFit = new JMenuItem("Zoom to Fit");
		final JMenuItem print = new JMenuItem("Export to PNG or PDF");
		pop.add(zoomIn);
		pop.add(zoomOut);
		pop.add(zoomToFit);
		pop.add(print);
		ActionListener act = new ActionListener() {
			public void actionPerformed(ActionEvent e) {
				Container c = getParent();
				while (c != null) {
					if (c instanceof JViewport)
						break;
					else
						c = c.getParent();
				}
				if (e.getSource() == print)
					alloySaveAs();
				if (e.getSource() == zoomIn) {
					scale = scale * 1.33d;
					if (!(scale < 500d))
						scale = 500d;
				}
				if (e.getSource() == zoomOut) {
					scale = scale / 1.33d;
					if (!(scale > 0.1d))
						scale = 0.1d;
				}
				if (e.getSource() == zoomToFit) {
					if (c == null)
						return;
					int w = c.getWidth() - 15, h = c.getHeight() - 15; // 15
																		// gives
																		// a
																		// comfortable
																		// round-off
																		// margin
					if (w <= 0 || h <= 0)
						return;
					double scale1 = ((double) w) / graph.getTotalWidth(),
							scale2 = ((double) h) / graph.getTotalHeight();
					if (scale1 < scale2)
						scale = scale1;
					else
						scale = scale2;
				}
				alloyRepaint();
			}
		};
		zoomIn.addActionListener(act);
		zoomOut.addActionListener(act);
		zoomToFit.addActionListener(act);
		print.addActionListener(act);
		addMouseMotionListener(new MouseMotionAdapter() {
			@Override
			public void mouseMoved(MouseEvent ev) {
				if (pop.isVisible())
					return;
				Object obj = alloyFind(ev.getX(), ev.getY());
				if (highlight != obj) {
					highlight = obj;
					alloyRepaint();
				}
			}

			@Override
			public void mouseDragged(MouseEvent ev) {
				if (selected instanceof GraphNode && dragButton == 1) {
					int newX = (int) (oldX + (ev.getX() - oldMouseX) / scale);
					int newY = (int) (oldY + (ev.getY() - oldMouseY) / scale);
					GraphNode n = (GraphNode) selected;
					if (n.x() != newX || n.y() != newY) {
						n.tweak(newX, newY);
						alloyRepaint();
						scrollRectToVisible(
								new Rectangle((int) ((newX - graph.getLeft()) * scale) - n.getWidth() / 2 - 5,
										(int) ((newY - graph.getTop()) * scale) - n.getHeight() / 2 - 5,
										n.getWidth() + n.getReserved() + 10, n.getHeight() + 10));
					}
				}
			}
		});
		addMouseListener(new MouseAdapter() {
			@Override
			public void mouseReleased(MouseEvent ev) {
				Object obj = alloyFind(ev.getX(), ev.getY());
				graph.recalcBound(true);
				selected = null;
				highlight = obj;
				alloyRepaint();
			}

			@Override
			public void mousePressed(MouseEvent ev) {
				dragButton = 0;
				int mod = ev.getModifiers();
				if ((mod & BUTTON3_MASK) != 0) {
					selected = alloyFind(ev.getX(), ev.getY());
					highlight = null;
					alloyRepaint();
					pop.show(GraphViewer.this, ev.getX(), ev.getY());
				} else if ((mod & BUTTON1_MASK) != 0 && (mod & CTRL_MASK) != 0) {
					// This lets Ctrl+LeftClick bring up the popup menu, just
					// like RightClick,
					// since many Mac mouses do not have a right button.
					selected = alloyFind(ev.getX(), ev.getY());
					highlight = null;
					alloyRepaint();
					pop.show(GraphViewer.this, ev.getX(), ev.getY());
				} else if ((mod & BUTTON1_MASK) != 0) {
					dragButton = 1;
					selected = alloyFind(oldMouseX = ev.getX(), oldMouseY = ev.getY());
					highlight = null;
					alloyRepaint();
					if (selected instanceof GraphNode) {
						oldX = ((GraphNode) selected).x();
						oldY = ((GraphNode) selected).y();
					}
				}
			}

			@Override
			public void mouseExited(MouseEvent ev) {
				if (highlight != null) {
					highlight = null;
					alloyRepaint();
				}
			}
		});
	}

	/**
	 * This color is used as the background for a JTextField that contains bad
	 * data.
	 * <p>
	 * Note: we intentionally choose to make it an instance field rather than a
	 * static field, since we want to make sure we only instantiate it from the
	 * AWT Event Dispatching thread.
	 */
	private final Color				badColor	= new Color(255, 200, 200);

	/** This synchronized field stores the most recent DPI value. */
	private static volatile double	oldDPI		= 72;

	/** True if we are currently in the middle of a DocumentListener already. */
	private boolean					recursive	= false;

	/**
	 * This updates the three input boxes and the three accompanying text
	 * labels, then return the width in pixels.
	 */
	private int alloyRefresh(int who, double ratio, JTextField w1, JLabel w2, JTextField h1, JLabel h2, JTextField d1,
			JLabel d2, JLabel msg) {
		if (recursive)
			return 0;
		try {
			recursive = true;
			w1.setBackground(WHITE);
			h1.setBackground(WHITE);
			d1.setBackground(WHITE);
			boolean bad = false;
			double w;
			try {
				w = Double.parseDouble(w1.getText());
			} catch (NumberFormatException ex) {
				w = 0;
			}
			double h;
			try {
				h = Double.parseDouble(h1.getText());
			} catch (NumberFormatException ex) {
				h = 0;
			}
			double d;
			try {
				d = Double.parseDouble(d1.getText());
			} catch (NumberFormatException ex) {
				d = 0;
			}
			if (who == 1) {
				h = ((int) (w * 100 / ratio)) / 100D;
				h1.setText("" + h);
			} // Maintains aspect ratio
			if (who == 2) {
				w = ((int) (h * 100 * ratio)) / 100D;
				w1.setText("" + w);
			} // Maintains aspect ratio
			if (!(d >= 0.01) || !(d <= 10000)) {
				bad = true;
				d1.setBackground(badColor);
				msg.setText("DPI must be between 0.01 and 10000");
			}
			if (!(h >= 0.01) || !(h <= 10000)) {
				bad = true;
				h1.setBackground(badColor);
				msg.setText("Height must be between 0.01 and 10000");
				if (who == 1)
					h1.setText("");
			}
			if (!(w >= 0.01) || !(w <= 10000)) {
				bad = true;
				w1.setBackground(badColor);
				msg.setText("Width must be between 0.01 and 10000");
				if (who == 2)
					w1.setText("");
			}
			if (bad) {
				w2.setText(" inches");
				h2.setText(" inches");
				return 0;
			} else
				msg.setText(" ");
			w2.setText(" inches (" + (int) (w * d) + " pixels)");
			h2.setText(" inches (" + (int) (h * d) + " pixels)");
			return (int) (w * d);
		} finally {
			recursive = false;
		}
	}

	/**
	 * Export the current drawing as a PNG or PDF file by asking the user for
	 * the filename and the image resolution.
	 */
	public void alloySaveAs() {
		// Figure out the initial width, height, and DPI that we might want to
		// suggest to the user
		final double ratio = ((double) (graph.getTotalWidth())) / graph.getTotalHeight();
		double dpi, iw = 8.5D, ih = ((int) (iw * 100 / ratio)) / 100D; // First
																		// set
																		// the
																		// width
																		// to be
																		// 8.5inch
																		// and
																		// compute
																		// height
																		// accordingly
		if (ih > 11D) {
			ih = 11D;
			iw = ((int) (ih * 100 * ratio)) / 100D;
		} // If too tall, then set height=11inch, and compute width accordingly
		synchronized (GraphViewer.class) {
			dpi = oldDPI;
		}
		// Prepare the dialog box
		final JLabel msg = OurUtil.label(" ", Color.RED);
		final JLabel w = OurUtil.label("Width: " + ((int) (graph.getTotalWidth() * scale)) + " pixels");
		final JLabel h = OurUtil.label("Height: " + ((int) (graph.getTotalHeight() * scale)) + " pixels");
		final JTextField w1 = new JTextField("" + iw);
		final JLabel w0 = OurUtil.label("Width: "), w2 = OurUtil.label("");
		final JTextField h1 = new JTextField("" + ih);
		final JLabel h0 = OurUtil.label("Height: "), h2 = OurUtil.label("");
		final JTextField d1 = new JTextField("" + (int) dpi);
		final JLabel d0 = OurUtil.label("Resolution: "), d2 = OurUtil.label(" dots per inch");
		final JTextField dp1 = new JTextField("" + (int) dpi);
		final JLabel dp0 = OurUtil.label("Resolution: "), dp2 = OurUtil.label(" dots per inch");
		alloyRefresh(0, ratio, w1, w2, h1, h2, d1, d2, msg);
		Dimension dim = new Dimension(100, 20);
		w1.setMaximumSize(dim);
		w1.setPreferredSize(dim);
		w1.setEnabled(false);
		h1.setMaximumSize(dim);
		h1.setPreferredSize(dim);
		h1.setEnabled(false);
		d1.setMaximumSize(dim);
		d1.setPreferredSize(dim);
		d1.setEnabled(false);
		dp1.setMaximumSize(dim);
		dp1.setPreferredSize(dim);
		dp1.setEnabled(false);
		w1.getDocument().addDocumentListener(new DocumentListener() {
			public void changedUpdate(DocumentEvent e) {
				alloyRefresh(1, ratio, w1, w2, h1, h2, d1, d2, msg);
			}

			public void insertUpdate(DocumentEvent e) {
				changedUpdate(null);
			}

			public void removeUpdate(DocumentEvent e) {
				changedUpdate(null);
			}
		});
		h1.getDocument().addDocumentListener(new DocumentListener() {
			public void changedUpdate(DocumentEvent e) {
				alloyRefresh(2, ratio, w1, w2, h1, h2, d1, d2, msg);
			}

			public void insertUpdate(DocumentEvent e) {
				changedUpdate(null);
			}

			public void removeUpdate(DocumentEvent e) {
				changedUpdate(null);
			}
		});
		d1.getDocument().addDocumentListener(new DocumentListener() {
			public void changedUpdate(DocumentEvent e) {
				alloyRefresh(3, ratio, w1, w2, h1, h2, d1, d2, msg);
			}

			public void insertUpdate(DocumentEvent e) {
				changedUpdate(null);
			}

			public void removeUpdate(DocumentEvent e) {
				changedUpdate(null);
			}
		});
		final JRadioButton b1 = new JRadioButton("As a PNG with the window's current magnification:", true);
		final JRadioButton b2 = new JRadioButton("As a PNG with a specific width, height, and resolution:", false);
		final JRadioButton b3 = new JRadioButton("As a PDF with the given resolution:", false);
		b1.addActionListener(new ActionListener() {
			public void actionPerformed(ActionEvent e) {
				b2.setSelected(false);
				b3.setSelected(false);
				if (!b1.isSelected())
					b1.setSelected(true);
				w1.setEnabled(false);
				h1.setEnabled(false);
				d1.setEnabled(false);
				dp1.setEnabled(false);
				msg.setText(" ");
				w1.setBackground(WHITE);
				h1.setBackground(WHITE);
				d1.setBackground(WHITE);
			}
		});
		b2.addActionListener(new ActionListener() {
			public void actionPerformed(ActionEvent e) {
				b1.setSelected(false);
				b3.setSelected(false);
				if (!b2.isSelected())
					b2.setSelected(true);
				w1.setEnabled(true);
				h1.setEnabled(true);
				d1.setEnabled(true);
				dp1.setEnabled(false);
				msg.setText(" ");
				alloyRefresh(1, ratio, w1, w2, h1, h2, d1, d2, msg);
			}
		});
		b3.addActionListener(new ActionListener() {
			public void actionPerformed(ActionEvent e) {
				b1.setSelected(false);
				b2.setSelected(false);
				if (!b3.isSelected())
					b3.setSelected(true);
				w1.setEnabled(false);
				h1.setEnabled(false);
				d1.setEnabled(false);
				dp1.setEnabled(true);
				msg.setText(" ");
				w1.setBackground(WHITE);
				h1.setBackground(WHITE);
				d1.setBackground(WHITE);
			}
		});
		// Ask whether the user wants to change the width, height, and DPI
		double myScale;
		while (true) {
			if (!OurDialog.getInput("Export as PNG or PDF", new Object[] {
					b1, OurUtil.makeH(20, w, null), OurUtil.makeH(20, h, null), " ", b2,
					OurUtil.makeH(20, w0, w1, w2, null), OurUtil.makeH(20, h0, h1, h2, null),
					OurUtil.makeH(20, d0, d1, d2, null), OurUtil.makeH(20, msg, null), b3,
					OurUtil.makeH(20, dp0, dp1, dp2, null)
			}))
				return;
			// Let's validate the values
			if (b2.isSelected()) {
				int widthInPixel = alloyRefresh(3, ratio, w1, w2, h1, h2, d1, d2, msg);
				String err = msg.getText().trim();
				if (err.length() > 0)
					continue;
				dpi = Double.parseDouble(d1.getText());
				myScale = ((double) widthInPixel) / graph.getTotalWidth();
				int heightInPixel = (int) (graph.getTotalHeight() * myScale);
				if (widthInPixel > 4000 || heightInPixel > 4000)
					if (!OurDialog.yesno("The image dimension (" + widthInPixel + "x" + heightInPixel
							+ ") is very large. Are you sure?"))
						continue;
			} else if (b3.isSelected()) {
				try {
					dpi = Double.parseDouble(dp1.getText());
				} catch (NumberFormatException ex) {
					dpi = (-1);
				}
				if (dpi < 50 || dpi > 3000) {
					OurDialog.alert("The DPI must be between 50 and 3000.");
					continue;
				}
				myScale = 0; // This field is unused for PDF generation
			} else {
				dpi = 72;
				myScale = scale;
			}
			break;
		}
		// Ask the user for a filename
		File filename;
		if (b3.isSelected())
			filename = OurDialog.askFile(false, null, ".pdf", "PDF file");
		else
			filename = OurDialog.askFile(false, null, ".png", "PNG file");
		if (filename == null)
			return;
		if (filename.exists() && !OurDialog.askOverwrite(filename.getAbsolutePath()))
			return;
		// Attempt to write the PNG or PDF file
		try {
			System.gc(); // Try to avoid possible premature out-of-memory
							// exceptions
			if (b3.isSelected())
				alloySaveAsPDF(filename.getAbsolutePath(), (int) dpi);
			else
				alloySaveAsPNG(filename.getAbsolutePath(), myScale, dpi, dpi);
			synchronized (GraphViewer.class) {
				oldDPI = dpi;
			}
			Util.setCurrentDirectory(filename.getParentFile());
		} catch (Throwable ex) {
			OurDialog.alert("An error has occured in writing the output file:\n" + ex);
		}
	}

	/**
	 * Export the current drawing as a PDF file with the given image resolution.
	 */
	public void alloySaveAsPDF(String filename, int dpi) throws IOException {
		try {
			double xwidth = dpi * 8L + (dpi / 2L); // Width is up to 8.5 inch
			double xheight = dpi * 11L; // Height is up to 11 inch
			double scale1 = (xwidth - dpi) / graph.getTotalWidth(); // We leave
																	// 0.5 inch
																	// on the
																	// left and
																	// right
			double scale2 = (xheight - dpi) / graph.getTotalHeight(); // We
																		// leave
																		// 0.5
																		// inch
																		// on
																		// the
																		// left
																		// and
																		// right
			if (scale1 < scale2)
				scale2 = scale1; // Choose the scale such that the image does
									// not exceed the page in either direction
			OurPDFWriter x = new OurPDFWriter(filename, dpi, scale2);
			graph.draw(new Artist(x), scale2, null, false);
			x.close();
		} catch (Throwable ex) {
			if (ex instanceof IOException)
				throw (IOException) ex;
			throw new IOException("Failure writing the PDF file to " + filename + " (" + ex + ")");
		}
	}

	/**
	 * Export the current drawing as a PNG file with the given file name and
	 * image resolution.
	 */
	public void alloySaveAsPNG(String filename, double scale, double dpiX, double dpiY) throws IOException {
		try {
			int width = (int) (graph.getTotalWidth() * scale);
			if (width < 10)
				width = 10;
			int height = (int) (graph.getTotalHeight() * scale);
			if (height < 10)
				height = 10;
			BufferedImage bf = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
			Graphics2D gr = (Graphics2D) (bf.getGraphics());
			gr.setColor(WHITE);
			gr.fillRect(0, 0, width, height);
			gr.setColor(BLACK);
			gr.scale(scale, scale);
			gr.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
			graph.draw(new Artist(gr), scale, null, false);
			OurPNGWriter.writePNG(bf, filename, dpiX, dpiY);
		} catch (Throwable ex) {
			if (ex instanceof IOException)
				throw (IOException) ex;
			throw new IOException("Failure writing the PNG file to " + filename + " (" + ex + ")");
		}
	}

	/** Show the popup menu at location (x,y) */
	public void alloyPopup(Component c, int x, int y) {
		pop.show(c, x, y);
	}

	/** Returns a DOT representation of the current graph. */
	@Override
	public String toString() {
		return graph.toString();
	}

	/** Returns the preferred size of this component. */
	@Override
	public Dimension getPreferredSize() {
		return new Dimension((int) (graph.getTotalWidth() * scale), (int) (graph.getTotalHeight() * scale));
	}

	/** This method is called by Swing to draw this component. */
	@Override
	public void paintComponent(final Graphics gr) {
		super.paintComponent(gr);
		Graphics2D g2 = (Graphics2D) gr;
		AffineTransform oldAF = (AffineTransform) (g2.getTransform().clone());
		g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
		g2.scale(scale, scale);
		Object sel = (selected != null ? selected : highlight);
		GraphNode c = null;
		if (sel instanceof GraphNode && ((GraphNode) sel).shape() == null) {
			c = (GraphNode) sel;
			sel = c.ins.get(0);
		}
		graph.draw(new Artist(g2), scale, sel, true);
		if (c != null) {
			gr.setColor(((GraphEdge) sel).color());
			gr.fillArc(c.x() - 5 - graph.getLeft(), c.y() - 5 - graph.getTop(), 10, 10, 0, 360);
		}
		g2.setTransform(oldAF);
	}
}