/* Parser.java

	Purpose:
		
	Description:
		
	History:
		Sat Sep 17 12:12:41     2005, Created by tomyeh

Copyright (C) 2004 Potix Corporation. All Rights Reserved.

{{IS_RIGHT
	This program is distributed under LGPL Version 2.1 in the hope that
	it will be useful, but WITHOUT ANY WARRANTY.
}}IS_RIGHT
*/
package org.zkoss.web.servlet.dsp.impl;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URL;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

import org.zkoss.idom.Element;
import org.zkoss.idom.input.SAXBuilder;
import org.zkoss.idom.util.IDOMs;
import org.zkoss.lang.Classes;
import org.zkoss.util.resource.Locator;
import org.zkoss.web.mesg.MWeb;
import org.zkoss.web.servlet.dsp.DspException;
import org.zkoss.web.servlet.dsp.Interpretation;
import org.zkoss.web.servlet.dsp.action.Action;
import org.zkoss.web.servlet.dsp.action.Page;
import org.zkoss.xel.ExpressionFactory;
import org.zkoss.xel.Expressions;
import org.zkoss.xel.FunctionMapper;
import org.zkoss.xel.VariableResolver;
import org.zkoss.xel.XelContext;
import org.zkoss.xel.XelException;
import org.zkoss.xel.taglib.Taglibs;
import org.zkoss.xel.util.SimpleMapper;

/**
 * Used to parse a DSP page into a meta format called
 * {@link Interpretation}.
 *
 * @author tomyeh
 */
public class Parser {
	/** Parses the content into a meta format
	 *
	 * @param content the content to parse; never null.
	 * @param ctype the content type. Optional. It is used only if
	 * no page action at all. If it is not specified and not page
	 * action, "text/html" is assumed.
	 * @param xelc the context information used to parse XEL expressions
	 * in the content.
	 * @param loc used to locate the resource such as taglib.
	 * It could null only if DSP contains no such resource.
	 */
	public Interpretation parse(String content, String ctype, XelContext xelc, Locator loc)
			throws DspException, IOException, XelException {
		final Context ctx = new Context(content, xelc, loc);
		final RootNode root = new RootNode();
		parse0(ctx, root, 0, content.length());

		root.setFunctionMapper(ctx.getFunctionMapper());
		if (!ctx.pageDefined) {
			//We always create a page definition
			final ActionNode action = new ActionNode(Page.class, 0);
			root.addChild(0, action);
			final Map<String, String> attrs = new HashMap<String, String>(2);

			if (ctype == null)
				ctype = "text/html";
			else if (ctype.length() > 0 && ctype.charAt(0) == ';')
				ctype = "text/html" + ctype;

			attrs.put("optionalContentType", ctype);
			applyAttrs("page", action, attrs, ctx);
		}

		return root;
	}

	/** Recursively parse the content into a tree of {@link Node}.
	 */
	private static void parse0(Context ctx, Node parent, int from, int to)
			throws DspException, IOException, XelException {
		boolean esc = false;
		final StringBuffer sb = new StringBuffer(512);
		for (int j = from; j < to; ++j) {
			char cc = ctx.content.charAt(j);
			//We only recognize <%, <\%, ${, $\{ and <xx:yy>
			switch (cc) {
			case '<':
				if (j + 1 < to) {
					char c2 = ctx.content.charAt(j + 1);
					if (c2 == '\\') {
						if (j + 2 < to && ctx.content.charAt(j + 2) == '%')
							++j; //skip '\\'
					} else if (c2 == '%') {
						addText(parent, sb);
						j = parseControl(ctx, parent, j, to);
						continue;
					} else {
						final int oldLines = ctx.nLines;
						int k = skipWhitespaces(ctx, j + 1, to);
						int l = nextSeparator(ctx, k, to);
						if (l >= to || l == k || ctx.content.charAt(l) != ':') {
							ctx.nLines = oldLines;
							break; //bypass what we don't recognize
						}
						final String prefix = ctx.content.substring(k, l);
						if (!ctx.hasPrefix(prefix)) {
							ctx.nLines = oldLines;
							break; //bypass what we don't recognize
						}

						addText(parent, sb);
						j = parseAction(ctx, parent, prefix, l, to);
						continue;
					}
				}
				break;
			case '$':
				if (j + 1 < to) {
					char c2 = ctx.content.charAt(j + 1);
					if (c2 == '\\') {
						if (j + 2 < to && ctx.content.charAt(j + 2) == '{')
							++j; //skip '\\'
					} else if (c2 == '{') {
						addText(parent, sb);
						j = parseEL(ctx, parent, j, to);
						continue;
					}
				}
				break;
			case '\n':
				++ctx.nLines;
			}
			sb.append(cc);
		}
		addText(parent, sb);
	}

	/** Parses a control (e.g., &lt;% page %&gt;) starting at from,
	 * and returns the position of '&gt' (in %&gt;).
	 */
	private static int parseControl(Context ctx, Node parent, int from, int to)
			throws DspException, IOException, XelException {
		int j = from + 2;
		if (j + 1 >= to)
			throw new DspException(MWeb.DSP_ACTION_NOT_TERMINATED, new Object[] { null, new Integer(ctx.nLines) });

		//0. comment
		char cc = ctx.content.charAt(j);
		if (cc == '-' && ctx.content.charAt(j + 1) == '-') { //comment
			for (int end = to - 4;; ++j) {
				if (j > end)
					throw new DspException(MWeb.DSP_COMMENT_NOT_TERMINATED, new Integer(ctx.nLines));
				if (ctx.content.charAt(j) == '\n')
					++ctx.nLines;
				else if (startsWith(ctx.content, j, to, "--%>"))
					return j + 3;
			}
		}
		if (cc != '@')
			throw new DspException(MWeb.DSP_EXPECT_CHARACTER,
					new Object[] { new Character('@'), new Integer(ctx.nLines) });

		//1: which control
		j = skipWhitespaces(ctx, j + 1, to);
		int k = nextSeparator(ctx, j, to);
		if (k >= to)
			throw new DspException(MWeb.DSP_ACTION_NOT_TERMINATED, new Object[] { null, new Integer(ctx.nLines) });
		final ActionNode action;
		final String ctlnm = ctx.content.substring(j, k);
		if ("taglib".equals(ctlnm)) {
			action = null;
		} else if ("page".equals(ctlnm)) {
			ctx.pageDefined = true;
			trim(parent); //Bug 1798123: avoid getOut being called before Page
			parent.addChild(action = new ActionNode(Page.class, ctx.nLines));
		} else {
			throw new DspException(MWeb.DSP_UNKNOWN_ACTION, new Object[] { ctlnm, new Integer(ctx.nLines) });
		}

		//2: parse attributes
		final Map<String, String> attrs = new HashMap<String, String>();
		k = parseAttrs(ctx, attrs, ctlnm, k, to);
		cc = ctx.content.charAt(k);
		if (cc != '%')
			throw new DspException(MWeb.DSP_EXPECT_CHARACTER,
					new Object[] { new Character('%'), new Integer(ctx.nLines) });

		if (action == null) { //taglib
			final String uri = attrs.get("uri"), prefix = attrs.get("prefix");
			if (prefix == null || uri == null)
				throw new DspException(MWeb.DSP_TAGLIB_ATTRIBUTE_REQUIRED, new Integer(ctx.nLines));
			ctx.loadTaglib(prefix, uri);
		} else {
			applyAttrs(ctlnm, action, attrs, ctx);
		}

		if (++k >= to || ctx.content.charAt(k) != '>')
			throw new DspException(MWeb.DSP_ACTION_NOT_TERMINATED, new Object[] { ctlnm, new Integer(ctx.nLines) });
		return k;
	}

	/** Trimmed {@link TextNode} that contains nothing but spaces.
	 */
	private static void trim(Node node) {
		for (Iterator it = node.getChildren().iterator(); it.hasNext();) {
			final Object o = it.next();
			if (o instanceof TextNode) {
				final String s = ((TextNode) o).getText();
				if (s == null || s.trim().length() == 0)
					it.remove();
			}
		}
	}

	/** Parses an action (e.g., &lt;c:forEach...&gt;...&lt;/c:forEach&gt;).
	 * @param from the position of ':'
	 * @return the position of the last '&gt'.
	 */
	private static int parseAction(Context ctx, Node parent, String prefix, int from, int to)
			throws DspException, IOException, XelException {
		//1: which action
		int j = skipWhitespaces(ctx, from + 1, to);
		int k = nextSeparator(ctx, j, to);
		if (k >= to)
			throw new DspException(MWeb.DSP_ACTION_NOT_TERMINATED,
					new Object[] { prefix + ':', new Integer(ctx.nLines) });
		if (k == j)
			throw new DspException(MWeb.DSP_ACTION_REQUIRED, new Integer(ctx.nLines));

		final String actnm = ctx.content.substring(j, k);
		final Class actcls = ctx.getActionClass(prefix, actnm);
		if (actcls == null)
			throw new DspException(MWeb.DSP_UNKNOWN_ACTION,
					new Object[] { prefix + ':' + actnm, new Integer(ctx.nLines) });
		final ActionNode action = new ActionNode(actcls, ctx.nLines);
		parent.addChild(action);

		//2: action's attributes
		final Map<String, String> attrs = new HashMap<String, String>();
		j = parseAttrs(ctx, attrs, actnm, k, to);
		char cc = ctx.content.charAt(j);
		boolean ended = cc == '/';
		if (!ended && cc != '>')
			throw new DspException(MWeb.DSP_UNEXPECT_CHARACTER,
					new Object[] { new Character(cc), new Integer(ctx.nLines) });

		applyAttrs(actnm, action, attrs, ctx);

		if (ended) {
			if (j + 1 >= to || ctx.content.charAt(j + 1) != '>')
				throw new DspException(MWeb.DSP_ACTION_NOT_TERMINATED,
						new Object[] { prefix + ':' + actnm, new Integer(action.getLineNumber()) });
			return j + 1;
		}

		//3: nested content
		final int nestedFrom = ++j, nestedTo;
		for (int depth = 0;; ++j) {
			if (j >= to)
				throw new DspException(MWeb.DSP_ACTION_NOT_TERMINATED,
						new Object[] { actnm, new Integer(action.getLineNumber()) });

			cc = ctx.content.charAt(j);
			if (j + 1 < to) {
				if (cc == '<') {
					final int oldLines = ctx.nLines;
					k = j + 1;
					ended = ctx.content.charAt(k) == '/';
					k = skipWhitespaces(ctx, ended ? k + 1 : k, to);
					int l = nextSeparator(ctx, k, to);
					if (l >= to || ctx.content.charAt(l) != ':' || !prefix.equals(ctx.content.substring(k, l))) {
						ctx.nLines = oldLines;
						continue; //bypass
					}

					k = skipWhitespaces(ctx, l + 1, to);
					l = nextSeparator(ctx, k, to);
					if (l >= to || !actnm.equals(ctx.content.substring(k, l))) {
						ctx.nLines = oldLines;
						continue; //bypass
					}
					l = skipWhitespaces(ctx, l, to);
					if (l >= to || (ended && ctx.content.charAt(l) != '>')) {
						ctx.nLines = oldLines;
						continue; //bypass
					}

					if (ended) {
						if (--depth < 0) {
							nestedTo = j;
							j = l;
							break; //done
						}
					} else {
						++depth;
					}
					j = l;
					continue;
				} else if (cc == '$' && ctx.content.charAt(j + 1) == '{') {
					j = endOfEL(ctx, j, to);
					continue;
				}
			}
			if (cc == '\n')
				++ctx.nLines;
		}

		parse0(ctx, action, nestedFrom, nestedTo); //recursive
		return j;
	}

	private static boolean startsWith(String content, int from, int to, String s) {
		for (int j = 0, len = s.length();; ++from, ++j) {
			if (j >= len)
				return true;
			if (from >= to || content.charAt(from) != s.charAt(j))
				return false;
		}
	}

	private static int skipWhitespaces(Context ctx, int from, int to) {
		for (; from < to; ++from) {
			final char cc = ctx.content.charAt(from);
			if (cc == '\n')
				++ctx.nLines;
			else if (!Character.isWhitespace(cc))
				break;
		}
		return from;
	}

	private static int nextSeparator(Context ctx, int from, int to) {
		for (; from < to; ++from) {
			final char cc = ctx.content.charAt(from);
			if ((cc < '0' || cc > '9') && (cc < 'a' || cc > 'z') && (cc < 'A' || cc > 'Z') && cc != '_')
				break;
		}
		return from;
	}

	/** Parses the attributes.
	 */
	private static int parseAttrs(Context ctx, Map<String, String> attrs, String actnm, int from, int to)
			throws DspException {
		for (int j, k = from;;) {
			j = skipWhitespaces(ctx, k, to);
			k = nextSeparator(ctx, j, to);
			if (k >= to)
				throw new DspException(MWeb.DSP_ACTION_NOT_TERMINATED, new Object[] { actnm, new Integer(ctx.nLines) });
			if (j == k)
				return j;

			final String attrnm = ctx.content.substring(j, k);
			k = skipWhitespaces(ctx, k, to);
			j = skipWhitespaces(ctx, k + 1, to);
			if (j >= to || ctx.content.charAt(k) != '=')
				throw new DspException(MWeb.DSP_ATTRIBUTE_VALUE_REQUIRED,
						new Object[] { actnm, attrnm, new Integer(ctx.nLines) });

			final char quot = ctx.content.charAt(j);
			if (quot != '"' && quot != '\'')
				throw new DspException(MWeb.DSP_ATTRIBUTE_VALUE_QUOTE_REQUIRED,
						new Object[] { actnm, attrnm, new Integer(ctx.nLines) });

			final StringBuffer sbval = new StringBuffer();
			for (k = ++j;; ++k) {
				if (k >= to)
					throw new DspException(MWeb.DSP_ATTRIBUTE_VALUE_QUOTE_REQUIRED,
							new Object[] { actnm, attrnm, new Integer(ctx.nLines) });
				final char cc = ctx.content.charAt(k);
				if (cc == '\n')
					throw new DspException(MWeb.DSP_ATTRIBUTE_VALUE_QUOTE_REQUIRED,
							new Object[] { actnm, attrnm, new Integer(ctx.nLines) });

				if (cc == quot) {
					++k;
					break; //found
				}

				sbval.append(cc);
				if (cc == '\\' && ++k < to)
					sbval.setCharAt(sbval.length() - 1, ctx.content.charAt(k));
			}

			attrs.put(attrnm, sbval.toString());
		}
	}

	/** Applies attributes.
	 */
	private static final void applyAttrs(String actnm, ActionNode action, Map<String, String> attrs, ParseContext ctx)
			throws DspException, XelException {
		for (Map.Entry<String, String> me : attrs.entrySet()) {
			final String attrnm = me.getKey();
			final String attrval = me.getValue();
			try {
				action.addAttribute(attrnm, attrval, ctx);
			} catch (NoSuchMethodException ex) {
				throw new DspException(MWeb.DSP_ATTRIBUTE_NOT_FOUND,
						new Object[] { actnm, attrnm, new Integer(action.getLineNumber()) });
			} catch (ClassCastException ex) {
				throw new DspException(MWeb.DSP_ATTRIBUTE_INVALID_VALUE,
						new Object[] { actnm, attrnm, attrval, new Integer(action.getLineNumber()) }, ex);
			}
		}
	}

	/** Parses an EL expression starting at from.
	 * @return the position of }.
	 */
	private static int parseEL(Context ctx, Node parent, int from, int to) throws DspException, XelException {
		int j = endOfEL(ctx, from, to); //point to }
		parent.addChild(new XelNode(ctx.content.substring(from, j + 1), ctx));
		return j;
	}

	/** Returns the position of '}'. */
	private static int endOfEL(Context ctx, int from, int to) throws DspException {
		for (int j = from + 2;; ++j) {
			if (j >= to)
				throw new DspException(MWeb.EL_NOT_TERMINATED, new Integer(ctx.nLines));

			final char cc = ctx.content.charAt(j);
			if (cc == '}') {
				return j;
			} else if (cc == '\'' || cc == '"') {
				while (++j < to) {
					final char c2 = ctx.content.charAt(j);
					if (c2 == cc)
						break;
					if (cc == '\n')
						throw new DspException("Illegal EL expression: non-terminaled " + cc + " at line " + ctx.nLines
								+ " character " + j);
					if (c2 == '\\' && ++j < to && ctx.content.charAt(j) == '\n')
						++ctx.nLines;
				}
			} else if (cc == '\n') {
				++ctx.nLines;
			}
		}
	}

	/** Adds a text node. */
	private static void addText(Node parent, StringBuffer sb) {
		if (sb.length() > 0) {
			parent.addChild(new TextNode(sb.toString()));
			sb.setLength(0);
		}
	}

	/** Context used for parsing. */
	private static class Context implements ParseContext {
		private final String content;
		/** (String prefix, Map(String name, Class class)). */
		private final Map<String, Map<String, Class<?>>> _actions = new HashMap<String, Map<String, Class<?>>>();
		private final Locator _locator;
		private final ExpressionFactory _xelf;
		private final SimpleMapper _mapper;
		private final VariableResolver _resolver;
		private Map<String, Object> _attrs;
		private int nLines;
		/** Whether the page action is defined. */
		private boolean pageDefined;

		//ParseContext//
		public ExpressionFactory getExpressionFactory() {
			return _xelf;
		}

		public VariableResolver getVariableResolver() {
			return _resolver;
		}

		public FunctionMapper getFunctionMapper() {
			return _mapper;
		}

		//Internal//
		private Context(String content, XelContext xelc, Locator loc) {
			this.content = content;
			_resolver = xelc != null ? xelc.getVariableResolver() : null;
			_mapper = new SimpleMapper(xelc != null ? xelc.getFunctionMapper() : null);
			_xelf = Expressions.newExpressionFactory();
			_locator = loc;
			this.nLines = 1;
		}

		private boolean hasPrefix(String prefix) {
			return _actions.containsKey(prefix);
		}

		private Class<?> getActionClass(String prefix, String actnm) {
			final Map<String, Class<?>> acts = _actions.get(prefix);
			return acts != null ? acts.get(actnm) : null;
		}

		private void loadTaglib(String prefix, String uri) throws DspException, IOException {
			if (_locator == null)
				throw new DspException("Unable to load " + uri + " because locator is not specified");

			URL url = uri.indexOf("://") > 0 ? null : _locator.getResource(uri);
			if (url == null) {
				url = Taglibs.getDefaultURL(uri);
				if (url == null)
					throw new FileNotFoundException(uri);
			}

			try {
				loadTaglib0(prefix, url);
			} catch (IOException ex) {
				throw ex;
			} catch (Exception ex) {
				throw DspException.Aide.wrap(ex);
			}
		}

		private void loadTaglib0(String prefix, URL url) throws Exception {
			final Element root = new SAXBuilder(true, false, true).build(url).getRootElement();
			_mapper.load(prefix, url);

			final Map<String, Class<?>> acts = new HashMap<String, Class<?>>();
			for (Iterator it = root.getElements("tag").iterator(); it.hasNext();) {
				final Element e = (Element) it.next();
				final String name = IDOMs.getRequiredElementValue(e, "name");
				final String clsName = IDOMs.getRequiredElementValue(e, "tag-class");
				final Class<?> cls = Classes.forNameByThread(clsName);
				if (!Action.class.isAssignableFrom(cls))
					throw new DspException(cls + " doesn't implement " + Action.class);
				acts.put(name, cls);
			}
			if (!acts.isEmpty())
				_actions.put(prefix, acts);
		}

		private Map<String, Object> attrs() {
			return _attrs != null ? _attrs : (_attrs = new HashMap<String, Object>());
		}

		public Object getAttribute(String name) {
			return _attrs != null ? _attrs.get(name) : null;
		}

		public Object setAttribute(String name, Object value) {
			return attrs().put(name, value);
		}

		public boolean hasAttribute(String name) {
			return _attrs != null && _attrs.containsKey(name);
		}

		public Object removeAttribute(String name) {
			return _attrs != null ? _attrs.remove(name) : null;
		}

		public Map<String, Object> getAttributes() {
			return attrs();
		}
	}
}
