package net.dowhatimean.jme;

import java.awt.BorderLayout;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Random;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingUtilities;
import javax.swing.WindowConstants;
import javax.swing.event.CaretEvent;
import javax.swing.event.CaretListener;
import javax.swing.text.BadLocationException;

import com.hp.hpl.jena.n3.N3Exception;
import com.hp.hpl.jena.rdf.listeners.StatementListener;
import com.hp.hpl.jena.rdf.model.Model;
import com.hp.hpl.jena.rdf.model.ModelFactory;
import com.hp.hpl.jena.rdf.model.Statement;

/**
 * <p>A Swing-based GUI window that provides a simple Turtle-based
 * editor and inspector for Jena models. Useful for debugging GUI
 * and web applications. To open an editor window, pass the model
 * instance to the static {@link ModelEditor#open(Model)} method.</p>
 * 
 * <p>The editor has basic reporting of Turtle syntax errors.
 * It also updates the namespace prefixes of the model.
 * Several windows for different models may be open at the same
 * time. Concurrent changes to the model are reported.</p>
 * 
 * <p>The class has a {@link #main} method for demonstration purposes.
 * It loads one or more RDF files into Jena models and displays an editor
 * for each.</p>
 * 
 * @version $Id$
 * @author Richard Cyganiak (richard@cyganiak.de)
 */
public class ModelEditor {
	private final static int WINDOW_MIN_WIDTH = 400;
	private final static int WINDOW_MIN_HEIGHT = 200;
	
	// We randomize the position of new windows
	private final static Random random = new Random(19790715);

	/**
	 * Opens a new editor window and binds it to the given model.
	 * @param sourceModel A Jena model
	 * @return A reference to the new editor window
	 */
	public static ModelEditor open(Model sourceModel) {
		return new ModelEditor(sourceModel, "Jena Model Editor");
	}
	
	/**
	 * Opens a new editor window and binds it to the given model.
	 * A custom title is useful to distinguish multiple editor
	 * windows for different models.
	 * @param sourceModel A Jena model
	 * @param title A custom title for the editor window
	 * @return A reference to the new editor window
	 */
	public static ModelEditor open(Model sourceModel, String title) {
		return new ModelEditor(sourceModel,
				title + " - Jena Model Editor");
	}

	/**
	 * Main method for demonstration purposes. Takes a number of
	 * filename or URL arguments. Reads them as RDF/XML or Turtle
	 * (if ends with ".n3" or ".ttl"). Displays an editor for
	 * each. If the same filename appears twice, then both editors
	 * will use the same model.
	 * @param args
	 */
	public static void main(String[] args) {
		if (args.length == 0) {
			System.out.println("Please specify one or more "
					+ "RDF filenames or URLs.");
			return;
		}
		Map filenamesToModels = new HashMap();
		for (int i = 0; i < args.length; i++) {
			if (!filenamesToModels.containsKey(args[i])) {
				String url = args[i];
				if (url.indexOf(":") == -1) {
					url = "file:" + url;
				}
				Model m = ModelFactory.createDefaultModel();
				String lang =
					(url.endsWith(".n3") || url.endsWith(".ttl"))
					? "N3" : "RDF/XML";
				m.read(url, lang);
				filenamesToModels.put(args[i], m);
			}
			Model m = (Model) filenamesToModels.get(args[i]);
			open(m, args[i]);
		}
	}
	
	/**
	 * The model bound to the editor.
	 */
	Model boundModel;
	
	JFrame window;
	JTextArea turtleTextArea;
	JLabel cursorPositionLabel;
	JPanel buttons;
	StatementListener listener;
	
	/**
	 * true when the model has been changed by another part of the
	 * system
	 */
	boolean outOfSync;
	
	/**
	 * true while we've locked the model. Assumption: All changes to
	 * the model reported by the listener during this period have
	 * been caused by us.
	 */
	boolean weAreEditingTheModel = false;

	/**
	 * Creates and displays new ModelEditor.
	 * @param sourceModel The model bound to the editor
	 * @param title The full title of the editor window
	 */
	private ModelEditor(Model sourceModel, String title) {
		this.window = new JFrame(title) {
			// Remove listener from bound model when window is closed
			public void dispose() {
				ModelEditor.this.boundModel.unregister(
						ModelEditor.this.listener);
				super.dispose();
			}
		};
		this.boundModel = sourceModel;
		init();
		fetchTurtleFromModel();
		this.window.setVisible(true);
	}
	
	/**
	 * Parses the contents of the Turtle text area and adds
	 * all statements and namespace prefixes to the model.
	 * Displays an error message if the contents are invalid.
	 */
	protected synchronized void addTurtleToModel() {
		lockModel();
		Model contents = getContentsAsModel();
		if (contents == null) {	// Syntax error?
			return;
		}
		
		// Namespace prefixes
		Iterator it = contents.getNsPrefixMap().keySet().iterator();
		while (it.hasNext()) {
			String prefix = (String) it.next();
			String uri = contents.getNsPrefixURI(prefix);
			if (!uri.equals(this.boundModel.getNsPrefixURI(prefix))) {
				this.boundModel.setNsPrefix(prefix, uri);
			}
		}
		
		this.boundModel.add(contents);
		this.turtleTextArea.requestFocusInWindow();
		unlockModel();
	}
	
	/**
	 * Parses the contents of the Turtle text area and removes
	 * all statements and namespace prefixes from the model.
	 * Displays an error message if the contents are invalid.
	 */
	protected synchronized void removeTurtleFromModel() {
		lockModel();
		Model contents = getContentsAsModel();
		if (contents == null) {	// syntax error?
			return;
		}

		// Namespace prefixes
		Iterator it = contents.getNsPrefixMap().keySet().iterator();
		while (it.hasNext()) {
			String prefix = (String) it.next();
			this.boundModel.removeNsPrefix(prefix);
		}
		
		this.boundModel.remove(contents);
		this.turtleTextArea.requestFocusInWindow();
		unlockModel();
	}
	
	/**
	 * Parses the contents of the Turtle text area and replaces
	 * all statements and namespace prefixes in the model with
	 * those from the text area.
	 * Displays an error message if the contents are invalid.
	 */
	protected synchronized void replaceModelWithTurtle() {
		lockModel();
		Model contents = getContentsAsModel();
		if (contents == null) {	// syntax error?
			return;
		}
		
		// Namespace prefixes
		Iterator it = this.boundModel.getNsPrefixMap().keySet().iterator();
		while (it.hasNext()) {
			String prefix = (String) it.next();
			this.boundModel.removeNsPrefix(prefix);
		}
		this.boundModel.setNsPrefixes(contents);
		
		this.boundModel.removeAll();
		this.boundModel.add(contents);
		
		this.turtleTextArea.requestFocusInWindow();
		unlockModel();

		// Model contains the contents of the text area so we're synced
		this.outOfSync = false;
	}

	/**
	 * Replaces the contents of the text area with a Turtle
	 * serialization of the model. 
	 */
	protected void fetchTurtleFromModel() {
		
		// Serialize model and update text area
		StringWriter writer = new StringWriter();
		this.boundModel.write(writer, "N3");
		setContents(writer.toString());
		
		this.turtleTextArea.requestFocusInWindow();
		moveCursorTo(1, 1);
		
		// Text area now contains model so we're synced
		this.outOfSync = false;
	}
	
	/**
	 * @return The current contents of the Turtle text area
	 */
	protected String getContentsAsTurtle() {
		return this.turtleTextArea.getText();
	}

	/**
	 * Parses the current contents of the Turtle text area
	 * and returns them as a Jena model. Will show an error
	 * message to the user and return null if the contents
	 * are not syntactically valid.
	 * @return The parsed contents of the Turtle text area
	 */
	protected Model getContentsAsModel() {
		Model result = ModelFactory.createDefaultModel();
		StringReader reader = new StringReader(getContentsAsTurtle());
		try {
			result.read(reader, "", "N3");
			return result;
		} catch (N3Exception ex) {	// syntax error?
			// Split error message into line, column, parser message
			Pattern p = Pattern.compile(".*\\[([0-9]+):([0-9]+)\\] (.*)");
			Matcher m = p.matcher(ex.getMessage());
			if (!m.matches()) {
				throw new RuntimeException(
						"Unexpected error format: " + ex.getMessage());
			}

			// Show error message
			JOptionPane.showMessageDialog(
					this.window, m.group(3), "Parse Error", 
					JOptionPane.ERROR_MESSAGE);

			// Then highlight the offending character
			int line = Integer.parseInt(m.group(1));
			int column = Integer.parseInt(m.group(2));
			highlightCharacter(line, column);
			
			// null return means there was a syntax error
			return null;
		}
	}

	/**
	 * Sets the contents of the Turtle text area.
	 * @param text The new contents
	 */
	protected void setContents(String text) {
		this.turtleTextArea.setText(text);
	}

	/**
	 * @return The line number of the current cursor position
	 */
	protected int getCurrentCursorLine() {
		try {
			return this.turtleTextArea.getLineOfOffset(
					this.turtleTextArea.getCaretPosition()) + 1;
		} catch (BadLocationException e) {
			throw new RuntimeException(e);
		}
	}
	
	/**
	 * @return The column number of the current cursor position
	 */
	protected int getCurrentCursorColumn() {
		try {
			int line = this.turtleTextArea.getLineOfOffset(
					this.turtleTextArea.getCaretPosition());
			return this.turtleTextArea.getCaretPosition() -
					this.turtleTextArea.getLineStartOffset(line) + 1;
		} catch (BadLocationException e) {
			throw new RuntimeException(e);
		}
	}
	
	/**
	 * Selects the character at the given position to indicate the
	 * location of a syntax error.
	 * @param line A cursor position
	 * @param column A cursor position
	 */
	protected void highlightCharacter(int line, int column) {
		try {
			this.turtleTextArea.requestFocusInWindow();
			int offset = this.turtleTextArea.getLineStartOffset(line - 1) +
					column - 1;
			this.turtleTextArea.setCaretPosition(offset + 1);
			this.turtleTextArea.moveCaretPosition(offset);
		} catch (BadLocationException e) {
			throw new RuntimeException(e);
		}
	}
	
	/**
	 * Moves the cursor to a given position.
	 * @param line A cursor position
	 * @param column A cursor position
	 */
	protected void moveCursorTo(int line, int column) {
		try {
			this.turtleTextArea.requestFocusInWindow();
			int offset = this.turtleTextArea.getLineStartOffset(line - 1) +
					column - 1;
			this.turtleTextArea.setCaretPosition(offset);
			notifyCursorPositionChanged();
		} catch (BadLocationException e) {
			throw new RuntimeException(e);
		}
	}
	
	/**
	 * Updates the cursor position label.
	 */
	protected void notifyCursorPositionChanged() {
		this.cursorPositionLabel.setText(
				getCurrentCursorLine() + " : " + getCurrentCursorColumn());
	}

	/**
	 * Shows a message about a concurrent modification of the
	 * model to the user, except if we have locked the model
	 * (then we assume the change has been by ourselves), or if
	 * model and text area are already out of sync (then the
	 * user has already seen the message earlier).
	 */
	protected synchronized void notifyConcurrentChange() {
		if (this.weAreEditingTheModel || this.outOfSync) {
			return;
		}
		this.outOfSync = true;
		
		// This is called by the model's worker thread and
		// the error dialog blocks the thread until the user
		// clicks OK, so we better show the dialog in another
		// thread
		SwingUtilities.invokeLater(new Runnable() {
			public void run() {
				JOptionPane.showMessageDialog(ModelEditor.this.window,
						"The model has been changed by another part of the system.\n" +
						"Re-fetch the contents of the model to get the latest changes.",
						"Concurrent Change", JOptionPane.WARNING_MESSAGE);
			}
		});
	}
	
	private void lockModel() {
		this.weAreEditingTheModel = true;
		this.boundModel.enterCriticalSection(false);
	}
	
	private void unlockModel() {
		this.weAreEditingTheModel = false;
		this.boundModel.leaveCriticalSection();
	}

	/**
	 * Sets up the window.
	 */
	private void init() {
		// Add listener to the model
		this.listener = new StatementListener() {
			public void addedStatement(Statement s) {
				notifyConcurrentChange();
			}
			public void removedStatement(Statement s) {
				notifyConcurrentChange();
			}
		};
		this.boundModel.register(this.listener);
		
		// set up Turtle text area
		this.turtleTextArea = new JTextArea();
		this.turtleTextArea.setFont(new Font("Monospaced", Font.PLAIN, 12));
		this.turtleTextArea.addCaretListener(new CaretListener() {
			public void caretUpdate(CaretEvent e) {
				// update cursor position label
				notifyCursorPositionChanged();
			}
		});
		// make text area scrollable
		JScrollPane scroller = new JScrollPane(this.turtleTextArea,
				ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS,
				ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED);
		scroller.setPreferredSize(new Dimension(400, 300));
		
		// set up the right-side panel with the buttons
		this.buttons = new JPanel();
		this.buttons.setLayout(
				new BoxLayout(this.buttons, BoxLayout.PAGE_AXIS));
		this.buttons.setBorder(
				BorderFactory.createEmptyBorder(0, 12, 0, 16));

		// buttons
		makeButton("Add this to the model", new ActionListener() {
			public void actionPerformed(ActionEvent e) {
				addTurtleToModel();
			}
		});
		makeButton("Remove this from the model", new ActionListener() {
			public void actionPerformed(ActionEvent e) {
				removeTurtleFromModel();
			}
		});
		makeButton("Fetch contents of the model", new ActionListener() {
			public void actionPerformed(ActionEvent e) {
				fetchTurtleFromModel();
			}
		});
		makeButton("Replace contents of the model", new ActionListener() {
			public void actionPerformed(ActionEvent e) {
				replaceModelWithTurtle();
			}
		});

		// cursor position label
		this.cursorPositionLabel = new JLabel("Status bar");
		this.cursorPositionLabel.setFont(
				new Font("Dialog", Font.PLAIN, 10));
		this.buttons.add(Box.createVerticalGlue());
		this.buttons.add(this.cursorPositionLabel);

		// put pieces together and add some borders
		Container contentPane = this.window.getContentPane();
		contentPane.add(scroller, BorderLayout.CENTER);
		contentPane.add(this.buttons, BorderLayout.EAST);
		contentPane.add(Box.createRigidArea(
				new Dimension(16, 0)), BorderLayout.WEST);
		contentPane.add(Box.createRigidArea(
				new Dimension(0, 12)), BorderLayout.NORTH);
		contentPane.add(Box.createRigidArea(
				new Dimension(0, 12)), BorderLayout.SOUTH);

		// Hack to prevent resizing the window below a minimum size
		this.window.addComponentListener(new ComponentAdapter() {
			public void componentResized(ComponentEvent e) {
				int width = ModelEditor.this.window.getWidth();
				int height = ModelEditor.this.window.getHeight();
				boolean doResize = false;
				if (width < WINDOW_MIN_WIDTH) {
					width = WINDOW_MIN_WIDTH;
					doResize = true;
				}
				if (height < WINDOW_MIN_HEIGHT) {
					height = WINDOW_MIN_HEIGHT;
					doResize = true;
				}
				if (!doResize) {
					return;
				}
				ModelEditor.this.window.setSize(width, height);
			}
		});
		
		// DISPOSE_ON_CLOSE so we can have multiple windows running
		this.window.setDefaultCloseOperation(
				WindowConstants.DISPOSE_ON_CLOSE);
		this.window.pack();

		// Randomly set initial window positions (otherwise they would
		// all sit overlapping in the top left corner)
		Dimension screen = Toolkit.getDefaultToolkit().getScreenSize();
		this.window.setLocation(
				random.nextInt(screen.width - this.window.getWidth()),
				random.nextInt(screen.height - this.window.getHeight()));
	}

	/**
	 * Helper function that creates a button
	 * @param label The button's label
	 * @param action
	 */
	private void makeButton(String label,
			ActionListener action) {
		JButton newButton = new JButton(label) {
			// Try to cover the whole width of its parent container.
			// We use this because we want all buttons to have the
			// same width.
			public Dimension getMaximumSize() {
				Dimension dim = super.getMaximumSize();
				return new Dimension(
						Short.MAX_VALUE,
						(int) dim.getHeight());
			}
		};
		newButton.addActionListener(action);
		this.buttons.add(newButton);
		// 6 pixels spacing between this and next button
		this.buttons.add(Box.createRigidArea(new Dimension(0, 6)));
	}
}