package com.atlassian.stash.internal.auth;

import java.util.Optional;

import com.atlassian.bitbucket.auth.*;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.user.NoSuchUserException;
import com.atlassian.bitbucket.util.ModuleDescriptorUtils;
import com.atlassian.bitbucket.util.Timer;
import com.atlassian.bitbucket.util.TimerUtils;
import com.atlassian.bitbucket.util.UncheckedOperation;
import com.atlassian.plugin.PluginAccessor;
import com.atlassian.stash.internal.annotation.Unsecured;
import com.atlassian.stash.internal.user.CaptchaService;
import com.atlassian.stash.internal.user.CaptchaTicket;
import com.atlassian.stash.internal.user.StashUserAuthenticationToken;
import com.atlassian.stash.internal.web.FakeHttpServletRequest;
import com.atlassian.stash.internal.web.FakeHttpServletResponse;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Nonnull;
import javax.servlet.http.HttpSession;

import static java.util.Optional.empty;
import static java.util.Optional.of;
import static java.util.stream.Collectors.toList;

@Component("authenticationService")
@Transactional(propagation = Propagation.SUPPORTS, noRollbackFor = {AuthenticationException.class, NoSuchUserException.class})
public class DefaultAuthenticationService implements AuthenticationService {

    private static final String CAPTCHA = "captcha";
    private static final Logger log = LoggerFactory.getLogger(DefaultAuthenticationService.class);

    private final CaptchaService captchaService;
    private final I18nService i18nService;
    private final PluginAccessor pluginAccessor;

    @Autowired
    public DefaultAuthenticationService(CaptchaService captchaService, I18nService i18nService,
                                        PluginAccessor pluginAccessor) {
        this.captchaService = captchaService;
        this.i18nService = i18nService;
        this.pluginAccessor = pluginAccessor;
    }

    @Nonnull
    @Override
    @Unsecured("This needs to be available to unauthenticated contexts")
    public ApplicationUser authenticate(@Nonnull String username, @Nonnull String password) {
        return authenticateInternal(username, password, false);
    }

    @Nonnull
    @Override
    @Unsecured("This needs to be available to unauthenticated contexts")
    public Optional<StashUserAuthenticationToken> authenticate(@Nonnull HttpAuthenticationContext context) {
        return authenticateInternal(context, true);
    }

    @Nonnull
    @Override
    @Unsecured("This needs to be available to unauthenticated contexts")
    public ApplicationUser authenticateWithCaptcha(@Nonnull String username, @Nonnull String password) {
        return authenticateInternal(username, password, true);
    }

    private ApplicationUser authenticateInternal(String username, String password, boolean allowCaptcha) {
        log.debug("Authenticating user: {}", username);

        return authenticateInternal(new DefaultHttpAuthenticationContext(
                new FakeHttpServletRequest(), new FakeHttpServletResponse(), (req, res) -> { },
                HttpAuthenticationContext.METHOD_BASIC, username, password), allowCaptcha)
                .map(StashUserAuthenticationToken::getPrincipal)
                .orElseThrow(this::badCredentials);
    }

    @Nonnull
    private Optional<StashUserAuthenticationToken> authenticateInternal(@Nonnull HttpAuthenticationContext context, boolean allowCaptcha) {
        CaptchaResponse captchaResponse = extractCaptchaResponse(context);
        ApplicationUser user = null;

        // CAPTCHA validation is not free; it involves loading the user attributes from the database. CAPTCHA validation
        // should be done at most once.
        //noinspection ConstantConditions
        Supplier<CaptchaTicket> captchaTicket = Suppliers.memoize(
                () -> captchaService.checkCaptcha(context.getUsername(), captchaResponse));
        for (HttpAuthenticationHandlerModuleDescriptor moduleDescriptor : getSortedAuthenticationModuleDescriptors()) {
            try (Timer ignored = TimerUtils.start("attemptAuthentication - " + moduleDescriptor.getCompleteKey())) {
                HttpAuthenticationHandler authenticator = instantiateModule(moduleDescriptor);

                if (authenticator == null) {
                    log.debug("not attempting authentication with authenticator {} because it could not be instantiated",
                            moduleDescriptor.getCompleteKey());
                    continue;
                }

                log.trace("attempting authentication with authenticator {}", moduleDescriptor.getCompleteKey());
                UncheckedOperation<ApplicationUser> authenticateOperation = () -> authenticator.authenticate(context);

                try {
                    if (allowCaptcha && moduleDescriptor.isCaptchaSupported() && isCredentialsProvided(context)) {
                        // only go through CAPTCHA if a username and a credential have been presented. If no credentials
                        // are presented, CAPTCHA processing is bypasses. This is a concession to ensure that we don't
                        // hit CAPTCHA too quickly in the case of basic authentication where the password is not
                        // provided preemptively. Authentication will still proceed against an empty password though.
                        user = captchaService.authenticateWithCaptcha(captchaTicket.get(), authenticateOperation);
                    } else {
                        // bypass captcha - appropriate for SSO tokens, etc.
                        user = authenticateOperation.perform();
                    }
                } catch (AuthenticationException e) {
                    throw e;
                } catch (NoClassDefFoundError | RuntimeException e) {
                    // can happen if AMPS is uninstalling a plugin containing this authentication handler
                    log.warn("Authenticator '{}' threw an exception", moduleDescriptor.getCompleteKey(), e);
                }
                if (user != null) {
                    break;
                }
            }
        }

        if (user == null) {
            return empty();
        }

        return of(StashUserAuthenticationToken.forUser(user));
    }

    private AuthenticationException badCredentials() {
        throw new IncorrectPasswordAuthenticationException(
                i18nService.createKeyedMessage("bitbucket.service.user.authenticationfailed"));
    }

    /**
     * Extracts a CAPTCHA response from the given authentication, if there is any to be had.
     *
     * @param context the authentication that may be holding CAPTCHA-related parameters
     * @return a new CAPTCHA response, or null if the CAPTCHA-related parameters are missing or incomplete
     */
    private CaptchaResponse extractCaptchaResponse(HttpAuthenticationContext context) {
        HttpSession session = context.getRequest().getSession(false);
        String challengeId = session == null ? null : session.getId();
        String userResponse = StringUtils.trimToNull(context.getRequest().getParameter(CAPTCHA));

        return StringUtils.isNotEmpty(challengeId) && userResponse != null
                ? new CaptchaResponse(challengeId, userResponse) : null;
    }

    private Iterable<HttpAuthenticationHandlerModuleDescriptor> getSortedAuthenticationModuleDescriptors() {
        return pluginAccessor.getEnabledModuleDescriptorsByClass(HttpAuthenticationHandlerModuleDescriptor.class)
                .stream()
                .sorted()
                .collect(toList());
    }

    private HttpAuthenticationHandler instantiateModule(HttpAuthenticationHandlerModuleDescriptor moduleDescriptor) {
        return ModuleDescriptorUtils.<HttpAuthenticationHandler, HttpAuthenticationHandlerModuleDescriptor>toModule().apply(moduleDescriptor);
    }

    private boolean isCredentialsProvided(HttpAuthenticationContext context) {
        Object credentials = context.getCredentials();

        return StringUtils.isNotBlank(context.getUsername()) &&
                (credentials instanceof String) && StringUtils.isNotEmpty((String) credentials);
    }
}
