package com.atlassian.stash.internal.auth;

import com.atlassian.bitbucket.ServerException;
import com.atlassian.bitbucket.auth.*;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.server.ApplicationMode;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.bitbucket.server.FeatureManager;
import com.atlassian.bitbucket.server.StandardFeature;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.NoSuchUserException;
import com.atlassian.bitbucket.util.UncheckedOperation;
import com.atlassian.crowd.embedded.api.User;
import com.atlassian.crowd.embedded.api.UserWithAttributes;
import com.atlassian.stash.internal.AbstractService;
import com.atlassian.stash.internal.annotation.Unsecured;
import com.atlassian.stash.internal.crowd.CrowdControl;
import com.atlassian.stash.internal.user.CaptchaService;
import com.atlassian.stash.internal.user.CaptchaTicket;
import com.octo.captcha.service.CaptchaServiceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

@Service("captchaService")
public class DefaultCaptchaService extends AbstractService implements CaptchaService {

    private static final Logger log = LoggerFactory.getLogger(DefaultCaptchaService.class);

    //We don't use the Crowd invalidPasswordAttempts attr because it is only enforced for local users
    public static final String FAILED_AUTHENTICATION_ATTEMPT_COUNT = "failedAuthenticationAttemptCount";

    private final com.octo.captcha.service.CaptchaService captchaService;
    private final CrowdControl crowdControl;
    private final FeatureManager featureManager;
    private final I18nService i18nService;
    private final ApplicationPropertiesService propertiesService;

    @Autowired
    public DefaultCaptchaService(com.octo.captcha.service.CaptchaService captchaService, CrowdControl crowdControl,
                                 FeatureManager featureManager, I18nService i18nService,
                                 ApplicationPropertiesService propertiesService) {
        this.captchaService = captchaService;
        this.crowdControl = crowdControl;
        this.featureManager = featureManager;
        this.i18nService = i18nService;
        this.propertiesService = propertiesService;
    }

    @Override
    @Transactional(propagation = Propagation.SUPPORTS, noRollbackFor = {AuthenticationException.class, NoSuchUserException.class})
    @Unsecured("This needs to be available during authentication")
    public ApplicationUser authenticateWithCaptcha(@Nonnull CaptchaTicket captchaTicket,
                                                   @Nonnull UncheckedOperation<ApplicationUser> authenticateOperation) {
        checkNotNull(captchaTicket, "captchaTicket");
        checkNotNull(authenticateOperation, "authenticateOperation");
        checkArgument(captchaTicket instanceof ValidCaptchaTicket, "invalid CaptchaTicket!");

        ValidCaptchaTicket validCaptchaTicket = (ValidCaptchaTicket) captchaTicket;

        try {
            ApplicationUser user = authenticateOperation.perform();
            if (user != null) {
                validCaptchaTicket.onAuthenticationSuccess();
            }

            return user;
        } catch (IncorrectPasswordAuthenticationException e) {
            // let the ticket handle authentication. Note that this may throw a Captcha exception if the failed
            // authentication attempt limit is exceeded.
            validCaptchaTicket.onAuthenticationFailure(e);

            throw e;
        }
    }

    @Nonnull
    @Override
    @Transactional(noRollbackFor = IncorrectCaptchaAuthenticationException.class)
    @Unsecured("This needs to be available during authentication")
    public CaptchaTicket checkCaptcha(@Nonnull String username, @Nullable CaptchaResponse captchaResponse) {
        checkNotNull(username, "username");

        UserWithAttributes attributes = crowdControl.findUserWithAttributes(username);
        if (isCaptchaRequired(attributes)) {
            if (captchaResponse == null) {
                throw new CaptchaRequiredAuthenticationException(missingCaptchaResponse());
            }

            if (!captchaResponse.isVerified() && !isCaptchaValid(captchaResponse)) {
                incrementAttemptCountFor(attributes);

                throw new IncorrectCaptchaAuthenticationException(invalidCaptcha());
            }
        }

        return new ValidCaptchaTicket(attributes);
    }

    @Override
    @Transactional
    @PreAuthorize("hasGlobalPermission('SYS_ADMIN') or (hasGlobalPermission('ADMIN') and not hasGlobalPermission(#username, 'SYS_ADMIN'))")
    public void clearCaptchaChallenge(@Nonnull String username) {
        User user = crowdControl.findUser(username, false);
        if (user == null) {
            throw newNoSuchUserException(username);
        }
        crowdControl.setUserAttribute(user, FAILED_AUTHENTICATION_ATTEMPT_COUNT, 0L);
    }

    @Override
    @PreAuthorize("hasGlobalPermission('ADMIN')")
    public boolean isCaptchaChallenged(@Nonnull String username) {
        UserWithAttributes user = crowdControl.findUserWithAttributes(username);
        if (user == null) {
            throw newNoSuchUserException(username);
        }
        return isCaptchaRequired(user);
    }

    @Override
    @Unsecured("This needs to be available during signup")
    public boolean validateCaptchaResponse(@Nonnull CaptchaResponse captchaResponse) {
        return captchaService.validateResponseForID(captchaResponse.getChallengeId(), captchaResponse.getUserResponse());
    }

    private KeyedMessage captchaServiceFail() {
        return i18nService.createKeyedMessage("bitbucket.service.user.captchasvcfail");
    }

    /**
     * Retrieves the number of failed auth attempts the user has made.
     *
     * This information is local to this Stash instance. It may not correspond with
     * any such count recorded by a remote directory server or remote Crowd.
     *
     * @param user the user to retrieve the attempt count for
     * @return the count of failed login attempts or 0 if this information cannot be determined
     */
    private long getAttemptCountFor(UserWithAttributes user) {
        if (user != null) {
            String count = user.getValue(FAILED_AUTHENTICATION_ATTEMPT_COUNT);
            if (count != null) {
                try {
                    return Long.parseLong(count);
                } catch (NumberFormatException e) {
                    log.warn("Invalid attribute {} for user {}: {}",
                            FAILED_AUTHENTICATION_ATTEMPT_COUNT, user.getName(), count);
                }
            }
        }

        return 0L;
    }

    private long incrementAttemptCount(long val) {
        return val == Long.MAX_VALUE ? Long.MAX_VALUE : val + 1;
    }

    /**
     * Increments by one the number of failed auth attempts the user has made.
     * <p>
     * This information is local to this Stash instance. It may not correspond with any such count recorded by a remote
     * directory server or remote Crowd.
     *
     * @param user the user whose attempt count should be incremented
     * @throws ServerException if Crowd does not permit updating the corresponding attribute
     * @return updated number of failed auth attempts
     */
    private long incrementAttemptCountFor(UserWithAttributes user) {
        if (user != null) {
            long failedLoginCount = incrementAttemptCount(getAttemptCountFor(user));

            crowdControl.setUserAttribute(user, FAILED_AUTHENTICATION_ATTEMPT_COUNT, failedLoginCount);

            return failedLoginCount;
        }

        return 0L;
    }

    private KeyedMessage invalidCaptcha() {
        return i18nService.createKeyedMessage("bitbucket.service.user.invalidcaptcha");
    }

    /**
     * Determines if the given user is required to enter a CAPTCHA response.
     * <p>
     * This implementation considers it necessary for a user to enter a CAPTCHA response when the number of failed
     * authentication attempts by that user exceeds the predefined maximum.
     *
     * @param user the user for whom the check is performed
     * @return true if the user is required to enter a CAPTCHA response; false otherwise
     */
    private boolean isCaptchaRequired(UserWithAttributes user) {
        return isCaptchaRequired(getAttemptCountFor(user));
    }

    private boolean isCaptchaRequired(long attempts) {
        return propertiesService.getMode() != ApplicationMode.MIRROR
                && featureManager.isEnabled(StandardFeature.AUTH_CAPTCHA)
                && attempts > 0
                && attempts >= propertiesService.getMaxCaptchaAttempts();
    }

    private boolean isCaptchaValid(CaptchaResponse response) {
        try {
            if (!response.isVerified() &&
                    captchaService.validateResponseForID(response.getChallengeId(), response.getUserResponse())) {
                response.setVerified(true);
            }
            return response.isVerified();
        } catch (CaptchaServiceException e) {
            throw new AuthenticationSystemException(captchaServiceFail(), e);
        }
    }

    private KeyedMessage missingCaptchaResponse() {
        return i18nService.createKeyedMessage("bitbucket.service.user.missingcaptcharesponse");
    }

    private NoSuchUserException newNoSuchUserException(String username) {
        throw new NoSuchUserException(i18nService.createKeyedMessage("bitbucket.service.users.noSuchUser", username), username);
    }

    private class ValidCaptchaTicket implements CaptchaTicket {
        private long attempts;
        private final UserWithAttributes user;

        private ValidCaptchaTicket(UserWithAttributes user) {
            this.attempts = getAttemptCountFor(user);
            this.user = user;
        }

        public void onAuthenticationFailure(AuthenticationException exception) {
            if (user != null) {
                attempts = incrementAttemptCount(attempts);
                updateFailedAuthAttempts(attempts);

                //If the attempt count for the user has exceeded the configured limit, replace the exception with one
                //indicating the user must now solve a CAPTCHA before they will be allowed to login
                if (isCaptchaRequired(attempts)) {
                    throw new CaptchaRequiredAuthenticationException(exception.getKeyedMessage());
                }
            }
        }

        public void onAuthenticationSuccess() {
            if (attempts != 0L) {
                updateFailedAuthAttempts(0L);
            }
        }

        private void updateFailedAuthAttempts(final long newCount) {
            crowdControl.setUserAttribute(user, FAILED_AUTHENTICATION_ATTEMPT_COUNT, newCount);
        }
    }
}
