/*
 * Copyright (C) 2012 Issa Gorissen <issa-gorissen@usa.net>. All rights reserved.
 *
 * 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 org.jivesoftware.openfire.crowd;

import java.io.IOException;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLEncoder;
import java.rmi.RemoteException;
import java.util.ArrayList;
import java.util.List;

import javax.xml.bind.JAXB;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xmpp.packet.JID;
import org.apache.commons.httpclient.Credentials;
import org.apache.commons.httpclient.Header;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpMethod;
import org.apache.commons.httpclient.HttpState;
import org.apache.commons.httpclient.MultiThreadedHttpConnectionManager;
import org.apache.commons.httpclient.UsernamePasswordCredentials;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.methods.PostMethod;
import org.apache.commons.httpclient.methods.StringRequestEntity;
import org.apache.commons.httpclient.params.HttpClientParams;
import org.apache.commons.httpclient.params.HttpConnectionManagerParams;
import org.apache.commons.lang.StringUtils;

import org.jivesoftware.openfire.crowd.jaxb.AuthenticatePost;
import org.jivesoftware.openfire.crowd.jaxb.Group;
import org.jivesoftware.openfire.crowd.jaxb.Groups;
import org.jivesoftware.openfire.crowd.jaxb.User;
import org.jivesoftware.openfire.crowd.jaxb.Users;



public class CrowdManager {
	private static final Logger LOG = LoggerFactory.getLogger(CrowdManager.class);
	private static final Object O = new Object();
	private static final String APPLICATION_XML = "application/xml";
	private static final Header HEADER_ACCEPT_APPLICATION_XML = new Header("Accept", APPLICATION_XML);
	private static final Header HEADER_ACCEPT_CHARSET_UTF8 = new Header("Accept-Charset", "UTF-8");

	private static CrowdManager INSTANCE;
	
	private HttpClient client;
	private URI crowdServer;

	public static CrowdManager getInstance() {
		if (INSTANCE == null) {
			synchronized (O) {
				if (INSTANCE == null) {
					CrowdManager manager = new CrowdManager();
					if (manager != null)
						INSTANCE = manager;
				}
			}
		}
		return INSTANCE;
	}
	
	private CrowdManager() {
		try {
			// loading crowd.properties file
			CrowdProperties crowdProps = new CrowdProperties();
			
			MultiThreadedHttpConnectionManager threadedConnectionManager = new MultiThreadedHttpConnectionManager();
			HttpClient hc = new HttpClient(threadedConnectionManager);
	
			HttpClientParams hcParams = hc.getParams();
			hcParams.setAuthenticationPreemptive(true);
			
			HttpConnectionManagerParams hcConnectionParams = hc.getHttpConnectionManager().getParams();
			hcConnectionParams.setDefaultMaxConnectionsPerHost(crowdProps.getHttpMaxConnections());
			hcConnectionParams.setMaxTotalConnections(crowdProps.getHttpMaxConnections());
			hcConnectionParams.setConnectionTimeout(crowdProps.getHttpConnectionTimeout());
			hcConnectionParams.setSoTimeout(crowdProps.getHttpSocketTimeout());
			
			crowdServer = new URI(crowdProps.getCrowdServerUrl()).resolve("rest/usermanagement/latest/");
			
			// setting BASIC authentication in place for connection with Crowd
			HttpState httpState = hc.getState();
			Credentials crowdCreds = new UsernamePasswordCredentials(crowdProps.getApplicationName(), crowdProps.getApplicationPassword());
			httpState.setCredentials(new AuthScope(crowdServer.getHost(), crowdServer.getPort()), crowdCreds);
			
			// setting Proxy config in place if needed
			if (StringUtils.isNotBlank(crowdProps.getHttpProxyHost()) && crowdProps.getHttpProxyPort() > 0) {
				hc.getHostConfiguration().setProxy(crowdProps.getHttpProxyHost(), crowdProps.getHttpProxyPort());
				
				if (StringUtils.isNotBlank(crowdProps.getHttpProxyUsername()) || StringUtils.isNotBlank(crowdProps.getHttpProxyPassword())) {
					Credentials proxyCreds = new UsernamePasswordCredentials(crowdProps.getHttpProxyUsername(), crowdProps.getHttpProxyPassword());
					httpState.setProxyCredentials(new AuthScope(crowdProps.getHttpProxyHost(), crowdProps.getHttpProxyPort()), proxyCreds);
				}
			}
			
			if (LOG.isDebugEnabled()) {
				LOG.debug("HTTP Client config");
				LOG.debug(crowdServer.toString());
				LOG.debug("Max connections:" + hcConnectionParams.getMaxTotalConnections());
				LOG.debug("Socket timeout:" + hcConnectionParams.getSoTimeout());
				LOG.debug("Connect timeout:" + hcConnectionParams.getConnectionTimeout());
				LOG.debug("Proxy host:" + crowdProps.getHttpProxyHost() + ":" + crowdProps.getHttpProxyPort());
				LOG.debug("Crowd application name:" + crowdProps.getApplicationName());
			}
			
			client = hc;
		} catch (Exception e) {
			LOG.error("Failure to load the Crowd manager", e);
		}
	}
	
	
	
	/**
	 * Authenticates a user with crowd. If authentication failed, raises a <code>RemoteException</code>
	 * @param username
	 * @param password
	 * @throws RemoteException
	 */
	public void authenticate(String username, String password) throws RemoteException {
		username = JID.unescapeNode(username);
		if (LOG.isDebugEnabled()) LOG.debug("authenticate '" + String.valueOf(username) + "'");
		
		PostMethod post = new PostMethod(crowdServer.resolve("authentication?username=" + urlEncode(username)).toString());
		
		AuthenticatePost creds = new AuthenticatePost();
		creds.value = password;
		try {
			StringWriter writer = new StringWriter();
			JAXB.marshal(creds, writer);
			post.setRequestEntity(new StringRequestEntity(writer.toString(), APPLICATION_XML, "UTF-8"));
			
			int httpCode = client.executeMethod(post);
			if (httpCode != 200) {
				handleHTTPError(post);
			}
			
		} catch (IOException ioe) {
			handleError(ioe);
		} finally {
			post.releaseConnection();
		}
		
		LOG.info("authenticated user:" + username);
	}
	
	
	/**
	 * Get all the users from Crowd
	 * @return a List of User containing all the users stored in Crowd
	 * @throws RemoteException
	 */
	public List<User> getAllUsers() throws RemoteException {
		if (LOG.isDebugEnabled()) LOG.debug("fetching all crowd users");
		
		int maxResults = 100;
		int startIndex = 0;
		List<User> results = new ArrayList<>();
		StringBuilder request = new StringBuilder("search?entity-type=user&expand=user&restriction=active%3dtrue")
			.append("&max-results=").append(maxResults)
			.append("&start-index=");
		
		try {
			while (true) {
				GetMethod get = createGetMethodXmlResponse(crowdServer.resolve(request.toString() + startIndex));
				Users users = null;
				
				try {
					int httpCode = client.executeMethod(get);
					if (httpCode != 200) {
						handleHTTPError(get);
					}
					users = JAXB.unmarshal(get.getResponseBodyAsStream(), Users.class);
				} finally {
					get.releaseConnection();
				}
				
				if (users != null && users.user != null) {
					for (User user : users.user) {
						user.name = JID.escapeNode(user.name);
						results.add(user);
					}
					
					if (users.user.size() != maxResults) {
						break;
					} else {
						startIndex += maxResults;
					}
				} else {
					break;
				}
			}
			
		} catch (IOException ioe) {
			handleError(ioe);
		}
		
		return results;
	}
	
	
	/**
	 * Get all the crowd groups
	 * @return a List of group names
	 * @throws RemoteException
	 */
	public List<String> getAllGroupNames() throws RemoteException {
		if (LOG.isDebugEnabled()) LOG.debug("fetch all crowd groups");

		int maxResults = 100;
		int startIndex = 0;
		List<String> results = new ArrayList<>();
		StringBuilder request = new StringBuilder("search?entity-type=group&restriction=active%3dtrue")
			.append("&max-results=").append(maxResults)
			.append("&start-index=");
		
		try {
			while (true) {
				GetMethod get = createGetMethodXmlResponse(crowdServer.resolve(request.toString() + startIndex));
				Groups groups = null;
				
				try {
					int httpCode = client.executeMethod(get);
					if (httpCode != 200) {
						handleHTTPError(get);
					}
					groups = JAXB.unmarshal(get.getResponseBodyAsStream(), Groups.class);
				} finally {
					get.releaseConnection();
				}
				
				if (groups != null && groups.group != null) {
					for (Group group : groups.group) {
						results.add(group.name);
					}
					
					if (groups.group.size() != maxResults) {
						break;
					} else {
						startIndex += maxResults;
					}
				} else {
					break;
				}
			}
			
		} catch (IOException ioe) {
			handleError(ioe);
		}
		
		return results;
	}
	
	
	/**
	 * Get all the groups of a given username
	 * @param username
	 * @return a List of groups name
	 * @throws RemoteException
	 */
	public List<String> getUserGroups(String username) throws RemoteException {
		username = JID.unescapeNode(username);
		if (LOG.isDebugEnabled()) LOG.debug("fetch all crowd groups for user:" + username);
		
		int maxResults = 100;
		int startIndex = 0;
		List<String> results = new ArrayList<>();
		StringBuilder request = new StringBuilder("user/group/nested?username=").append(urlEncode(username))
			.append("&max-results=").append(maxResults)
			.append("&start-index=");
		
		try {
			while (true) {
				GetMethod get = createGetMethodXmlResponse(crowdServer.resolve(request.toString() + startIndex));
				Groups groups = null;
				
				try {
					int httpCode = client.executeMethod(get);
					if (httpCode != 200) {
						handleHTTPError(get);
					}
					groups = JAXB.unmarshal(get.getResponseBodyAsStream(), Groups.class);
				} finally {
					get.releaseConnection();
				}
				
				if (groups != null && groups.group != null) {
					for (Group group : groups.group) {
						results.add(group.name);
					}
					
					if (groups.group.size() != maxResults) {
						break;
					} else {
						startIndex += maxResults;
					}
				} else {
					break;
				}
			}
			
		} catch (IOException ioe) {
			handleError(ioe);
		}
		
		return results;
	}
	
	
	/**
	 * Get the description of a group from crowd
	 * @param groupName
	 * @return a Group object
	 * @throws RemoteException
	 */
	public Group getGroup(String groupName) throws RemoteException {
		if (LOG.isDebugEnabled()) LOG.debug("Get group:" + groupName + " from crowd");
		
		GetMethod get = createGetMethodXmlResponse(crowdServer.resolve("group?groupname=" + urlEncode(groupName)));
		Group group = null;
		
		try {
			int httpCode = client.executeMethod(get);
			if (httpCode != 200) {
				handleHTTPError(get);
			}
			
			group = JAXB.unmarshal(get.getResponseBodyAsStream(), Group.class);
			
		} catch (IOException ioe) {
			handleError(ioe);
		} finally {
			get.releaseConnection();
		}
		
		return group;
	}
	
	
	/**
	 * Get the members of the given group
	 * @param groupName
	 * @return a List of String with the usernames members of the given group
	 * @throws RemoteException
	 */
	public List<String> getGroupMembers(String groupName) throws RemoteException {
		if (LOG.isDebugEnabled()) LOG.debug("Get all members for group:" + groupName);
		
		int maxResults = 100;
		int startIndex = 0;
		List<String> results = new ArrayList<>();
		StringBuilder request = new StringBuilder("group/user/nested?groupname=").append(urlEncode(groupName))
			.append("&max-results=").append(maxResults)
			.append("&start-index=");
		
		try {
			while (true) {
				GetMethod get = createGetMethodXmlResponse(crowdServer.resolve(request.toString() + startIndex));
				Users users = null;
				
				try {
					int httpCode = client.executeMethod(get);
					if (httpCode != 200) {
						handleHTTPError(get);
					}
					users = JAXB.unmarshal(get.getResponseBodyAsStream(), Users.class);
				} finally {
					get.releaseConnection();
				}
				
				if (users != null && users.user != null) {
					for (org.jivesoftware.openfire.crowd.jaxb.User user : users.user) {
						results.add(JID.escapeNode(user.name));
					}
					
					if (users.user.size() != maxResults) {
						break;
					} else {
						startIndex += maxResults;
					}
				} else {
					break;
				}
			}
			
		} catch (IOException ioe) {
			handleError(ioe);
		}
		
		return results;
	}
	
	private String urlEncode(String str) {
		try {
			return URLEncoder.encode(str, "UTF-8");
		} catch (UnsupportedEncodingException uee) {
			LOG.error("UTF-8 not supported ?", uee);
			return str;
		}
	}
	
	
	private void handleHTTPError(HttpMethod method) throws RemoteException {
		int status = method.getStatusCode();
		String statusText = method.getStatusText();
		String body = null;
		try {
			body = method.getResponseBodyAsString();
		} catch (IOException ioe) {
			LOG.warn("Unable to retreive Crowd http response body", ioe);
		}
		
		StringBuilder strBuf = new StringBuilder();
		strBuf.append("Crowd returned HTTP error code:").append(status);
		strBuf.append(" - ").append(statusText);
		if (StringUtils.isNotBlank(body)) {
			strBuf.append("\n").append(body);
		}
		
		throw new RemoteException(strBuf.toString());
	}
	
	private void handleError(Exception e) throws RemoteException {
		LOG.error("Error occured while consuming Crowd REST service", e);
		throw new RemoteException(e.getMessage());
	}
	
	private GetMethod createGetMethodXmlResponse(URI uri) {
		GetMethod get = new GetMethod(uri.toString());
		get.addRequestHeader(HEADER_ACCEPT_APPLICATION_XML);
		get.addRequestHeader(HEADER_ACCEPT_CHARSET_UTF8);
		return get;
	}

}
