// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you 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.apache.cloudstack.ldap;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import javax.inject.Inject;

import org.apache.cloudstack.acl.RoleType;
import org.apache.cloudstack.auth.UserAuthenticator;
import org.apache.commons.lang3.StringUtils;

import com.cloud.user.Account;
import com.cloud.user.AccountManager;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.user.dao.UserAccountDao;
import com.cloud.utils.Pair;
import com.cloud.utils.component.AdapterBase;
import com.cloud.utils.exception.CloudRuntimeException;

public class LdapAuthenticator extends AdapterBase implements UserAuthenticator {

    @Inject
    private LdapManager _ldapManager;
    @Inject
    private UserAccountDao _userAccountDao;
    @Inject
    private AccountManager _accountManager;

    private static final String LDAP_READ_TIMED_OUT_MESSAGE = "LDAP response read timed out";

    public LdapAuthenticator() {
        super();
    }

    public LdapAuthenticator(final LdapManager ldapManager, final UserAccountDao userAccountDao) {
        super();
        _ldapManager = ldapManager;
        _userAccountDao = userAccountDao;
    }

    @Override
    public Pair<Boolean, ActionOnFailedAuthentication> authenticate(final String username, final String password, final Long domainId, final Map<String, Object[]> requestParameters) {
        Pair<Boolean, ActionOnFailedAuthentication> rc = new Pair<Boolean, ActionOnFailedAuthentication>(false, null);

        if (logger.isDebugEnabled()) {
            logger.debug("Retrieving ldap user: " + username);
        }

        // TODO not allowing an empty password is a policy we shouldn't decide on. A private cloud may well want to allow this.
        if (StringUtils.isNoneEmpty(username, password)) {
            if (_ldapManager.isLdapEnabled(domainId) || _ldapManager.isLdapEnabled()) {
                if (logger.isTraceEnabled()) {
                    logger.trace("LDAP is enabled in the ldapManager");
                }
                final UserAccount user = _userAccountDao.getUserAccount(username, domainId);
                if (user != null && ! User.Source.LDAP.equals(user.getSource())) {
                    return rc;
                }
                List<LdapTrustMapVO> ldapTrustMapVOs = getLdapTrustMapVOS(domainId);
                if (ldapTrustMapVOs != null && ldapTrustMapVOs.size() > 0) {
                    if (ldapTrustMapVOs.size() == 1 && ldapTrustMapVOs.get(0).getAccountId() == 0) {
                        if (logger.isTraceEnabled()) {
                            logger.trace("We have a single mapping of a domain to an ldap group or ou");
                        }
                        rc = authenticate(username, password, domainId, user, ldapTrustMapVOs.get(0));
                    } else {
                        if (logger.isTraceEnabled()) {
                            logger.trace("we are dealing with mapping of accounts in a domain to ldap groups");
                        }
                        rc = authenticate(username, password, domainId, user, ldapTrustMapVOs);
                    }
                } else {
                    if (logger.isTraceEnabled()) {
                        logger.trace(String.format("'this' domain (%d) is not linked to ldap follow normal authentication", domainId));
                    }
                    rc = authenticate(username, password, domainId, user);
                }
            }
        } else {
            logger.debug("Username or Password cannot be empty");
        }

        return rc;
    }

    private List<LdapTrustMapVO> getLdapTrustMapVOS(Long domainId) {
        return _ldapManager.getDomainLinkage(domainId);
    }

    /**
     * checks if the user exists in ldap and create in cloudstack if needed.
     *
     * @param username login id
     * @param password pass phrase
     * @param domainId domain the user is trying to log on to
     * @param userAccount cloudstack user object
     * @param ldapTrustMapVOs the trust mappings of accounts in the domain to ldap groups
     * @return false if the ldap user object does not exist, is not mapped to an account, is mapped to multiple accounts or if authenitication fails
     */
    Pair<Boolean, ActionOnFailedAuthentication> authenticate(String username, String password, Long domainId, UserAccount userAccount, List<LdapTrustMapVO> ldapTrustMapVOs) {
        Pair<Boolean, ActionOnFailedAuthentication> rc = new Pair<Boolean, ActionOnFailedAuthentication>(false, null);
        try {
            LdapUser ldapUser = _ldapManager.getUser(username, domainId);
            List<String> memberships = ldapUser.getMemberships();
            tracelist("memberships for " + username, memberships);
            List<String> mappedGroups = getMappedGroups(ldapTrustMapVOs);
            tracelist("mappedgroups for " + username, mappedGroups);
            mappedGroups.retainAll(memberships);
            tracelist("actual groups for " + username, mappedGroups);
            // check membership, there must be only one match in this domain
            if (ldapUser.isDisabled()) {
                logAndDisable(userAccount, "attempt to log on using disabled ldap user " + userAccount.getUsername(), false);
            } else if (mappedGroups.size() > 1) {
                logAndDisable(userAccount, "user '" + username + "' is mapped to more then one account in domain and will be disabled.", false);
            } else if (mappedGroups.size() < 1) {
                logAndDisable(userAccount, "user '" + username + "' is not mapped to an account in domain and will be removed.", true);
            } else {
                // a valid ldap configured user exists
                LdapTrustMapVO mapping = _ldapManager.getLinkedLdapGroup(domainId,mappedGroups.get(0));
                // we could now assert that ldapTrustMapVOs.contains(mapping);
                // createUser in Account can only be done by account name not by account id;
                Account account = _accountManager.getAccount(mapping.getAccountId());
                if (null == account) {
                    throw new CloudRuntimeException(String.format("account for user (%s) not found by id %d", username, mapping.getAccountId()));
                }
                String accountName = account.getAccountName();
                rc.first(_ldapManager.canAuthenticate(ldapUser.getPrincipal(), password, domainId));
                if (!rc.first()) {
                    rc.second(ActionOnFailedAuthentication.INCREMENT_INCORRECT_LOGIN_ATTEMPT_COUNT);
                }
                // for security reasons we keep processing on faulty login attempt to not give a way information on userid existence
                if (userAccount == null) {
                    // new user that is in ldap; authenticate and create
                    User user = _accountManager.createUser(username, "", ldapUser.getFirstname(), ldapUser.getLastname(), ldapUser.getEmail(), null, accountName,
                            domainId, UUID.randomUUID().toString(), User.Source.LDAP);
                    /* expected error conditions:
                     *
                     * caught in APIServlet: CloudRuntimeException("The domain " + domainId + " does not exist; unable to create user");
                     * caught in APIServlet: CloudRuntimeException("The user cannot be created as domain " + domain.getName() + " is being deleted");
                     * would have been thrown above: InvalidParameterValueException("Unable to find account " + accountName + " in domain id=" + domainId + " to create user");
                     * we are system user: PermissionDeniedException("Account id : " + account.getId() + " is a system account, can't add a user to it");
                     * serious and must be thrown: CloudRuntimeException("The user " + userName + " already exists in domain " + domainId);
                     * fatal system error and must be thrown: CloudRuntimeException("Failed to encode password");
                     */
                    userAccount = _accountManager.getUserAccountById(user.getId());
                } else {
                    // not a new user, check if mapped group has changed
                    if (userAccount.getAccountId() != mapping.getAccountId()) {
                        final Account mappedAccount = _accountManager.getAccount(mapping.getAccountId());
                        if (mappedAccount == null || mappedAccount.getRemoved() != null) {
                            throw new CloudRuntimeException("Mapped account for users does not exist. Please contact your administrator.");
                        }
                        _accountManager.moveUser(userAccount.getId(), userAccount.getDomainId(), mappedAccount);
                    }
                    // else { the user hasn't changed in ldap, the ldap group stayed the same, hurray, pass, fun thou self a lot of fun }
                }
            }
        } catch (NoLdapUserMatchingQueryException e) {
            logger.debug(e.getMessage());
            processLdapUserErrorMessage(userAccount, e.getMessage(), rc);
        }

        return rc;
    }

    private void processLdapUserErrorMessage(UserAccount user, String errorMessage, Pair<Boolean, ActionOnFailedAuthentication> rc) {
        if (StringUtils.isNotEmpty(errorMessage) && errorMessage.contains(LDAP_READ_TIMED_OUT_MESSAGE) && !rc.first()) {
            rc.second(ActionOnFailedAuthentication.INCREMENT_INCORRECT_LOGIN_ATTEMPT_COUNT);
        } else {
            // no user in ldap ==>> disable user in cloudstack
            disableUserInCloudStack(user);
        }
    }

    private void tracelist(String msg, List<String> listToTrace) {
        if (logger.isTraceEnabled()) {
            StringBuilder logMsg = new StringBuilder();
            logMsg.append(msg);
            logMsg.append(':');
            for (String listMember : listToTrace) {
                logMsg.append(' ');
                logMsg.append(listMember);
            }
            logger.trace(logMsg.toString());
        }
    }

    private void logAndDisable(UserAccount userAccount, String msg, boolean remove) {
        if (logger.isInfoEnabled()) {
            logger.info(msg);
        }
        if (remove) {
            removeUserInCloudStack(userAccount);
        } else {
            disableUserInCloudStack(userAccount);
        }
    }

    List<String> getMappedGroups(List<LdapTrustMapVO> ldapTrustMapVOs) {
        List<String> groups = new ArrayList<>();
        for (LdapTrustMapVO vo : ldapTrustMapVOs) {
            groups.add(vo.getName());
        }
        return groups;
    }

    /**
     * checks if the user exists in ldap and create in cloudstack if needed
     * @param username login id
     * @param password pass phrase
     * @param domainId domain the user is trying to log on to
     * @param user cloudstack user object
     * @param ldapTrustMapVO the trust mapping for the domain to the ldap group
     * @return false if the ldap user object does not exist or authenitication fails
     */
    private Pair<Boolean, ActionOnFailedAuthentication> authenticate(String username, String password, Long domainId, UserAccount user, LdapTrustMapVO ldapTrustMapVO) {
        Pair<Boolean, ActionOnFailedAuthentication> rc = new Pair<Boolean, ActionOnFailedAuthentication>(false, null);
        try {
            LdapUser ldapUser = _ldapManager.getUser(username, ldapTrustMapVO.getType().toString(), ldapTrustMapVO.getName(), domainId);
            final Account.Type accountType = ldapTrustMapVO.getAccountType();
            processLdapUser(password, domainId, user, rc, ldapUser, accountType);
        } catch (NoLdapUserMatchingQueryException e) {
            logger.debug(e.getMessage());
            processLdapUserErrorMessage(user, e.getMessage(), rc);
        }
        return rc;
    }

    private void processLdapUser(String password, Long domainId, UserAccount user, Pair<Boolean, ActionOnFailedAuthentication> rc, LdapUser ldapUser, Account.Type accountType) {
        if (!ldapUser.isDisabled()) {
            rc.first(_ldapManager.canAuthenticate(ldapUser.getPrincipal(), password, domainId));
            if (rc.first()) {
                if (user == null) {
                    // import user to cloudstack
                    createCloudStackUserAccount(ldapUser, domainId, accountType);
                } else {
                    enableUserInCloudStack(user);
                }
            } else if (user != null) {
                rc.second(ActionOnFailedAuthentication.INCREMENT_INCORRECT_LOGIN_ATTEMPT_COUNT);
            }
        } else {
            //disable user in cloudstack
            disableUserInCloudStack(user);
        }
    }

    /**
     * checks if the user is configured both in ldap and in cloudstack.
     * @param username login id
     * @param password pass phrase
     * @param domainId domain the user is trying to log on to
     * @param user cloudstack user object
     * @return false if either user object does not exist or authenitication fails
     */
    Pair<Boolean, ActionOnFailedAuthentication> authenticate(String username, String password, Long domainId, UserAccount user) {
        boolean result = false;
        boolean timedOut = false;

        if (user != null ) {
            try {
                LdapUser ldapUser = _ldapManager.getUser(username, domainId);
                if (!ldapUser.isDisabled()) {
                    result = _ldapManager.canAuthenticate(ldapUser.getPrincipal(), password, domainId);
                } else {
                    logger.debug("user with principal "+ ldapUser.getPrincipal() + " is disabled in ldap");
                }
            } catch (NoLdapUserMatchingQueryException e) {
                logger.debug(e.getMessage());
                if (e.getMessage().contains(LDAP_READ_TIMED_OUT_MESSAGE)) {
                    timedOut = true;
                }
            }
        }
        return processResultAndAction(user, result, timedOut);
    }

    private Pair<Boolean, ActionOnFailedAuthentication> processResultAndAction(UserAccount user, boolean result, boolean timedOut) {
        return (!result && (user != null || timedOut)) ?
                new Pair<Boolean, ActionOnFailedAuthentication>(result, ActionOnFailedAuthentication.INCREMENT_INCORRECT_LOGIN_ATTEMPT_COUNT):
                new Pair<Boolean, ActionOnFailedAuthentication>(result, null);
    }

    private void enableUserInCloudStack(UserAccount user) {
        if (user != null && (user.getState().equalsIgnoreCase(Account.State.DISABLED.toString()))) {
            _accountManager.enableUser(user.getId());
        }
    }

    private void createCloudStackUserAccount(LdapUser user, long domainId, Account.Type accountType) {
        String username = user.getUsername();
        _accountManager.createUserAccount(username, "", user.getFirstname(), user.getLastname(), user.getEmail(), null, username,
                                          accountType, RoleType.getByAccountType(accountType).getId(), domainId, null, null,
                                          UUID.randomUUID().toString(), UUID.randomUUID().toString(), User.Source.LDAP);
    }

    private void disableUserInCloudStack(UserAccount user) {
        if (user != null) {
            _accountManager.disableUser(user.getId());
        }
    }

    private void removeUserInCloudStack(UserAccount user) {
        if (user != null) {
            _accountManager.disableUser(user.getId());
        }
    }

    @Override
    public String encode(final String password) {
        return password;
    }
}
