/*
 * Copyright 2008 Ayman Al-Sairafi ayman.alsairafi@gmail.com
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); 
 * you may not use this file except in compliance with the License. 
 * You may obtain a copy of the License 
 *       at http://www.apache.org/licenses/LICENSE-2.0 
 * Unless required by applicable law or agreed to in writing, software 
 * distributed under the License is distributed on an "AS IS" BASIS, 
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
 * See the License for the specific language governing permissions and 
 * limitations under the License.  
 */
package jsyntaxpane;

import java.awt.Color;
import java.awt.Container;
import java.util.logging.Level;
import java.awt.Font;
import java.awt.GraphicsEnvironment;
import java.awt.Toolkit;
import java.awt.event.KeyEvent;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import javax.swing.Action;
import javax.swing.ActionMap;
import javax.swing.BorderFactory;
import javax.swing.ImageIcon;
import javax.swing.InputMap;
import javax.swing.JButton;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JEditorPane;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.text.DefaultEditorKit;
import javax.swing.text.Document;
import javax.swing.text.EditorKit;
import javax.swing.text.Element;
import javax.swing.text.JTextComponent;
import javax.swing.text.View;
import javax.swing.text.ViewFactory;
import jsyntaxpane.actions.DefaultSyntaxAction;
import jsyntaxpane.actions.SyntaxAction;
import jsyntaxpane.components.SyntaxComponent;
import jsyntaxpane.util.Configuration;
import jsyntaxpane.util.JarServiceProvider;

/**
 * The DefaultSyntaxKit is the main entry to SyntaxPane.  To use the package, just 
 * set the EditorKit of the EditorPane to a new instance of this class.
 * 
 * You need to pass a proper lexer to the class.
 * 
 * @author ayman
 */
public class DefaultSyntaxKit extends DefaultEditorKit implements ViewFactory {

	public static final String CONFIG_CARETCOLOR = "CaretColor";
	public static final String CONFIG_SELECTION = "SelectionColor";
	public static final String CONFIG_COMPONENTS = "Components";
	public static final String CONFIG_MENU = "PopupMenu";
	public static final String CONFIG_TOOLBAR = "Toolbar";
	public static final String CONFIG_TOOLBAR_ROLLOVER = "Toolbar.Buttons.Rollover";
	public static final String CONFIG_TOOLBAR_BORDER = "Toolbar.Buttons.BorderPainted";
	public static final String CONFIG_TOOLBAR_OPAQUE = "Toolbar.Buttons.Opaque";
	public static final String CONFIG_TOOLBAR_BORDER_SIZE = "Toolbar.Buttons.BorderSize";
	private static final Pattern ACTION_KEY_PATTERN = Pattern.compile("Action\\.((\\w|-)+)");
	private static final Pattern DEFAULT_ACTION_PATTERN = Pattern.compile("(DefaultAction.((\\w|-)+)).*");
	private static Font DEFAULT_FONT;
	private static Set<String> CONTENT_TYPES = new HashSet<String>();
	private static Boolean initialized = false;
	private static Map<String, String> abbrvs;
	private static String MENU_MASK_STRING = "control ";
	private Lexer lexer;
	private static final Logger LOG = Logger.getLogger(DefaultSyntaxKit.class.getName());
	private Map<JEditorPane, List<SyntaxComponent>> editorComponents =
		new WeakHashMap<JEditorPane, List<SyntaxComponent>>();
	private Map<JEditorPane, JPopupMenu> popupMenu =
		new WeakHashMap<JEditorPane, JPopupMenu>();
	/**
	 * Main Configuration of JSyntaxPane EditorKits
	 */
	private static Map<Class<? extends DefaultSyntaxKit>, Configuration> CONFIGS;

	static {
		// we only need to initialize once.
		if (!initialized) {
			initKit();
		}
		int menuMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask();
		if(menuMask == KeyEvent.ALT_DOWN_MASK) {
			MENU_MASK_STRING = "alt ";
		}
	}
	private static final String ACTION_MENU_TEXT = "MenuText";

	/**
	 * Create a new Kit for the given language
	 * @param lexer
	 */
	public DefaultSyntaxKit(Lexer lexer) {
		super();
		this.lexer = lexer;
	}

	/**
	 * Adds UI components to the pane
	 * @param editorPane
	 */
	public void addComponents(JEditorPane editorPane) {
		// install the components to the editor:
		String[] components = getConfig().getPropertyList(CONFIG_COMPONENTS);
		for (String c : components) {
			installComponent(editorPane, c);
		}
	}

	/**
	 * Creates a SyntaxComponent of the the given classname and installs
	 * it on the pane
	 * @param pane
	 * @param classname
	 */
	public void installComponent(JEditorPane pane, String classname) {
		try {
			@SuppressWarnings(value = "unchecked")
			Class compClass = Class.forName(classname);
			SyntaxComponent comp = (SyntaxComponent) compClass.newInstance();
			comp.config(getConfig());
			comp.install(pane);
			if (editorComponents.get(pane) == null) {
				editorComponents.put(pane, new ArrayList<SyntaxComponent>());
			}
			editorComponents.get(pane).add(comp);
		} catch (InstantiationException ex) {
			LOG.log(Level.SEVERE, null, ex);
		} catch (IllegalAccessException ex) {
			LOG.log(Level.SEVERE, null, ex);
		} catch (ClassNotFoundException ex) {
			LOG.log(Level.SEVERE, null, ex);
		}
	}

	/**
	 * Find the SyntaxCOmponent with given classname that is installed
	 * on the given pane, then deinstalls and removes it fom the
	 * editorComponents list
	 * @param pane
	 * @param classname
	 */
	public void deinstallComponent(JEditorPane pane, String classname) {
		for (SyntaxComponent c : editorComponents.get(pane)) {
			if (c.getClass().getName().equals(classname)) {
				c.deinstall(pane);
				editorComponents.get(pane).remove(c);
				break;
			}
		}
	}

	/**
	 * Checks if the component with given classname is installed on the
	 * pane.
	 * @param pane
	 * @param classname
	 * @return true if component is installed, false otherwise
	 */
	public boolean isComponentInstalled(JEditorPane pane, String classname) {
		for (SyntaxComponent c : editorComponents.get(pane)) {
			if (c.getClass().getName().equals(classname)) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Toggles the component with given classname.  If component is found
	 * and installed, then it is deinstalled.  Otherwise a new one is
	 * installed
	 * @param pane
	 * @param classname
	 * @return true if component was installed, false if it was removed
	 */
	public boolean toggleComponent(JEditorPane pane, String classname) {
		for (SyntaxComponent c : editorComponents.get(pane)) {
			if (c.getClass().getName().equals(classname)) {
				c.deinstall(pane);
				editorComponents.get(pane).remove(c);
				return false;
			}
		}
		installComponent(pane, classname);
		return true;
	}

	/**
	 * Adds a popup menu to the editorPane if needed.
	 *
	 * @param editorPane
	 */
	public void addPopupMenu(JEditorPane editorPane) {
		String[] menuItems = getConfig().getPropertyList(CONFIG_MENU);
		if (menuItems == null || menuItems.length == 0) {
			return;
		}
		popupMenu.put(editorPane, new JPopupMenu());
		JMenu stack = null;
		for (String menuString : menuItems) {

			// create the Popup menu
			if (menuString.equals("-")) {
				popupMenu.get(editorPane).addSeparator();
			} else if (menuString.startsWith(">")) {
				JMenu sub = new JMenu(menuString.substring(1));
				popupMenu.get(editorPane).add(sub);
				stack = sub;
			} else if (menuString.startsWith("<")) {
				Container parent = stack.getParent();
				if (parent instanceof JMenu) {
					JMenu jMenu = (JMenu) parent;
					stack = jMenu;
				} else {
					stack = null;
				}
			} else {
				Action action = editorPane.getActionMap().get(menuString);
				if (action != null) {
					JMenuItem menuItem;
					if (action.getValue(Action.SELECTED_KEY) != null) {
						menuItem = new JCheckBoxMenuItem(action);
					} else {
						menuItem = new JMenuItem(action);
					}
					// Use our own property if it was set for the menu text
					if (action.getValue(ACTION_MENU_TEXT) != null) {
						menuItem.setText((String) action.getValue(ACTION_MENU_TEXT));
					}
					if (stack == null) {
						popupMenu.get(editorPane).add(menuItem);
					} else {
						stack.add(menuItem);
					}
				}
			}
		}
		editorPane.setComponentPopupMenu(popupMenu.get(editorPane));
	}

	/**
	 * Add all pop-up menu items to a Toolbar.  <b>You need to call the validate method
	 * on the toolbar after this is done to layout the buttons.</b>
	 * Only Actions which have a SMALL_ICON property will be added to the toolbar
	 * There are three Configuration Keys that affect the appearance of the added buttons:
	 * CONFIG_TOOLBAR_ROLLOVER, CONFIG_TOOLBAR_BORDER, CONFIG_TOOLBAR_OPAQUE
	 * 
	 * @param editorPane
	 * @param toolbar
	 */
	public void addToolBarActions(JEditorPane editorPane, JToolBar toolbar) {
		String[] toolBarItems = getConfig().getPropertyList(CONFIG_TOOLBAR);
		if (toolBarItems == null || toolBarItems.length == 0) {
			toolBarItems = getConfig().getPropertyList(CONFIG_MENU);
			if (toolBarItems == null || toolBarItems.length == 0) {
				return;
			}
		}
		boolean btnRolloverEnabled = getConfig().getBoolean(CONFIG_TOOLBAR_ROLLOVER, true);
		boolean btnBorderPainted = getConfig().getBoolean(CONFIG_TOOLBAR_BORDER, false);
		boolean btnOpaque = getConfig().getBoolean(CONFIG_TOOLBAR_OPAQUE, false);
		int btnBorderSize = getConfig().getInteger(CONFIG_TOOLBAR_BORDER_SIZE, 2);
		for (String menuString : toolBarItems) {
			if (menuString.equals("-") ||
				menuString.startsWith("<") ||
				menuString.startsWith(">")) {
				toolbar.addSeparator();
			} else {
				Action action = editorPane.getActionMap().get(menuString);
				if (action != null && action.getValue(Action.SMALL_ICON) != null) {
					JButton b = toolbar.add(action);
					b.setRolloverEnabled(btnRolloverEnabled);
					b.setBorderPainted(btnBorderPainted);
					b.setOpaque(btnOpaque);
					b.setFocusable(false);
					b.setBorder(BorderFactory.createEmptyBorder(btnBorderSize,
						btnBorderSize, btnBorderSize, btnBorderSize));
				}
			}
		}
	}

	@Override
	public ViewFactory getViewFactory() {
		return this;
	}

	@Override
	public View create(Element element) {
		return new SyntaxView(element, getConfig());
	}

	/**
	 * Install the View on the given EditorPane.  This is called by Swing and
	 * can be used to do anything you need on the JEditorPane control.  Here
	 * I set some default Actions.
	 *
	 * @param editorPane
	 */
	@Override
	public void install(JEditorPane editorPane) {
		super.install(editorPane);
		// get our font
		String fontName = getProperty("DefaultFont");
		Font font = DEFAULT_FONT;
		if (fontName != null) {
			font = Font.decode(fontName);
		}
		editorPane.setFont(font);
		Configuration conf = getConfig();
		Color caretColor = conf.getColor(CONFIG_CARETCOLOR, Color.BLACK);
		editorPane.setCaretColor(caretColor);
		Color selectionColor = getConfig().getColor(CONFIG_SELECTION, new Color(0x99ccff));
		editorPane.setSelectionColor(selectionColor);
		addActions(editorPane);
		addComponents(editorPane);
		addPopupMenu(editorPane);
	}

	@Override
	public void deinstall(JEditorPane editorPane) {
		List<SyntaxComponent> l = editorComponents.get(editorPane);
		for (SyntaxComponent c : editorComponents.get(editorPane)) {
			c.deinstall(editorPane);
		}
		editorComponents.clear();
		editorPane.getInputMap().clear();
		editorPane.getActionMap().clear();
	}

	/**
	 * Add keyboard actions to this control using the Configuration we have
	 * This is revised to properly use InputMap and ActionMap of the component
	 * instead of using the KeyMaps directly.
	 * @param editorPane
	 */
	public void addActions(JEditorPane editorPane) {
		InputMap imap = new InputMap();
		imap.setParent(editorPane.getInputMap());
		ActionMap amap = new ActionMap();
		amap.setParent(editorPane.getActionMap());

		for (Configuration.StringKeyMatcher m : getConfig().getKeys(ACTION_KEY_PATTERN)) {
			String[] values = Configuration.COMMA_SEPARATOR.split(
				m.value);
			String actionClass = values[0];
			String actionName = m.group1;
			SyntaxAction action = createAction(actionClass);
			// The configuration keys will need to be prefixed by Action
			// to make it more readable in the Configuration files.
			action.config(getConfig(), DefaultSyntaxAction.ACTION_PREFIX + actionName);
			// Add the action to the component also
			amap.put(actionName, action);
			// Now bind all the keys to the Action we have using the InputMap
			for (int i = 1; i < values.length; i++) {
				String keyStrokeString = values[i].replace("menu ", MENU_MASK_STRING);
				KeyStroke ks = KeyStroke.getKeyStroke(keyStrokeString);
				// we may have more than onr value ( for key action ), but we will use the 
				// last one in the single value here.  This will display the key in the
				// popup menus.  Pretty neat.
				if (ks == null) {
					throw new IllegalArgumentException("Invalid KeyStroke: " +
						keyStrokeString);
				}
				action.putValue(Action.ACCELERATOR_KEY, ks);
				imap.put(ks, actionName);
			}
		}

		// Now configure the Default actions for better display in the popup menu
		for (Configuration.StringKeyMatcher m : getConfig().getKeys(DEFAULT_ACTION_PATTERN)) {
			String name = m.matcher.group(2);
			Action action = editorPane.getActionMap().get(name);
			if (action != null) {
				configActionProperties(action, name, m.group1);
			}
			// The below commented block does find the keys for the default Actions
			// using InputMap, however there are multiple bound keys for the
			// default actions that displaying them in the menu will probably not
			// be the most obvious binding
            /*
			for (KeyStroke key : imap.allKeys()) {
			Object o = imap.get(key);
			if(name.equals(o)) {
			action.putValue(Action.ACCELERATOR_KEY, key);
			break;
			}
			}
			 */
		}
		editorPane.setActionMap(amap);
		editorPane.setInputMap(JTextComponent.WHEN_FOCUSED, imap);
	}

	private void configActionProperties(Action action, String actionName, String configKey) {

		// if we have an icon, then load it:
		String iconLoc = getConfig().getString(configKey + ".SmallIcon", actionName + ".png");
		URL loc = this.getClass().getResource(DefaultSyntaxAction.SMALL_ICONS_LOC_PREFIX + iconLoc);
		if (loc != null) {
			ImageIcon i = new ImageIcon(loc);
			action.putValue(Action.SMALL_ICON, i);
		}
		// Set the menu text.  Use the Action.NAME property, unless it is
		// already set.
		// The NAME would be set for default actions, and we should not change those names.
		// so we will put another property and use it for the menu text
		String name = getProperty(configKey + ".MenuText");
		if (action.getValue(Action.NAME) == null) {
			action.putValue(Action.NAME, name);
		} else {
			action.putValue(ACTION_MENU_TEXT, name);
		}
		// Set the menu tooltips
		String shortDesc = getProperty(configKey + ".ToolTip");
		if (shortDesc != null) {
			action.putValue(Action.SHORT_DESCRIPTION, shortDesc);
		} else {
			action.putValue(Action.SHORT_DESCRIPTION, name);
		}
	}

	private SyntaxAction createAction(String actionClassName) {
		SyntaxAction action = null;
		try {
			Class clazz = Class.forName(actionClassName);
			action = (SyntaxAction) clazz.newInstance();
		} catch (InstantiationException ex) {
			throw new IllegalArgumentException("Cannot create action class: " +
				actionClassName + ". Ensure it has default constructor.", ex);
		} catch (IllegalAccessException ex) {
			throw new IllegalArgumentException("Cannot create action class: " +
				actionClassName, ex);
		} catch (ClassNotFoundException ex) {
			throw new IllegalArgumentException("Cannot create action class: " +
				actionClassName, ex);
		} catch (ClassCastException ex) {
			throw new IllegalArgumentException("Cannot create action class: " +
				actionClassName, ex);
		}
		return action;
	}

	/**
	 * This is called by Swing to create a Document for the JEditorPane document
	 * This may be called before you actually get a reference to the control.
	 * We use it here to create a proper lexer and pass it to the
	 * SyntaxDcument we return.
	 * @return
	 */
	@Override
	public Document createDefaultDocument() {
		return new SyntaxDocument(lexer);
	}

	/**
	 * This is called to initialize the list of <code>Lexer</code>s we have.
	 * You can call  this at initialization, or it will be called when needed.
	 * The method will also add the appropriate EditorKit classes to the
	 * corresponding ContentType of the JEditorPane.  After this is called,
	 * you can simply call the editor.setCOntentType("text/java") on the
	 * control and you will be done.
	 */
	public synchronized static void initKit() {
		// attempt to find a suitable default font
		String defaultFont = getConfig(DefaultSyntaxKit.class).getString("DefaultFont");
		if (defaultFont != null) {
			DEFAULT_FONT = Font.decode(defaultFont);
		} else {
			GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
			String[] fonts = ge.getAvailableFontFamilyNames();
			Arrays.sort(fonts);
			if (Arrays.binarySearch(fonts, "Courier New") >= 0) {
				DEFAULT_FONT = new Font("Courier New", Font.PLAIN, 12);
			} else if (Arrays.binarySearch(fonts, "Courier") >= 0) {
				DEFAULT_FONT = new Font("Courier", Font.PLAIN, 12);
			} else if (Arrays.binarySearch(fonts, "Monospaced") >= 0) {
				DEFAULT_FONT = new Font("Monospaced", Font.PLAIN, 13);
			}
		}

		// read the Default Kits and their associated types
		Properties kitsForTypes = JarServiceProvider.readProperties("jsyntaxpane/kitsfortypes");
		for (Map.Entry e : kitsForTypes.entrySet()) {
			String type = e.getKey().toString();
			String classname = e.getValue().toString();
			registerContentType(type, classname);
		}
		initialized = true;
	}

	/**
	 * Register the given content type to use the given class name as its kit
	 * When this is called, an entry is added into the private HashMap of the
	 * registered editors kits.  This is needed so that the SyntaxPane library
	 * has it's own registration of all the EditorKits
	 * @param type
	 * @param classname
	 */
	public static void registerContentType(String type, String classname) {
		try {
			// ensure the class is available and that it does supply a no args
			// constructor.  This saves debugging later if the classname is incorrect
			// or does not behave correctly:
			Class c = Class.forName(classname);
			// attempt to create the class, if we cannot with an empty argument
			// then the class is invalid
			Object kit = c.newInstance();
			if (!(kit instanceof EditorKit)) {
				throw new IllegalArgumentException("Cannot register class: " + classname +
					". It does not extend EditorKit");
			}
			JEditorPane.registerEditorKitForContentType(type, classname);
			CONTENT_TYPES.add(type);
		} catch (InstantiationException ex) {
			throw new IllegalArgumentException("Cannot register class: " + classname +
				". Ensure it has Default Constructor.", ex);
		} catch (IllegalAccessException ex) {
			throw new IllegalArgumentException("Cannot register class: " + classname, ex);
		} catch (ClassNotFoundException ex) {
			throw new IllegalArgumentException("Cannot register class: " + classname, ex);
		} catch (RuntimeException ex) {
			throw new IllegalArgumentException("Cannot register class: " + classname, ex);
		}
	}

	/**
	 * Return all the content types supported by this library.  This will be the
	 * content types in the file WEB-INF/services/resources/jsyntaxpane/kitsfortypes
	 * @return sorted array of all registered content types
	 */
	public static String[] getContentTypes() {
		String[] types = CONTENT_TYPES.toArray(new String[0]);
		Arrays.sort(types);
		return types;
	}

	/**
	 * Merges the given properties with the configurations for this Object
	 *
	 * @param config
	 */
	public void setConfig(Properties config) {
		getConfig().putAll(config);
	}

	/**
	 * Sets the given property to the given value.  If the kit is not
	 * initialized,  then calls initKit
	 * @param key
	 * @param value
	 */
	public void setProperty(String key, String value) {
		getConfig().put(key, value);
	}

	/**
	 * Return the property with the given key.  If the kit is not
	 * initialized,  then calls initKit
	 * Be careful when changing property as the default property may be used
	 * @param key
	 * @return value for given key
	 */
	public String getProperty(String key) {
		return getConfig().getString(key);
	}

	/**
	 * Get the configuration for this Object
	 * @return
	 */
	public Configuration getConfig() {
		return getConfig(this.getClass());
	}

	/**
	 * Return the Configurations object for a Kit.  Perfrom lazy creation of a
	 * Configuration object if nothing is created.
	 *
	 * @param kit
	 * @return
	 */
	public static synchronized Configuration getConfig(Class<? extends DefaultSyntaxKit> kit) {
		if (CONFIGS == null) {
			CONFIGS = new WeakHashMap<Class<? extends DefaultSyntaxKit>, Configuration>();
			Configuration defaultConfig = new Configuration(DefaultSyntaxKit.class);
			loadConfig(defaultConfig, DefaultSyntaxKit.class);
			CONFIGS.put(DefaultSyntaxKit.class, defaultConfig);
		}

		if (CONFIGS.containsKey(kit)) {
			return CONFIGS.get(kit);
		} else {
			// recursive call until we read the Super duper DefaultSyntaxKit
			Class superKit = kit.getSuperclass();
			@SuppressWarnings("unchecked")
			Configuration defaults = getConfig(superKit);
			Configuration mine = new Configuration(kit, defaults);
			loadConfig(mine, kit);
			CONFIGS.put(kit, mine);
			return mine;
		}
	}

	public Map<String, String> getAbbreviations() {
		// if we have not loaded the abbreviations, then load them now:
		if (abbrvs == null) {
			String cl = this.getClass().getName().replace('.', '/').toLowerCase();
			abbrvs = JarServiceProvider.readStringsMap(cl + "/abbreviations.properties");
		}
		return abbrvs;
	}

	/**
	 * Adds an abbrevisation to this kit's abbreviations.
	 * @param abbr
	 * @param template
	 */
	public static void addAbbreviation(String abbr, String template) {
		if (abbrvs == null) {
			abbrvs = new HashMap<String, String>();
		}
		abbrvs.put(abbr, template);
	}

	/**
	 * Get the template for the given abbreviation
	 * @param abbr
	 * @return
	 */
	public static String getAbbreviation(String abbr) {
		return abbrvs == null ? null : abbrvs.get(abbr);
	}

	private static void loadConfig(Configuration conf, Class<? extends EditorKit> kit) {
		String url = kit.getName().replace(".", "/") + "/config";
		Properties p = JarServiceProvider.readProperties(url, Locale.getDefault());
		if (p.size() == 0) {
			LOG.log(Level.INFO, "unable to load configuration for: {0} from: {1}.properties",
				new Object[]{kit, url});
		} else {
			conf.putAll(p);
		}
	}

	@Override
	public String getContentType() {
		return "text/" + this.getClass().getSimpleName().replace("SyntaxKit", "").toLowerCase();
	}
}
