package com.atlassian.stash.internal.auth;

import com.atlassian.bitbucket.auth.*;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.i18n.KeyedMessage;
import com.atlassian.bitbucket.i18n.SimpleI18nService;
import com.atlassian.bitbucket.user.ApplicationUser;
import com.atlassian.bitbucket.util.UncheckedOperation;
import com.atlassian.plugin.PluginAccessor;
import com.atlassian.plugin.module.ModuleFactory;
import com.atlassian.stash.internal.user.CaptchaService;
import com.atlassian.stash.internal.user.CaptchaTicket;
import com.atlassian.stash.internal.user.StashUserAuthenticationToken;
import com.google.common.collect.Lists;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.*;
import org.mockito.runners.MockitoJUnitRunner;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Arrays;
import java.util.Optional;

import static com.atlassian.bitbucket.junit.MoreAssert.*;
import static java.util.Optional.ofNullable;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
import static org.mockito.Matchers.same;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class DefaultAuthenticationServiceTest {

    private static final KeyedMessage MESSAGE = new KeyedMessage("foo", "bar", "tzu");
    private static final String PASSWORD = "password";
    private static final String USERNAME = "username";

    @Mock
    private DefaultHttpAuthenticationContext context;
    @Mock
    private HttpAuthenticationHandler authenticationHandler;
    @Mock
    private CaptchaTicket captchaTicket;
    @Mock
    private CaptchaService captchaService;
    @Spy
    private I18nService i18nService = new SimpleI18nService();
    @Mock
    private HttpAuthenticationHandlerModuleDescriptor moduleDescriptor;
    @Mock
    private PluginAccessor pluginAccessor;
    @Mock
    private HttpServletRequest request;
    @InjectMocks
    private DefaultAuthenticationService service;
    @Mock
    private HttpSession session;
    @Mock
    private ApplicationUser user;

    @Before
    public void setup() {
        when(captchaService.authenticateWithCaptcha(any(), any())).thenAnswer(invocation ->
                ((UncheckedOperation) invocation.getArguments()[1]).perform());
        when(moduleDescriptor.getModule()).thenReturn(authenticationHandler);
        when(moduleDescriptor.isCaptchaSupported()).thenReturn(true);
        when(pluginAccessor.getEnabledModuleDescriptorsByClass(any()))
                .thenReturn(Lists.newArrayList(moduleDescriptor));

        when(context.getRequest()).thenReturn(request);
        when(request.getSession(false)).thenReturn(session);
    }

    @Test
    public void testAuthenticate() {
        setupAuthenticationToken();
        when(authenticationHandler.authenticate(any(HttpAuthenticationContext.class))).thenReturn(user);

        Optional<StashUserAuthenticationToken> token = service.authenticate(context);

        assertAuthenticationToken(user, token);
        verify(captchaService).authenticateWithCaptcha(any(), any());
    }

    @Test
    public void testAuthenticateSkipsCaptchaWhenHandlerDoesNotSupportIt() {
        setupAuthenticationToken();
        when(authenticationHandler.authenticate(any(HttpAuthenticationContext.class))).thenReturn(user);
        when(moduleDescriptor.isCaptchaSupported()).thenReturn(false);

        Optional<StashUserAuthenticationToken> token = service.authenticate(context);

        assertAuthenticationToken(user, token);
        verify(captchaService, never()).authenticateWithCaptcha(any(), any());
    }

    @Test
    public void testAuthenticateSkipsHandlersThatCannotBeInstantiated() throws Exception {
        setupAuthenticationToken();

        HttpAuthenticationHandlerModuleDescriptor moduleDescriptor2 = mock(HttpAuthenticationHandlerModuleDescriptor.class);
        HttpAuthenticationHandler authenticationHandler2 = mock(HttpAuthenticationHandler.class);
        when(authenticationHandler2.authenticate(any(HttpAuthenticationContext.class))).thenReturn(user);
        when(moduleDescriptor2.getModule()).thenReturn(authenticationHandler2);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(any()))
                .thenReturn(Arrays.asList(moduleDescriptor, moduleDescriptor2));

        when(moduleDescriptor.getModule()).thenThrow(new RuntimeException());

        Optional<StashUserAuthenticationToken> token = service.authenticate(context);

        assertAuthenticationToken(user, token);
        verify(authenticationHandler, never()).authenticate(any(HttpAuthenticationContext.class));
        verify(authenticationHandler2).authenticate(any(HttpAuthenticationContext.class));
    }

    @Test
    public void testAuthenticateUsernamePassword() {
        when(moduleDescriptor.isCaptchaSupported()).thenReturn(true);
        when(authenticationHandler.authenticate(any())).thenReturn(user);

        assertEquals(user, service.authenticate(USERNAME, PASSWORD));
        verifyZeroInteractions(captchaService);
    }

    @Test
    public void testAuthenticateWitchCaptchaUsernamePassword() {
        when(moduleDescriptor.isCaptchaSupported()).thenReturn(true);
        when(authenticationHandler.authenticate(any())).thenReturn(user);

        assertEquals(user, service.authenticateWithCaptcha(USERNAME, PASSWORD));
        verify(captchaService).authenticateWithCaptcha(any(), any());
    }

    @Test(expected = AuthenticationSystemException.class)
    public void testAuthenticateWhenAuthenticationHandlerFails() {
        setupAuthenticationToken();

        when(authenticationHandler.authenticate(any(HttpAuthenticationContext.class)))
                .thenThrow(new AuthenticationSystemException(MESSAGE));

        service.authenticate(context);
    }

    @Test(expected = IncorrectPasswordAuthenticationException.class)
    public void testAuthenticateWithBadCredentials() {
        setupAuthenticationToken();

        Throwable ex = new IncorrectPasswordAuthenticationException(MESSAGE);
        when(authenticationHandler.authenticate(any(HttpAuthenticationContext.class))).thenThrow(ex);

        service.authenticate(context);
    }

    @Test
    public void testAuthenticateWithEmptyAuthenticationContext() {
        assertEmpty(service.authenticate(context));
    }

    @Test
    public void testAuthenticateWithCaptchaMultipleProviders() {
        setupAuthenticationToken();
        setupCaptchaResponse();

        when(captchaService.checkCaptcha(eq(USERNAME), any(CaptchaResponse.class))).thenReturn(captchaTicket);

        reset(pluginAccessor);
        HttpAuthenticationHandler authenticationHandler2 = mock(HttpAuthenticationHandler.class);
        HttpAuthenticationHandlerModuleDescriptor moduleDescriptor2 = mock(HttpAuthenticationHandlerModuleDescriptor.class);
        when(moduleDescriptor2.getModule()).thenReturn(authenticationHandler2);
        when(moduleDescriptor.isCaptchaSupported()).thenReturn(true);
        when(moduleDescriptor2.isCaptchaSupported()).thenReturn(true);
        when(pluginAccessor.getEnabledModuleDescriptorsByClass(any()))
                .thenReturn(Lists.newArrayList(moduleDescriptor, moduleDescriptor2));
        // first authentication handler opts out of authentication, second one succeeds
        when(authenticationHandler.authenticate(context)).thenReturn(null);
        when(authenticationHandler2.authenticate(context)).thenReturn(user);

        Optional<StashUserAuthenticationToken> token = service.authenticate(context);

        assertPresent(token);
        assertTrue(token.get().isAuthenticated());

        // checkCaptcha should have been called only once
        verify(captchaService, times(1)).checkCaptcha(anyString(), any(CaptchaResponse.class));
        verify(captchaService, times(2)).authenticateWithCaptcha(same(captchaTicket), any());
    }

    /**
     * Tests that a {@code LockedException} is thrown when:
     * <ul>
     * <li>the number of failed authentication attempts exceeds the predefined limit; and</li>
     * <li>the underlying user service throws a capture authentication exception.</li>
     * </ul>
     */
    @Test(expected = CaptchaRequiredAuthenticationException.class)
    public void testAuthenticateWithCaptchaFailure() {
        setupAuthenticationToken();
        setupCaptchaResponse();

        CaptchaRequiredAuthenticationException e = new CaptchaRequiredAuthenticationException(mock(KeyedMessage.class));
        when(captchaService.checkCaptcha(eq(USERNAME), any(CaptchaResponse.class)))
                .thenThrow(e);

        try {
            service.authenticate(context);
        } finally {
            ArgumentCaptor<CaptchaResponse> captcha = ArgumentCaptor.forClass(CaptchaResponse.class);
            verify(captchaService).checkCaptcha(eq(USERNAME), captcha.capture());

            assertEquals("123", captcha.getValue().getChallengeId());
            assertEquals("response", captcha.getValue().getUserResponse());
        }
    }

    @Test
    public void testAuthenticateWithCaptchaSuccess() {
        setupAuthenticationToken();
        setupCaptchaResponse();

        when(captchaService.checkCaptcha(eq(USERNAME), any(CaptchaResponse.class))).thenReturn(captchaTicket);
        when(authenticationHandler.authenticate(context)).thenReturn(user);

        Optional<StashUserAuthenticationToken> token = service.authenticate(context);

        assertPresent(token);
        assertTrue(token.get().isAuthenticated());
    }

    @Test
    public void testAuthenticateWithEmptyUsername() {
        when(context.getUsername()).thenReturn("");

        assertEmpty(service.authenticate(context));
    }

    @Test
    public void testAuthenticateWithNullUsernamePassword() {
        when(context.getUsername()).thenReturn(null);
        when(context.getCredentials()).thenReturn(null);

        assertEmpty(service.authenticate(context));
    }

    @Test
    public void testAuthenticateWithUnlicensedUser() {
        setupAuthenticationToken();

        when(authenticationHandler.authenticate(any(HttpAuthenticationContext.class))).thenReturn(user);

        assertAuthenticationToken(user, service.authenticate(context));
    }

    @Test
    public void testAuthenticationHandlersProcessedInWeightOrder() {
        HttpAuthenticationHandler handler1 = mock(HttpAuthenticationHandler.class);
        HttpAuthenticationHandlerModuleDescriptor descriptor1 = mockDescriptor(10, handler1);
        HttpAuthenticationHandler handler2 = mock(HttpAuthenticationHandler.class);
        HttpAuthenticationHandlerModuleDescriptor descriptor2 = mockDescriptor(100, handler2);
        HttpAuthenticationHandler handler3 = mock(HttpAuthenticationHandler.class);
        HttpAuthenticationHandlerModuleDescriptor descriptor3 = mockDescriptor(30, handler3);

        when(pluginAccessor.getEnabledModuleDescriptorsByClass(any())).thenReturn(
                Lists.newArrayList(descriptor1, descriptor2, descriptor3));
        setupAuthenticationToken();

        assertEmpty(service.authenticate(context));

        InOrder inOrder = inOrder(handler1, handler2, handler3);
        inOrder.verify(handler1).authenticate(context);
        inOrder.verify(handler3).authenticate(context);
        inOrder.verify(handler2).authenticate(context);
    }

    private void assertAuthenticationToken(ApplicationUser expectedUser, Optional<StashUserAuthenticationToken> actualToken) {
        assertNotNull(actualToken);
        assertEquals("incorrect user", ofNullable(expectedUser), actualToken.map(StashUserAuthenticationToken::getPrincipal));
    }
    
    private HttpAuthenticationHandlerModuleDescriptor mockDescriptor(int weight, HttpAuthenticationHandler handler) {
        ModuleFactory factory = mock(ModuleFactory.class);
        return new TestableModuleDescriptor(factory, weight, handler);
    }

    private void setupCaptchaResponse() {
        when(moduleDescriptor.isCaptchaSupported()).thenReturn(true);
        when(session.getId()).thenReturn("123");
        when(request.getParameter(eq("captcha"))).thenReturn("response");
    }

    private void setupAuthenticationToken() {
        when(context.getCredentials()).thenReturn(PASSWORD);
        when(context.getUsername()).thenReturn(USERNAME);
    }

    private static class TestableModuleDescriptor extends HttpAuthenticationHandlerModuleDescriptor {

        private final HttpAuthenticationHandler handler;
        private final int weight;

        TestableModuleDescriptor(ModuleFactory moduleFactory, int weight, HttpAuthenticationHandler handler) {
            super(moduleFactory);

            this.handler = handler;
            this.weight = weight;
        }

        @Override
        public HttpAuthenticationHandler getModule() {
            return handler;
        }

        @Override
        public int getWeight() {
            return weight;
        }
    }
}
