package com.pmease.quickbuild.plugin.authenticator.ldap;

import java.util.ArrayList;
import java.util.Hashtable;
import java.util.List;
import java.util.Stack;

import javax.naming.AuthenticationException;
import javax.naming.CompositeName;
import javax.naming.Context;
import javax.naming.InvalidNameException;
import javax.naming.Name;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.PartialResultException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.validation.constraints.NotNull;

import org.hibernate.validator.constraints.NotEmpty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.pmease.quickbuild.QuickbuildException;
import com.pmease.quickbuild.annotation.Editable;
import com.pmease.quickbuild.annotation.Password;
import com.pmease.quickbuild.bootstrap.Bootstrap;
import com.pmease.quickbuild.migration.VersionedDocument;
import com.pmease.quickbuild.security.AuthenticationResult;
import com.pmease.quickbuild.security.Authenticator;
import com.pmease.quickbuild.security.TrustAllSSLSocketFactory;
import com.pmease.quickbuild.util.StringUtils;

//TODO: 1. Add Active Directory authenticator based on a customization of the generic LDAP 
// authenticator.
// 2. Documentation on how to connect to ldaps protocol.
@Editable(name="LDAP", order=200)
public class LdapAuthenticator extends Authenticator {

	private static final long serialVersionUID = 1L;

	private static final Logger logger = LoggerFactory.getLogger(LdapAuthenticator.class);
	
    private String ldapUrl;

    private boolean followReferrals = true;
    
    private String bindUser;
    
    private String bindPassword;
    
    private String userSearchBase;
    
    private String userSearchFilter;
    
    private String userFullNameAttribute;
    
    private String userEmailAttribute;
    
    private GroupRetrievalStrategy groupRetrievalStrategy;
    
    @Editable(order=100, name="LDAP URL", description=
    	"Specifies LDAP URL, for example: <b>ldap://localhost:389</b>, or <b>ldaps://localhost:636</b>.")
    @NotEmpty
	public String getLdapUrl() {
		return ldapUrl;
	}

	public void setLdapUrl(String ldapUrl) {
		this.ldapUrl = ldapUrl;
	}

	@Editable(order=200, description=
		"Specifies whether or not to follow referrals when search in this LDAP server. " +
		"Consult your LDAP administrator for detail information about referral. Leave " +
		"it as checked is approriate for most cases.")
	public boolean isFollowReferrals() {
		return followReferrals;
	}

	public void setFollowReferrals(boolean followReferrals) {
		this.followReferrals = followReferrals;
	}

	@Editable(order=300, description=
		"Specifies the binding user DN in order to perform LDAP searches. If this property " +
		"is left as empty, QuickBuild will try to bind anonymously to perform LDAP searches.<br>" +
        "<strong>NOTE:</strong> <b>{0}</b> contained in this property will be replaced by " +
        "name of the login user.")
	public String getBindUser() {
		return bindUser;
	}

	public void setBindUser(String bindUser) {
		this.bindUser = bindUser;
	}

	@Editable(order=400, description=
		"Specifies password in order to bind as above DN.<br>" + 
		"<strong>NOTE:</strong> To specify password of the login user, just use <b>{0}</b>.")
	@Password
	public String getBindPassword() {
		return bindPassword;
	}

	public void setBindPassword(String bindPassword) {
		this.bindPassword = bindPassword;
	}

	@Editable(order=500, description=
		"Specifies the base node for user search. For example: <b>ou=users, dc=example, dc=com</b>")
	@NotEmpty
	public String getUserSearchBase() {
		return userSearchBase;
	}

	public void setUserSearchBase(String userSearchBase) {
		this.userSearchBase = userSearchBase;
	}

	@Editable(order=600, description=
	     "This filter is used to determine the LDAP entry for current user. " + 
	     "For example: <b>(&(uid={0})(objectclass=person))</b>. In this example, " +
	     "<b>{0}</b> represents login name of current user.")
	@NotEmpty
	public String getUserSearchFilter() {
		return userSearchFilter;
	}

	public void setUserSearchFilter(String userSearchFilter) {
		this.userSearchFilter = userSearchFilter;
	}

	@Editable(order=700, description=
		"Optionally specifies name of the attribute inside the user entry whose value will " +
		"be taken as user full name. If left empty, full name of the user will not be " +
		"retrieved.")
	public String getUserFullNameAttribute() {
		return userFullNameAttribute;
	}

	public void setUserFullNameAttribute(String userFullNameAttribute) {
		this.userFullNameAttribute = userFullNameAttribute;
	}

	@Editable(order=800, description=
		"Specifies name of the attribute inside the user entry whose value will be " +
		"taken as user email. If left empty, email of the user will not be retrieved. ")
	public String getUserEmailAttribute() {
		return userEmailAttribute;
	}

	public void setUserEmailAttribute(String userEmailAttribute) {
		this.userEmailAttribute = userEmailAttribute;
	}

	@Editable(order=900, description=
		"Specify group retrieval strategy here. QuickBuild tries to get groups " +
		"associated with authenticated user and map them to QuickBuild groups " +
		"to determine user permission. If group information is not retrieved, " +
		"the default group will be used to associate with authenticated users.")
	@NotNull
	public GroupRetrievalStrategy getGroupRetrievalStrategy() {
		return groupRetrievalStrategy;
	}

	public void setGroupRetrievalStrategy(
			GroupRetrievalStrategy groupRetrievalStrategy) {
		this.groupRetrievalStrategy = groupRetrievalStrategy;
	}

	@SuppressWarnings("rawtypes")
	public AuthenticationResult authenticate(String userName, String password) {
		if (password == null) {
			if (getBindUser() != null && getBindUser().contains("{0}") || 
					getBindPassword() != null && getBindPassword().contains("{0}")) {
				throw new QuickbuildException("Can only retrieve user info if LDAP authenticator "
						+ "is configured to bind as a separate account.");
			}
		}
		
		AuthenticationResult result = new AuthenticationResult();

        Name userSearchBase;
		try {
			userSearchBase = new CompositeName().add(getUserSearchBase());
		} catch (InvalidNameException e) {
			throw new RuntimeException(e);
		}
        String userSearchFilter = StringUtils.replace(getUserSearchFilter(), 
        		"{0}", userName);
        userSearchFilter = StringUtils.replace(userSearchFilter, "\\", "\\\\");
        logger.debug("Evaluated user search filter: " + userSearchFilter);
        
        SearchControls searchControls = new SearchControls();
        searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
        List<String> attributeNames = new ArrayList<String>();
        if (getUserFullNameAttribute() != null)
            attributeNames.add(getUserFullNameAttribute());
        if (getUserEmailAttribute() != null)
            attributeNames.add(getUserEmailAttribute());
        if (getGroupRetrievalStrategy() instanceof GetGroupsUsingAttribute) {
        	GetGroupsUsingAttribute strategy = 
        		(GetGroupsUsingAttribute)getGroupRetrievalStrategy();
            attributeNames.add(strategy.getUserGroupsAttribute());
        }
        searchControls.setReturningAttributes(
        		(String[]) attributeNames.toArray(new String[0]));
        searchControls.setReturningObjFlag(true);

        Hashtable<String, String> ldapEnv = new Hashtable<String, String>();
        ldapEnv.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        ldapEnv.put(Context.PROVIDER_URL, getLdapUrl());
        ldapEnv.put(Context.SECURITY_AUTHENTICATION, "simple");
        ldapEnv.put("com.sun.jndi.ldap.connect.timeout", String.valueOf(Bootstrap.NET_CONNECT_TIMEOUT*1000L));
        ldapEnv.put("com.sun.jndi.ldap.read.timeout", String.valueOf(Bootstrap.NET_READ_TIMEOUT*1000L));
        if (getLdapUrl().startsWith("ldaps"))
        	ldapEnv.put("java.naming.ldap.factory.socket", TrustAllSSLSocketFactory.class.getName());        	
        if (isFollowReferrals())
            ldapEnv.put(Context.REFERRAL, "follow");
        else
            ldapEnv.put(Context.REFERRAL, "ignore");

        String evaluatedBindUser = null;
        String evaluatedBindPassword = null;
        if (getBindUser() != null)
            evaluatedBindUser = StringUtils.replace(getBindUser(), "{0}", userName);
        if (getBindPassword() != null)
            evaluatedBindPassword = StringUtils.replace(getBindPassword(), "{0}", password);

        if (evaluatedBindUser != null)
            ldapEnv.put(Context.SECURITY_PRINCIPAL, evaluatedBindUser);
        if (evaluatedBindPassword != null)
            ldapEnv.put(Context.SECURITY_CREDENTIALS, evaluatedBindPassword);

        DirContext ctx = null;
        DirContext referralCtx = null;
        try {
            if (evaluatedBindUser != null) {
                logger.debug("Binding to ldap url '" + getLdapUrl() 
                		+ "' as '" + evaluatedBindUser + "'...");
            } else {
                logger.debug("Binding to ldap url '" + getLdapUrl() + "' anonymously...");
            }
            try {
            	ctx = new InitialDirContext(ldapEnv);
            } catch (AuthenticationException e) {
            	if (getBindUser() != null && getBindUser().contains("{0}")) {
            		logger.debug("Error authenticating user.", e);
            		return null;
            	} else {
            		throw new QuickbuildException("Can not bind to ldap server '" + getLdapUrl() + 
            				"' as user '" + getBindUser() + "': " + e.getMessage());
            	}
            }
            NamingEnumeration results = ctx.search(userSearchBase, userSearchFilter, searchControls);
            if (results == null || !results.hasMore()) {
            	logger.debug("User search returns no result.");
                return null;
            }
            SearchResult searchResult = (SearchResult) results.next();
            String userDN = searchResult.getNameInNamespace();
            if (!searchResult.isRelative()) {
            	StringBuffer buffer = new StringBuffer();
                buffer.append(StringUtils.substringBefore(searchResult.getName(), "//"));
                buffer.append("//");
                buffer.append(StringUtils.substringBefore(
                		StringUtils.substringAfter(searchResult.getName(), "//"), "/"));
                
                ldapEnv.put(Context.PROVIDER_URL, buffer.toString());
                if (evaluatedBindUser != null) {
                    logger.debug("Binding to referral ldap url '" + buffer.toString() 
                    		+ "' as '" + evaluatedBindUser + "'...");
                } else {
                    logger.debug("Binding to referral ldap url '" + buffer.toString() 
                    		+ "' anonymously...");
                }
                referralCtx = new InitialDirContext(ldapEnv);
            }
            if (userDN.startsWith("ldap")) {
            	userDN = StringUtils.substringAfter(userDN, "//");
            	userDN = StringUtils.substringAfter(userDN, "/");
            }

            if (password != null && !StringUtils.contains(getBindUser(), "{0}")) {
                ldapEnv.put(Context.SECURITY_PRINCIPAL, userDN);
                ldapEnv.put(Context.SECURITY_CREDENTIALS, password);
                DirContext userCtx = null;
                try {
                    logger.debug("Authenticating user by binding as '" + userDN + "'...");
                    userCtx = new InitialDirContext(ldapEnv);
                } catch (AuthenticationException e) {
                	logger.debug("Error authenticating user.", e);
                	return null;
                } finally {
                    if (userCtx != null)
                        try {
                            userCtx.close();
                        } catch (NamingException e) {
                            // ignores
                        }
                }
            }

            Attributes searchResultAttributes = searchResult.getAttributes();
            if (searchResultAttributes != null) {
                if (getUserFullNameAttribute() != null) {
                    Attribute attribute = searchResultAttributes.get(getUserFullNameAttribute());
                    if (attribute != null && attribute.get() != null)
                        result.setFullName((String) attribute.get());
                }
                if (getUserEmailAttribute() != null) {
                    Attribute attribute = searchResultAttributes.get(getUserEmailAttribute());
                    if (attribute != null && attribute.get() != null)
                        result.setEmail((String) attribute.get());
                }
                if (getGroupRetrievalStrategy() instanceof GetGroupsUsingAttribute) {
                	GetGroupsUsingAttribute strategy = 
                		(GetGroupsUsingAttribute) getGroupRetrievalStrategy();
                    Attribute attribute = searchResultAttributes.get(
                    		strategy.getUserGroupsAttribute());
                    if (attribute != null) {
                        for (NamingEnumeration e = attribute.getAll(); e.hasMore();) {

                        	// use composite name instead of DN according to
                            // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4307193
                            Name groupDN = new CompositeName().add((String) e.next());
                            logger.debug("Looking up group entry '" + groupDN + "'...");
                            
                            DirContext groupCtx = null;
                            try {
                                if (referralCtx != null)
                                    groupCtx = (DirContext) referralCtx.lookup(groupDN);
                                else
                                    groupCtx = (DirContext) ctx.lookup(groupDN);

                                if (groupCtx == null) {
                                    throw new QuickbuildException("Can not find group entry " +
                                    		"identified by '" + groupDN + "'.");
                                }
                                String groupNameAttribute = strategy.getGroupNameAttribute();
                                Attributes groupAttributes = groupCtx.getAttributes("", 
                                		new String[]{groupNameAttribute});
                                if (groupAttributes == null 
                                		|| groupAttributes.get(groupNameAttribute) == null
                                        || groupAttributes.get(groupNameAttribute).get() == null) {
                                    throw new QuickbuildException("Can not find attribute '" 
                                    		+ groupNameAttribute + "' in returned group entry.");
                                }
                                result.getGroupNames().add((String) groupAttributes.get(
                                		groupNameAttribute).get());
                            } catch (PartialResultException pre) {
                                throw new QuickbuildException("Partial exception detected. You may " +
                                		"try to set property 'follow referrals' to true to avoid " +
                                		"this exception.", pre);
                            } finally {
                                if (groupCtx != null)
                                    try {
                                        groupCtx.close();
                                    } catch (NamingException ne) {
                                        // ignores
                                    }
                            }
                        }
                    } else {
                        logger.warn("No attribute identified by '" + strategy.getUserGroupsAttribute() 
                        		+ "' inside fetched user entry.");
                    }
                }
            }

            if (getGroupRetrievalStrategy() instanceof SearchGroupsUsingFilter) {
            	SearchGroupsUsingFilter strategy = 
            		(SearchGroupsUsingFilter) getGroupRetrievalStrategy();
            	String groupNameAttribute = strategy.getGroupNameAttribute();
                Name groupSearchBase = new CompositeName().add(strategy.getGroupSearchBase());
                String groupSearchFilter = StringUtils.replace(strategy.getGroupSearchFilter(), "{0}", userDN);
                groupSearchFilter = StringUtils.replace(groupSearchFilter, "\\", "\\\\");

                logger.debug("Evaluated group search filter: " + groupSearchFilter);
                searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
                searchControls.setReturningAttributes(new String[]{groupNameAttribute});

                try {
                    if (referralCtx != null)
                        results = referralCtx.search(groupSearchBase, groupSearchFilter, searchControls);
                    else
                        results = ctx.search(groupSearchBase, groupSearchFilter, searchControls);
                    if (results != null) {
                        while (results.hasMore()) {
                            searchResult = (SearchResult) results.next();
                            searchResultAttributes = searchResult.getAttributes();
                            if (searchResultAttributes == null 
                            		|| searchResultAttributes.get(groupNameAttribute) == null
                                    || searchResultAttributes.get(groupNameAttribute).get() == null) {
                                throw new QuickbuildException("Can not find attribute '" 
                                		+ groupNameAttribute + "' in the returned group object.");
                            }
                            result.getGroupNames().add((String) searchResultAttributes.get(
                            		groupNameAttribute).get());
                        }
                    }
                } catch (PartialResultException pre) {
                    logger.warn("Partial exception detected. You may try to set property " +
                    		"'follow referrals' to true to avoid this exception.", pre);
                }
            }
            return result;
        } catch (NamingException e) {
        	throw new RuntimeException(e);
        } finally {
            if (ctx != null)
                try {
                    ctx.close();
                } catch (NamingException e) {
                    // ignores
                }
            if (referralCtx != null)
                try {
                    referralCtx.close();
                } catch (NamingException e) {
                    // ignores
                }
        }
    }

	@SuppressWarnings("unused")
	private void migrate1(VersionedDocument dom, Stack<Integer> versions) {
		if (versions.empty()) {
			versions.push(0);
			versions.push(0);
		}
	}

}
