package com.dumbhippo.web.servlets;

import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.slf4j.Logger;

import com.dumbhippo.ExceptionUtils;
import com.dumbhippo.GlobalSetup;
import com.dumbhippo.XmlBuilder;
import com.dumbhippo.identity20.Guid;
import com.dumbhippo.identity20.Guid.ParseException;
import com.dumbhippo.persistence.EmailResource;
import com.dumbhippo.persistence.Group;
import com.dumbhippo.persistence.Post;
import com.dumbhippo.persistence.SharedFile;
import com.dumbhippo.persistence.User;
import com.dumbhippo.persistence.UserBlockData;
import com.dumbhippo.persistence.ValidationException;
import com.dumbhippo.server.GroupSystem;
import com.dumbhippo.server.HttpContentTypes;
import com.dumbhippo.server.HttpMethods;
import com.dumbhippo.server.HttpOptions;
import com.dumbhippo.server.HttpParams;
import com.dumbhippo.server.HttpResponseData;
import com.dumbhippo.server.HumanVisibleException;
import com.dumbhippo.server.IdentitySpider;
import com.dumbhippo.server.NotFoundException;
import com.dumbhippo.server.PostingBoard;
import com.dumbhippo.server.SharedFileSystem;
import com.dumbhippo.server.Stacker;
import com.dumbhippo.server.XmlMethodErrorCode;
import com.dumbhippo.server.XmlMethodException;
import com.dumbhippo.server.util.EJBUtil;
import com.dumbhippo.server.views.AnonymousViewpoint;
import com.dumbhippo.server.views.UserViewpoint;
import com.dumbhippo.server.views.Viewpoint;
import com.dumbhippo.tx.RetryException;
import com.dumbhippo.web.LoginCookie;
import com.dumbhippo.web.SigninBean;
import com.dumbhippo.web.UserSigninBean;
import com.dumbhippo.web.WebEJBUtil;
import com.dumbhippo.web.WebStatistics;

/** 
 * This servlet proxies http method invocations ("ajax api") onto 
 * specially-annotated Java methods. It can also spit out docs for 
 * the available API.
 *
 */
public class HttpMethodsServlet2 extends AbstractServlet {
	private static final Logger logger = GlobalSetup.getLogger(HttpMethodsServlet2.class);
	
	private static final long serialVersionUID = 0L;

	private static HttpMethodRepository repository;
	
	private static HttpMethodRepository getRepository() {
		synchronized (HttpMethodsServlet2.class) {
			if (repository == null) {
				repository = new HttpMethodRepository(HttpMethods.class);
			}
		}
		return repository;
	}
	
	private static class HttpMethodRepository {
		private Map<Class<?>,Marshaller<?>> marshallers;
		
		private Map<String,HttpMethod> methods;

		private Map<String,HttpMethod> lowercaseMethods;
		
		private List<HttpMethod> sortedMethods;
		
		HttpMethodRepository(Class<?>... interfaces) {
			methods = new HashMap<String,HttpMethod>();
			
			marshallers = new HashMap<Class<?>,Marshaller<?>>();
			
			marshallers.put(String.class, new Marshaller<String>() {
	
				public String marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					return s;
				}
				
				public Class<?> getType() {
					return String.class;
				}
				
			});
			
			marshallers.put(boolean.class, new Marshaller<Boolean>() {
	
				public Boolean marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					if (s == null)
						return false;
					else if (s.equals("true"))
						return true;
					else if (s.equals("false"))
						return false;
					else
						throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "could not parse boolean value: '" + s + "' use 'true' or 'false'");
				}

				public Class<?> getType() {
					return boolean.class;
				}
				
			});	
			
			marshallers.put(int.class, new Marshaller<Integer>() {
				
				public Integer marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					if (s == null)
						return -1;
					
					try {
						return Integer.parseInt(s);
					} catch (NumberFormatException e) {
						throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "could not parse integer value: '" + s + "'");
					}
				}

				public Class<?> getType() {
					return int.class;
				}
				
			});	
			
			marshallers.put(User.class, new Marshaller<User>() {
				
				public User marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					if (s == null)
						return null;
					
					IdentitySpider identitySpider = WebEJBUtil.defaultLookup(IdentitySpider.class);
					try {
						return identitySpider.lookupGuidString(User.class, s);
					} catch (ParseException e) {
						throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad userId " + s);
					} catch (NotFoundException e) {
						throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "no such person " + s);
					}
				}

				public Class<?> getType() {
					return User.class;
				}
				
			});	
			
			marshallers.put(Group.class, new Marshaller<Group>() {
				
				public Group marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					if (s == null)
						return null;
					
					GroupSystem groupSystem = WebEJBUtil.defaultLookup(GroupSystem.class);
					try {
						return groupSystem.lookupGroupById(viewpoint, s);
					} catch (NotFoundException e) {
						throw new XmlMethodException(XmlMethodErrorCode.UNKNOWN_GROUP, "Unknown group '" + s + "'");
					}
				}

				public Class<?> getType() {
					return Group.class;
				}
				
			});
			
			marshallers.put(Post.class, new Marshaller<Post>() {
				
				public Post marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					if (s == null)
						return null;
					
					PostingBoard postingBoard = WebEJBUtil.defaultLookup(PostingBoard.class);
					try {
						return postingBoard.loadRawPost(viewpoint, new Guid(s));
					} catch (ParseException e) {
						throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad postId " + s);
					} catch (NotFoundException e) {
						throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "no such post " + s);
					}
				}

				public Class<?> getType() {
					return Post.class;
				}
				
			});
			
			marshallers.put(UserBlockData.class, new Marshaller<UserBlockData>() {
				
				public UserBlockData marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					if (s == null)
						return null;
					
					if (!(viewpoint instanceof UserViewpoint)) {
						throw new XmlMethodException(XmlMethodErrorCode.NOT_LOGGED_IN, "This method requires login");
					}
					
					UserViewpoint userViewpoint = (UserViewpoint) viewpoint;
					
					Stacker stacker = WebEJBUtil.defaultLookup(Stacker.class);
					try {
						return stacker.lookupUserBlockData(userViewpoint, new Guid(s));
					} catch (ParseException e) {
						throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad block id " + s);
					} catch (NotFoundException e) {
						throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "no such block " + s);
					}
				}

				public Class<?> getType() {
					return UserBlockData.class;
				}
				
			});
			
			marshallers.put(EmailResource.class, new Marshaller<EmailResource>() {
				
				public EmailResource marshal(Viewpoint viewpoint, String s) throws XmlMethodException, RetryException {
					if (s == null)
						return null;
					
					IdentitySpider identitySpider = WebEJBUtil.defaultLookup(IdentitySpider.class);
					try {
						return identitySpider.getEmail(s);
					} catch (ValidationException e) {
						throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad email address " + s);
					}
				}

				public Class<?> getType() {
					return EmailResource.class;
				}
				
			});
			
			marshallers.put(URL.class, new Marshaller<URL>() {
				public URL marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					if (s == null)
						return null;
					s = s.trim();
					URL url;
					
					if (s.length() == 0) {
						url = null;
					} else {
						try {
							url = new URL(s);
						} catch (MalformedURLException e) {
							if (!s.startsWith("http://")) {
								// let users type just "example.com" instead of "http://example.com"
								return marshal(viewpoint, "http://" + s);	
							} else {
								throw new XmlMethodException(XmlMethodErrorCode.INVALID_URL, "Invalid URL: '" + s + "'");
							}
						}
					}
					return url;
				}

				public Class<?> getType() {
					return URL.class;
				}
			});
			
			marshallers.put(SharedFile.class, new Marshaller<SharedFile>() {
				public SharedFile marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					if (s == null)
						return null;
					
					SharedFileSystem sharedFileSystem = WebEJBUtil.defaultLookup(SharedFileSystem.class);
					try {
						return sharedFileSystem.lookupFile(viewpoint, new Guid(s), true);
					} catch (ParseException e) {
						throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad file id " + s);
					} catch (NotFoundException e) {
						throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "no such file " + s);
					}
				}

				public Class<?> getType() {
					return SharedFile.class;
				}
			});
			
			marshallers.put(Guid.class, new Marshaller<Guid>() {
				public Guid marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
					if (s == null)
						return null;
					
					try {
						return new Guid(s);
					} catch (ParseException e) {
						throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, e.getMessage());
					}
				}

				public Class<?> getType() {
					return Guid.class;
				}
			});
			
			for (Class<?> iface : interfaces) {
				scanInterface(iface);
			}
			
			lock();
			
			logger.debug("HttpMethodRepository ready with {} methods on {} interfaces", methods.size(), interfaces.length);
		}
		
		Marshaller<?> lookupMarshaller(Class<?> klass) {
			return marshallers.get(klass);
		}
		
		private void scanInterface(Class<?> iface) {
			if (!iface.isInterface())
				throw new IllegalArgumentException("not an interface: " + iface.getName());
			int count = 0;
			logger.debug("Scanning interface {} for http-invokable methods", iface.getName());
			for (Method m : iface.getMethods()) {
				HttpContentTypes contentAnnotation = m.getAnnotation(HttpContentTypes.class);

				if (contentAnnotation == null) {
					logger.debug("  method {} has no content type annotation, skipping", m.getName());
					continue;
				}

				HttpMethod hMethod = new HttpMethod(this, m, contentAnnotation);
				
				methods.put(hMethod.getName(), hMethod);
				count += 1;
			}
			logger.debug("Found {} methods on {}", count, iface.getName());
		}
		
		/*
		private void scanClass(Class<?> klass) {
			Class<?>[] interfaces = klass.getInterfaces();
			for (Class<?> iface : interfaces) {
				scanInterface(iface);
			}
		}
		*/
		
		Collection<HttpMethod> getMethods() {
			return sortedMethods;
		}
		
		HttpMethod lookupMethod(String name) {
			if (sortedMethods == null || lowercaseMethods == null)
				throw new IllegalStateException("need to lock() prior to lookupMethod()");
			if (name == null)
				throw new IllegalArgumentException("null name passed to lookupMethod()");
			
			HttpMethod m = methods.get(name);
			if (m == null)
				m = lowercaseMethods.get(name);
			return m;
		}
		
		private void lock() {
			if (sortedMethods != null)
				throw new IllegalStateException("can only lock() once");
			
			sortedMethods = new ArrayList<HttpMethod>();
			for (HttpMethod m : methods.values()) {
				sortedMethods.add(m);
			}
			Collections.sort(sortedMethods, new Comparator<HttpMethod>() {

				public int compare(HttpMethod a, HttpMethod b) {
					return String.CASE_INSENSITIVE_ORDER.compare(a.getName(), b.getName());
				}
			});
		
			// so we can return it by reference
			sortedMethods = Collections.unmodifiableList(sortedMethods);
			
			// this is really for legacy javascript, studlyCaps can be used 
			// now
			lowercaseMethods = new HashMap<String,HttpMethod>();
			for (HttpMethod m : methods.values()) {
				lowercaseMethods.put(m.getName().toLowerCase(), m);
			}
		}
	}
	
	private interface Marshaller<BoxedArgType>  {
		/**
		 * Marshal from a string parameter to a Java method argument
		 * @param viewpoint viewpoint of the request
		 * @param s string from http request, or null if param not provided
		 * 
		 * @return the marshaled object (null is allowed)
		 * @throws XmlMethodException thrown if the string can't be parsed
		 * @throws RetryException 
		 */
		public BoxedArgType marshal(Viewpoint viewpoint, String s) throws XmlMethodException, RetryException;
		
		/**
		 * Gets the unboxed type that we marshal to; not templatized
		 * since unboxed types can't be used in generics
		 * @return the unboxed type
		 */
		public Class<?> getType();
	}
	
	private static class HttpMethodParam {
		private String name;
		private boolean isOptional;
		private Marshaller<?> marshaller;
		
		public HttpMethodParam(String name, boolean isOptional,
				Marshaller<?> marshaller) {
			this.name = name;
			this.isOptional = isOptional;
			this.marshaller = marshaller;
		}

		public boolean isOptional() {
			return isOptional;
		}
		
		public String getName() {
			return name;
		}
		
		public Marshaller<?> getMarshaller() {
			return marshaller;
		}
	}
	
	private static class HttpMethod {
		
		private String name;
		private boolean requiresPost;
		private List<HttpMethodParam> params;
		private boolean invalidatesSession;
		private boolean needsOutputStream;
		private boolean needsXmlBuilder;
		private boolean needsRequestedContentType;
		private boolean needsViewpoint;
		private boolean needsUserViewpoint;
		private Set<HttpResponseData> contentTypes;
		private boolean adminOnly;
		private boolean allowsDisabledAccount;
		private boolean requiresTransaction;
		private Method method;
		
		HttpMethod(HttpMethodRepository repository, Method m,
				   HttpContentTypes contentAnnotation) {
			
			this.method = m;
			
			HttpParams paramsAnnotation = m.getAnnotation(HttpParams.class);
			HttpOptions optionsAnnotation = m.getAnnotation(HttpOptions.class);
			
			if (paramsAnnotation == null) {
				throw new RuntimeException("missing params annotation on " + m.getName());
			}
		
			if (optionsAnnotation != null) {
				adminOnly = optionsAnnotation.adminOnly();
				allowsDisabledAccount = optionsAnnotation.allowDisabledAccount();
				invalidatesSession = optionsAnnotation.invalidatesSession();
				requiresTransaction = optionsAnnotation.transaction();
			} else {
				requiresTransaction = true;
			}
			
			if (m.getReturnType() != void.class)
				throw new RuntimeException("HTTP method " + m.getName() + " must return void not "
						+ m.getReturnType().getCanonicalName());
			
			String javaName = m.getName();
			if (javaName.startsWith("get")) {
				requiresPost = false;
				name = Character.toLowerCase(javaName.charAt(3)) + javaName.substring(4);
			} else if (javaName.startsWith("do")) {
				requiresPost = true;
				name = Character.toLowerCase(javaName.charAt(2)) + javaName.substring(3);
			} else {
				throw new RuntimeException("http method must start with get or do");
			}
			
			contentTypes = EnumSet.noneOf(HttpResponseData.class);
			for (HttpResponseData r : contentAnnotation.value()) {
				contentTypes.add(r);
			}
			
			if (contentTypes.contains(HttpResponseData.XML) &&
					contentTypes.contains(HttpResponseData.XMLMETHOD)) {
				throw new RuntimeException("Can't return both hand-coded XML and XMLMETHOD style XML from the same API call " + m.getName());
			}
			
			if (contentTypes.isEmpty()) 
				throw new RuntimeException("method has no return types specified " + m.getName());
			
			// lock it down
			contentTypes = Collections.unmodifiableSet(contentTypes);
			
			Class<?> args[] = m.getParameterTypes();
			if (args.length == 0)
				throw new RuntimeException("method doesn't take any args! " + m.getName());

			int i = 0;
			if (OutputStream.class.isAssignableFrom(args[i])) {
				needsOutputStream = true;
				++i;
			} else if (XmlBuilder.class.isAssignableFrom(args[i])) {
				needsXmlBuilder = true;
				++i;
			}
			
			boolean neverReturnsData = contentTypes.size() == 1 &&
				contentTypes.contains(HttpResponseData.NONE); 
			
			if (neverReturnsData && (needsOutputStream || needsXmlBuilder)) {
				throw new RuntimeException("method never returns data but has an output arg " + m.getName());
			}
			if (!neverReturnsData && !(needsOutputStream || needsXmlBuilder)) {
				throw new RuntimeException("method returns data but has no OutputStream or XmlBuilder arg " + m.getName());
			}
			if (!neverReturnsData && allowsDisabledAccount) {
				throw new RuntimeException("account-disabling methods can't return data since the current code will try setting a cookie after the method runs");
			}
			
			if (i < args.length && HttpResponseData.class.isAssignableFrom(args[i])) {
				needsRequestedContentType = true;
				++i;
			}
			
			if (contentTypes.size() > 1 && !needsRequestedContentType) {
				throw new RuntimeException("method must have HttpResponseData arg to specify which content type since it supports multiple " + m.getName()); 
			}
			
			if (i < args.length && Viewpoint.class.isAssignableFrom(args[i])) {
				needsViewpoint = true;
				if (UserViewpoint.class.isAssignableFrom(args[i])) {
					needsUserViewpoint = true;
				}
				++i;
			}
			
			if (args.length != i + paramsAnnotation.value().length) {
				throw new RuntimeException("method " + m.getName() + " should have params " + Arrays.toString(paramsAnnotation.value()));
			}
			
			params = new ArrayList<HttpMethodParam>(paramsAnnotation.value().length);
			
			for (String pname : paramsAnnotation.value()) {
				
				Marshaller<?> marshaller = repository.lookupMarshaller(args[i]);
				if (marshaller == null)
					throw new RuntimeException("don't know how to marshal argument to " + m.getName() + " of type " + args[i].getName());
				
				boolean isOptional = false;
				if (optionsAnnotation != null) {
					String[] optional = optionsAnnotation.optionalParams();
					for (String o : optional) {
						if (o.equals(pname)) {
							isOptional = true;
							break;
						}
					}
				}
				
				params.add(new HttpMethodParam(pname, isOptional, marshaller));
				++i;
			}
			// lock it down
			params = Collections.unmodifiableList(params);
		}

		public String getName() {
			return name;
		}

		public boolean isRequiresPost() {
			return requiresPost;
		}

		public boolean isAdminOnly() {
			return adminOnly;
		}

		public boolean isRequiresTransaction() {
			return requiresTransaction;
		}
		
		public boolean isAllowsDisabledAccount() {
			return allowsDisabledAccount;
		}

		public Set<HttpResponseData> getContentTypes() {
			return contentTypes;
		}

		public boolean isNeedsOutputStream() {
			return needsOutputStream;
		}

		public boolean isNeedsRequestedContentType() {
			return needsRequestedContentType;
		}

		public boolean isNeedsUserViewpoint() {
			return needsUserViewpoint;
		}

		public boolean isNeedsViewpoint() {
			return needsViewpoint;
		}

		public boolean isNeedsXmlBuilder() {
			return needsXmlBuilder;
		}

		public List<HttpMethodParam> getParams() {
			return params;
		}
		
		public Method getMethod() {
			return method;
		}

		public boolean isInvalidatesSession() {
			return invalidatesSession;
		}
	}
	
	public HttpMethodsServlet2() {
	
	}
	
	interface MethodFilter {
		public boolean included(HttpMethod m);
	}
	
	static private void appendIndexSection(XmlBuilder xml,
			String title,
			Collection<HttpMethod> methods,
			MethodFilter filter) {
		xml.appendTextNode("h2", title);
		
		xml.openElement("ul");
		for (HttpMethod m : methods) {
			if (filter.included(m)) {
				xml.openElement("li");
				xml.appendTextNode("a", m.getName(), "href",
						"/api-docs/" + m.getName());
				xml.closeElement();
			}
		}
		xml.closeElement();		
	}
	
	static private void handleApiDocsIndex(HttpServletRequest request,
				HttpServletResponse response) throws HttpException, IOException {
		// probably we should just use a jsp for this sooner or later
		XmlBuilder xml = new XmlBuilder();
		xml.appendHtmlHead("API Index");
		xml.openElement("body");
		
		xml.openElement("div");
		{
			Collection<HttpMethod> methods = getRepository().getMethods();
			appendIndexSection(xml, "Anonymously-callable Methods", methods,
				new MethodFilter() {
					public boolean included(HttpMethod m) {
						return !m.isNeedsUserViewpoint() && !m.isAdminOnly();
					}
			});
			appendIndexSection(xml, "Login-required Methods", methods,
				new MethodFilter() {
					public boolean included(HttpMethod m) {
						return m.isNeedsUserViewpoint() && !m.isAdminOnly();
					}
			});
			appendIndexSection(xml, "Administrator-only Methods", methods,
				new MethodFilter() {
					public boolean included(HttpMethod m) {
						return m.isAdminOnly();
					}
			});
		}
		xml.closeElement();
 	
		xml.closeElement();
		
		response.setContentType("text/html");
		response.getOutputStream().write(xml.getBytes());
	}

	static private void handleApiDocsMethod(HttpServletRequest request,
			HttpServletResponse response, String methodName) throws HttpException, IOException {
		HttpMethod method = getRepository().lookupMethod(methodName);
		if (method == null)
			throw new HttpException(HttpResponseCode.NOT_FOUND, "Unknown API method '" + methodName + "'");

		// probably we should just use a jsp for this sooner or later
		XmlBuilder xml = new XmlBuilder();
		xml.appendHtmlHead("API method " + method.getName());
		xml.openElement("body");

		{
			xml.openElement("div");
			
			xml.appendTextNode("h2", method.getName());
			
			{
				xml.openElement("div");			
				String getOrPost;
				if (method.isRequiresPost())
					getOrPost = "Only works with POST, not GET";
				else
					getOrPost = "Works with GET or POST";
				xml.appendTextNode("i", getOrPost);
				xml.closeElement();
			}
 
			{
				xml.openElement("div");			
				String login;
				if (method.isNeedsUserViewpoint())
					login = "Requires a login cookie (cannot be used anonymously)";
				else
					login = "Works anonymously (login cookie need not be provided)";
				xml.appendTextNode("i", login);
				xml.closeElement();
			}
			
			if (method.isAdminOnly()) {
				xml.openElement("div");			
				xml.appendTextNode("i", "Requires administrator privileges");
				xml.closeElement();
			}
			
			{
				xml.openElement("div");
				xml.appendTextNode("h3", "Parameters");
				
				xml.openElement("ul");
				for (HttpMethodParam p : method.getParams()) {
					xml.openElement("li");
					xml.append(p.getName());
					
					xml.append(" - ");
					
					xml.append(p.getMarshaller().getType().getName());
					
					if (p.isOptional()) {
						xml.appendTextNode("i", " - (optional)");
					}
				}
				xml.closeElement();
				
				xml.closeElement();
			}
			
		
			xml.closeElement();
		}
		
		xml.closeElement();
		
		response.setContentType("text/html");
		response.getOutputStream().write(xml.getBytes());		
	}
	
	static private String arrayToStringXmlBuilderWorkaround(Object[] array) {
		// XmlBuilder.toString() has kind of a broken side effect of closing the XML document, 
		// so we can't use Arrays.toString()
		StringBuilder sb = new StringBuilder();
		sb.append("{");
		for (Object arg : array) {
			if (arg instanceof XmlBuilder)
				sb.append("XmlBuilder");
			else
				sb.append(arg != null ? arg.toString() : "null");
			sb.append(", ");
		}
		if (array.length > 0)
			sb.setLength(sb.length() - 2); // delete last comma
		sb.append("}");
		return sb.toString();
	}

	/**
	 * This should run the method and write successful output to the output stream.
     *
	 * The caller will write any exception to the output stream, so this method
	 * should just throw the exception (as an XmlMethodException).
	 * 
	 * The caller should take care of side effects and sanity checks, this 
	 * method should only marshal args and run the method. This method does
	 * do some checks that relate to args, such as those that use the viewpoint.
	 * 
	 * Note that checks for the validity of the method itself and its annotations
	 * should be done when the method is initially loaded, not now on each invocation.
	 * 
	 * @param m
	 * @param requestedContentType
	 * @param request
	 * @param response
	 * @throws IOException
	 * @throws XmlMethodException
	 * @throws RetryException 
	 */
	static private void invokeMethod(HttpMethod m, HttpResponseData requestedContentType, HttpServletRequest request, HttpServletResponse response) throws IOException, XmlMethodException, RetryException {
		Class<?> iface = m.getMethod().getDeclaringClass();
		Object instance = WebEJBUtil.defaultLookup(iface);
		Method javaMethod = m.getMethod();
		
		OutputStream out = response.getOutputStream();

		if (requestedContentType != HttpResponseData.NONE)
			response.setContentType(requestedContentType.getMimeType());
		
		SigninBean signin = SigninBean.getForRequest(request);
		UserViewpoint userViewpoint = null;
		
		// if the method doesn't specifically allow disabled accounts, we treat 
		// the request as anonymous instead of as from a user viewpoint
		
		if ((m.isAllowsDisabledAccount() || signin.isActive()) && signin instanceof UserSigninBean)
			userViewpoint = ((UserSigninBean)signin).getViewpoint();
		
		if (userViewpoint == null && m.isNeedsUserViewpoint()) {
			throw new XmlMethodException(XmlMethodErrorCode.NOT_LOGGED_IN, "You need to be signed in to call this");
		}
		
		if (m.isAdminOnly()) {
			IdentitySpider spider = EJBUtil.defaultLookup(IdentitySpider.class);
			if (userViewpoint == null || !spider.isAdministrator(userViewpoint.getViewer()))
				throw new XmlMethodException(XmlMethodErrorCode.FORBIDDEN, "You need administrator privileges to do this");
		}
		
		Viewpoint viewpoint = null;
		if (userViewpoint != null)
			viewpoint = userViewpoint;
		else
			viewpoint = AnonymousViewpoint.getInstance(signin.getSite());
		
		// FIXME allow an XmlBuilder arg instead of output stream for 
		// HttpResponseData.XML as well as XMLMETHOD
		XmlBuilder xml = null;
		if (m.isNeedsXmlBuilder() && requestedContentType == HttpResponseData.XMLMETHOD) {
			xml = new XmlBuilder();
			xml.appendStandaloneFragmentHeader();
			xml.openElement("rsp", "stat", "ok");
		}
		
		int argc = m.getParams().size();
		if (m.isNeedsOutputStream())
			++argc;
		if (m.isNeedsXmlBuilder())
			++argc;
		if (m.isNeedsRequestedContentType())
			++argc;
		if (m.isNeedsViewpoint())
			++argc;
		
		Object[] argv = new Object[argc];
		int i = 0;
		if (m.isNeedsOutputStream()) {
			argv[i] = out;
			++i;
		} 
		
		if (m.isNeedsXmlBuilder()) {
			// note xml may just be null here e.g. if a method supports both 
			// XMLMETHOD and NONE
			argv[i] = xml;
			++i;
		}
		
		if (m.isNeedsRequestedContentType()) {
			argv[i] = requestedContentType;
			++i;
		}
		
		if (m.isNeedsViewpoint()) {
			argv[i] = viewpoint;
			++i;
		}
		
		for (HttpMethodParam param : m.getParams()) {
			String value = request.getParameter(param.getName());
			if (value == null && !param.isOptional()) {
				throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT,
						"Parameter " + param.getName() + " is required");
			}
			// if value is null for an optional param, the marshaller is supposed
			// to pass it through appropriately.
			argv[i] = param.getMarshaller().marshal(viewpoint, value);
			++i;
		}
		
		try {
			if (logger.isDebugEnabled()) {						
				String showArgs = arrayToStringXmlBuilderWorkaround(argv);
				// suppress plaintext password from appearing in log
				if (javaMethod.getName().equals("setPassword"))
					showArgs = "[SUPPRESSED FROM LOG]";
				logger.debug("Invoking method {} with args {}", javaMethod.getName(), showArgs);
			}
			javaMethod.invoke(instance, argv);
		} catch (IllegalArgumentException e) {
			logger.error("invoking method on http methods bean, illegal argument", e);
			throw new XmlMethodException(XmlMethodErrorCode.INTERNAL_SERVER_ERROR, "Internal server error");
		} catch (IllegalAccessException e) {
			logger.error("invoking method on http methods bean, illegal access", e);
			throw new XmlMethodException(XmlMethodErrorCode.INTERNAL_SERVER_ERROR, "Internal server error");
		} catch (InvocationTargetException e) {
			Throwable cause = e.getCause();
			Throwable rootCause = ExceptionUtils.getRootCause(e);
			
			WebStatistics.getInstance().incrementHttpMethodErrors();
			
			// HumanVisibleException is a legacy thing; it's useless 
			// from an http method since it redirects to the error page.
			// We convert it to XmlMethodException
			if (cause instanceof HumanVisibleException) {
				HumanVisibleException visibleException = (HumanVisibleException) cause;
				throw new XmlMethodException(XmlMethodErrorCode.FAILED, visibleException.getMessage());
			} else if (cause instanceof XmlMethodException){
				XmlMethodException methodException = (XmlMethodException) cause;
				throw methodException;
			} else if (cause instanceof IOException) {
				IOException ioException = (IOException) cause;
				throw ioException;
			} else if (cause instanceof RetryException) {
				RetryException retryException = (RetryException) cause;
				throw retryException;
			} else {
				logger.error("Exception root cause is {} message: {}", rootCause.getClass().getName(), rootCause.getMessage());
				logger.error("invoking method on http methods bean, unexpected", e);
				throw new XmlMethodException(XmlMethodErrorCode.INTERNAL_SERVER_ERROR, "Internal server error");				
			}
		}
		
		if (xml != null) {
			// content type already set at the top of the method
			xml.closeElement();
			byte[] bytes = xml.getBytes();
			response.setContentLength(bytes.length);
			out.write(bytes);
		}

		// The point of the allowDisabledUser annotations is to allow methods that
		// take a user from the disabled state to the enabled state; once we are
		// enabled, we need to persistant cookies, so we check that here. This
		// is a little hacky, but simpler than creating custom servlets.
		//
		// Note that this won't work well with methods that have write
		// output, since output may have already been written, and it
		// will be too late to set cookies. So we disallow that when first 
		// scanning the method.
		if (m.isAllowsDisabledAccount()) {
			SigninBean.updateAuthentication(request, response);
		}
		
		logger.debug("Finishing generating reply for {}", m.getName());
	}
	
	static private void writeXmlMethodError(HttpServletResponse response, String code, String message) throws IOException {
		XmlBuilder xml = new XmlBuilder();
		xml.appendStandaloneFragmentHeader();
		xml.openElement("rsp", "stat", "fail");
		xml.appendTextNode("err", "", "code", code, "msg", message);
		xml.closeElement();
		response.setContentType(HttpResponseData.XMLMETHOD.getMimeType());
		byte[] bytes = xml.getBytes();
		response.setContentLength(bytes.length);
		OutputStream out = response.getOutputStream();
		out.write(bytes);
		out.flush();
		out.close();
	}
	
	static private void writeXmlMethodError(HttpServletResponse response, XmlMethodException exception) throws IOException {
		writeXmlMethodError(response, exception.getCodeString(), exception.getMessage());
	}
	
	private interface RequestHandler {
		public boolean getNoCache();
		public boolean getRequiresTransaction();
		public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost)
			throws HttpException, IOException, RetryException;
		public boolean isReadWrite();
	}
	
	private static class HttpMethodRequestHandler implements RequestHandler {

		private String typeDir;
		private HttpMethod method;
		
		HttpMethodRequestHandler(String typeDir, HttpMethod method) {
			this.typeDir = typeDir;
			this.method = method;
		}
		
		public boolean getNoCache() {
			return true;
		}
		
		public boolean getRequiresTransaction() {
			return method.isRequiresTransaction();
		}
		
		public boolean isReadWrite() {
			return method.isRequiresPost();
		}

		public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost) throws HttpException, IOException, RetryException {
			HttpResponseData requestedContentType;
			if (typeDir.equals("xml")) {
				// gets overwritten with XMLMETHOD later if appropriate
				requestedContentType = HttpResponseData.XML;
			} else if (typeDir.equals("text"))
				requestedContentType = HttpResponseData.TEXT;
			else if (isPost && typeDir.equals("action"))
				requestedContentType = HttpResponseData.NONE;
			else {
				throw new HttpException(HttpResponseCode.NOT_FOUND,
						"Don't know about URI path /" + typeDir + " , only /xml, /text for GET plus /action for POST only)");
			}

			if (requestedContentType == HttpResponseData.XML && 
					method.getContentTypes().contains(HttpResponseData.XMLMETHOD))
				requestedContentType = HttpResponseData.XMLMETHOD;
			
			if (!method.getContentTypes().contains(requestedContentType)) {
				throw new HttpException(HttpResponseCode.NOT_FOUND, "Wrong content type requested "
						+ requestedContentType + " valid types for method are " + method.getContentTypes());
			}
			
			if (!isPost && method.isRequiresPost())
				throw new HttpException(HttpResponseCode.BAD_REQUEST, "Method only works via POST not GET");
			
			try {
				invokeMethod(method, requestedContentType, request, response);
			} catch (XmlMethodException e) {
				WebStatistics.getInstance().incrementHttpMethodErrors();
				
				if (requestedContentType == HttpResponseData.XMLMETHOD) {
					writeXmlMethodError(response, e);
					return;
				} else {
					throw new HttpException(HttpResponseCode.BAD_REQUEST,
							e.getCodeString() + ": " + e.getMessage());
				}
			}
			
			////// Note that we always throw or return on exception... so this code 
			////// runs only on method success
			WebStatistics.getInstance().incrementHttpMethodsServed();
			
			if (method.isInvalidatesSession()) {
				HttpSession sess = request.getSession(false);
				if (sess != null)
					sess.invalidate();	
			}			
		}
	}
	
	private String[] parseUri(String uri) {
		
		if (uri.length() < 4) { // "/a/b".length() == 4
			logger.debug("URI is too short to be valid, should be of the form /typeprefix/methodname, e.g. /xml/frobate");
			return null;
		}
		
		// ignore trailing /
		if (uri.endsWith("/"))
			uri = uri.substring(0, uri.length()-1);
		
		// split into the two components, the result is { "", "xml", "frobate" }
		String[] ret = uri.split("/");
		if (ret.length != 3) {
			logger.debug("All URIs are of the form /typeprefix/methodname, e.g. /xml/frobate, split into: " + Arrays.toString(ret));
			return null;
		}
		
		return new String[] { ret[1], ret[2] };
	}
	
	private RequestHandler tryHttpRequest(HttpServletRequest request) {
		String requestUri = request.getRequestURI(); 
		String[] uriComponents = parseUri(requestUri);
		if (uriComponents == null)
			return null;
		String requestedMethod = uriComponents[1];

		HttpMethodRepository repo = getRepository();
		HttpMethod m = repo.lookupMethod(requestedMethod);
		if (m == null)
			return null;
		else
			return new HttpMethodRequestHandler(uriComponents[0], m);
	}
	
	private RequestHandler tryLoginRequests(HttpServletRequest request) {
		if (!(request.getRequestURI().equals("/text/dologin") &&
				request.getMethod().toUpperCase().equals("POST"))
				&& !request.getRequestURI().equals("/text/checklogin")) {
			return null;
		} else {
			return new RequestHandler() {
				public boolean getNoCache() {
					return true;
				}				
				
				public boolean getRequiresTransaction() {
					return true;
				}

				public boolean isReadWrite() {
					return true;
				}
				
				public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost)
					throws HttpException, IOException {
					// special method that magically causes us to look at your cookie and log you 
					// in if it's set, then return person you're logged in as or "false"		
						
					User user = getUser(request);
						
					response.setContentType("text/plain");
					OutputStream out = response.getOutputStream();
					if (user != null)
						out.write(user.getId().getBytes());
					else
						out.write("false".getBytes());
					out.flush();					
				}
			};
		}
	}

	private RequestHandler trySignoutRequest(HttpServletRequest request) {
		if (!request.getRequestURI().equals("/action/signout") ||
			!request.getMethod().toUpperCase().equals("POST")) {
			return null;
		} else {
			return new RequestHandler() {
				public boolean getNoCache() {
					return true;
				}
				
				public boolean getRequiresTransaction() {
					return false;
				}

				public boolean isReadWrite() {
					return false;
				}

				public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost)
					throws HttpException, IOException {
					HttpSession session = request.getSession();
					if (session != null)
						session.invalidate();		
					
					// FIXME we need to drop the Client object when we do this,
					// both to save our own disk space, and in case someone stole the 
					// cookie.
					
					response.addCookie(LoginCookie.newDeleteCookie());
					response.addCookie(LoginCookie.newDeleteAuthenticatedCookie());
				}
			};
		}
	}

	private RequestHandler tryApiDocs(HttpServletRequest request) {
		String requestUri = request.getRequestURI();
	
		if (requestUri.equals("/api-docs"))
			requestUri = "/api-docs/";
	
		if (!requestUri.startsWith("/api-docs/"))
			return null;
	
		final String docsOn = requestUri.substring("/api-docs/".length());

		return new RequestHandler() {

			public boolean getNoCache() {
				return false;
			}
			
			public boolean getRequiresTransaction() {
				return false;
			}
			
			public boolean isReadWrite() {
				return false;
			}

			public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost)
				throws HttpException, IOException {
				if (docsOn.length() == 0) {
					handleApiDocsIndex(request, response);
				} else {
					handleApiDocsMethod(request, response, docsOn);
				}
			}
			
		};
	}
	
	@Override
	public void init() throws ServletException {
		// call this for side effect of loading methods so we get 
		// any errors on startup
		getRepository();
	} 
	
	private void doRequest(HttpServletRequest request, HttpServletResponse response, boolean isPost)
		throws HttpException, IOException, RetryException {
		RequestHandler handler = getRequestHandler(request);
		if (handler == null) {
			logger.debug("Found no handler for url '{}'", request.getRequestURI());
			throw new HttpException(HttpResponseCode.NOT_FOUND, "unknown URI");
		}
		if (handler.getNoCache())
			setNoCache(response);
		handler.handle(request, response, isPost);
	}
	
	@Override
	protected String wrappedDoPost(HttpServletRequest request, HttpServletResponse response) throws HttpException,
			IOException, RetryException {
		
		doRequest(request, response, true);

		return null;
	}
	
	@Override
	protected String wrappedDoGet(HttpServletRequest request, HttpServletResponse response) throws HttpException, IOException, RetryException {

		doRequest(request, response, false);
		
		return null;
	}

	// the idea is to do the "request analysis" only once 
	private RequestHandler getRequestHandler(HttpServletRequest request) {
		RequestHandler handler = (RequestHandler)request.getAttribute("request-handler");
		if (handler != null)
			return handler;
		
		boolean isPost = request.getMethod().toUpperCase().equals("POST");
		if (isPost) {
			if (handler == null)
				handler = tryLoginRequests(request);
			if (handler == null)
				handler = trySignoutRequest(request);
		} else {
			if (handler == null)
				handler = tryApiDocs(request);
		}
		if (handler == null)
			handler = tryHttpRequest(request);
		
		if (handler != null) {
			request.setAttribute("request-handler", handler);
			return handler;
		} else {
			return null;
		}
	}
	
	@Override
	protected boolean requiresTransaction(HttpServletRequest request) {
		RequestHandler handler = getRequestHandler(request);
		
		if (handler != null) {
			return handler.getRequiresTransaction();
		} else {
			// we're going to throw an error later
			return false;
		}
	}
	
	@Override
	protected boolean isReadWrite(HttpServletRequest request) {
		RequestHandler handler = getRequestHandler(request);
		
		return handler.isReadWrite();
	}
}