/*
 * 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;
    }

}
