package com.atlassian.bitbucket.internal.mirroring.mirror.client;

import com.atlassian.bitbucket.internal.mirroring.mirror.*;
import com.atlassian.bitbucket.internal.mirroring.mirror.auth.OAuthAccessToken;
import com.atlassian.bitbucket.internal.mirroring.mirror.nav.MirroringUrl;
import com.atlassian.bitbucket.mockito.MockitoUtils;
import com.atlassian.httpclient.api.Request;
import com.atlassian.httpclient.api.Response;
import com.atlassian.httpclient.api.ResponsePromise;
import com.atlassian.httpclient.api.ResponsePromises;
import com.atlassian.util.concurrent.Promises;
import com.google.common.collect.ImmutableList;
import org.apache.commons.lang3.StringUtils;
import org.hamcrest.BaseMatcher;
import org.hamcrest.Description;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;

import javax.validation.ConstraintViolation;
import javax.ws.rs.core.MediaType;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.atlassian.bitbucket.mirroring.mirror.UpstreamServerType.BITBUCKET_SERVER;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.*;
import static org.mockito.Matchers.anyMap;
import static org.mockito.Matchers.anyMapOf;
import static org.mockito.Matchers.anyObject;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.*;

@RunWith(MockitoJUnitRunner.class)
public class DefaultCloudUpstreamClientTest extends UpstreamClientTestBase<DefaultCloudUpstreamClient> {
    public static final int PAGE_SIZE = 2;

    private static final String ADDON_KEY = "bitbucket.mirror.server-id";
    private static final String BASE_URL = "https://www.bitbucket.org";
    private static final String BASE_API_URL = "https://api.bitbucket.org";
    private static final String TOKEN_VALUE = "something_random_here";
    private static final String TOKEN_VALUE_AUTH_HEADER = "Basic eC10b2tlbi1hdXRoOnNvbWV0aGluZ19yYW5kb21faGVyZQ==";
    private static final String UPSTREAM_ID = "upstream-id";

    @Mock
    private MirroringConfig config;

    @Before
    public void setUp() {
        when(upstream.getBaseUrl()).thenReturn(BASE_URL);
        when(upstream.getApiBaseUrl()).thenReturn(BASE_API_URL);
        when(upstream.getType()).thenReturn(BITBUCKET_SERVER);
        when(upstream.getId()).thenReturn(UPSTREAM_ID);

        when(config.getUpstreamRepositoryRequestPageSize()).thenReturn(PAGE_SIZE);
        when(config.getAddonKey()).thenReturn(ADDON_KEY);

        requestBuilder = mock(Request.Builder.class, MockitoUtils.returnSelf(Request.Builder.class));

        //noinspection unchecked
        when(jsonRenderer.render(anyObject(), anyMap())).thenAnswer(
                invocationOnMock -> toJson(invocationOnMock.getArguments()[0]));
        when(validator.validate(anyObject())).thenReturn(Collections.<ConstraintViolation<Object>>emptySet());

        promise = ResponsePromises.toResponsePromise(Promises.promise(response));

        client = new DefaultCloudUpstreamClient(config, httpClient, i18nService, jsonRenderer,
                mirroringNavBuilder, requestFactory, upstream, validator);
    }

    @Test
    public void testCanReadRepositorySsh() throws Exception {
        mockJsonJwtPostResponse(mirroringNavBuilder.cloudUpstream(upstream).repositoryPermissionBySlug("repo"), 204);

        assertEquals(CloudAuthenticationResult.OK, client.authenticate("repo", "ssh-dss key").claim());
    }

    @Test
    public void testCantReadRepositorySsh400() throws Exception {
        mockJsonJwtPostResponse(mirroringNavBuilder.cloudUpstream(upstream).repositoryPermissionBySlug("repo"), 400);
        assertEquals(CloudAuthenticationResult.ERROR, client.authenticate("repo", "ssh-dss key").claim());
    }

    @Test
    public void testCantReadRepositorySsh401() throws Exception {
        mockJsonJwtPostResponse(mirroringNavBuilder.cloudUpstream(upstream).repositoryPermissionBySlug("repo"),
                "{ \"data\": { \"error_code\": \"key_unknown\" }, \"error\": {} }", 401);
        assertEquals(CloudAuthenticationResult.UNAUTHORIZED, client.authenticate("repo", "ssh-dss key").claim());
    }

    @Test
    public void testCantReadRepositorySsh403() throws Exception {
        mockJsonJwtPostResponse(mirroringNavBuilder.cloudUpstream(upstream).repositoryPermissionBySlug("repo"),
                "{ \"data\": { \"error_code\": \"key_forbidden\" }, \"error\": {} }", 403);
        assertEquals(CloudAuthenticationResult.FORBIDDEN, client.authenticate("repo", "ssh-dss key").claim());
    }

    @Test
    public void testCantReadRepositorySsh500() throws Exception {
        mockJsonJwtPostResponse(mirroringNavBuilder.cloudUpstream(upstream).repositoryPermissionBySlug("repo"), 500);
        assertEquals(CloudAuthenticationResult.ERROR, client.authenticate("repo", "ssh-dss key").claim());
    }

    @Test
    public void testCanReadRepositoryHttp() throws Exception {
        mockHttpHeadResponse(mirroringNavBuilder.cloudUpstream(upstream).repositoryBySlug("repo"), 200);

        assertTrue(client.canReadRepository("repo", "Basic auth").claim());
    }

    @Test
    public void testCantReadRepositoryHttp() throws Exception {
        Integer[] failureCodes = new Integer[]{400, 401, 403, 500};

        for (int code : failureCodes) {
            mockHttpHeadResponse(mirroringNavBuilder.cloudUpstream(upstream).repositoryBySlug("repo"), code);
            assertFalse("Failure code " + code + " should disallow reading the repository",
                    client.canReadRepository("repo", "Basic auth").claim());
        }
    }

    @Test
    @SuppressWarnings("unchecked")
    public void testAccessToken() throws Exception {
        String token = "something_random_here";
        mockJwtPostResponse(mirroringNavBuilder.cloudUpstream(upstream).oauth2().accessToken(), mockAuthToken(token), 200);

        assertThat(client.getAccessToken().claim(), is(new AuthTokenMatcher(token)));
    }

    @Test(expected = UpstreamServerRequestFailedException.class)
    public void testAccessTokenNotFound() throws Exception {
        mockJwtPostResponse(mirroringNavBuilder.cloudUpstream(upstream).oauth2().accessToken(), "", 404);

        client.getAccessToken().claim();
    }

    @Test
    public void testGetEmptyPageOfRepositories() throws ExecutionException, InterruptedException {
        String responseEntity = jsonPage(PAGE_SIZE, null);

        mockJwtGetResponse(mirroringNavBuilder.cloudUpstream(upstream).repositories(PAGE_SIZE), responseEntity, 200);

        Stream<ExternalRepository> repositories = client.getRepositories();

        assertNotNull(repositories);
        List<ExternalRepository> values = ImmutableList.copyOf(repositories.iterator());
        assertTrue(values.isEmpty());
    }

    @Test
    public void testGetSinglePageOfRepositories() throws ExecutionException, InterruptedException {
        String jsonProject = jsonProject(9, "name9", "key9", false);
        String responseEntity = jsonPage(PAGE_SIZE, null,
                jsonRepo(1, "slug1", "git", false, jsonProject),
                jsonRepo(2, "slug2", "git", true, jsonProject));

        mockJwtGetResponse(mirroringNavBuilder.cloudUpstream(upstream).repositories(PAGE_SIZE), responseEntity, 200);

        Stream<ExternalRepository> repositories = client.getRepositories();

        assertNotNull(repositories);
        List<ExternalRepository> values = ImmutableList.copyOf(repositories.iterator());
        assertEquals(2, values.size());
        assertRepo(1, "slug1", "slug1", "git", false, values.get(0));
        assertRepo(2, "slug2", "slug2", "git", true, values.get(1));
    }

    @Test
    public void testGetMultiplePagesOfRepositories() throws ExecutionException, InterruptedException {
        String jsonProject = jsonProject(9, "name9", "key9", false);
        String page1ResponseEntity = jsonPage(PAGE_SIZE, BASE_API_URL + "/2.0/repositories/team?page=1",
                jsonRepo(1, "slug1", "git", false, jsonProject),
                jsonRepo(2, "slug2", "git", true, jsonProject));
        String page2ResponseEntity = jsonPage(PAGE_SIZE, BASE_API_URL + "/2.0/repositories/team?page=2",
                jsonRepo(3, "slug3", "git", false, jsonProject),
                jsonRepo(4, "slug4", "git", true, jsonProject));
        String page3ResponseEntity = jsonPage(PAGE_SIZE, null,
                jsonRepo(5, "slug5", "git", false, jsonProject),
                jsonRepo(6, "slug6", "git", true, jsonProject));

        Response response1 = mock(Response.class);
        mockResponse(response1, page1ResponseEntity, 200);
        ResponsePromise responsePromise1 = ResponsePromises.toResponsePromise(Promises.promise(response1));

        Response response2 = mock(Response.class);
        mockResponse(response2, page2ResponseEntity, 200);
        ResponsePromise responsePromise2 = ResponsePromises.toResponsePromise(Promises.promise(response2));

        Response response3 = mock(Response.class);
        mockResponse(response3, page3ResponseEntity, 200);
        ResponsePromise responsePromise3 = ResponsePromises.toResponsePromise(Promises.promise(response3));

        when(requestBuilder.get()).thenReturn(responsePromise1, responsePromise2, responsePromise3);
        mockJwtRequest(mirroringNavBuilder.cloudUpstream(upstream).repositories(PAGE_SIZE));
        mockJwtRequest(BASE_API_URL + "/2.0/repositories/team?page=1");
        mockJwtRequest(BASE_API_URL + "/2.0/repositories/team?page=2");

        Stream<ExternalRepository> repositories = client.getRepositories();

        assertNotNull(repositories);
        List<ExternalRepository> values = ImmutableList.copyOf(repositories.iterator());
        assertEquals(6, values.size());
        assertRepo(1, "slug1", "slug1", "git", false, values.get(0));
        assertRepo(2, "slug2", "slug2", "git", true, values.get(1));
        assertRepo(3, "slug3", "slug3", "git", false, values.get(2));
        assertRepo(4, "slug4", "slug4", "git", true, values.get(3));
        assertRepo(5, "slug5", "slug5", "git", false, values.get(4));
        assertRepo(6, "slug6", "slug6", "git", true, values.get(5));
    }

    @Test(expected = UnauthorizedException.class)
    public void testGetRepositoriesNotAuthorized() throws ExecutionException, InterruptedException {
        mockJwtRequest(mirroringNavBuilder.cloudUpstream(upstream).repositories(PAGE_SIZE));
        when(requestBuilder.get()).thenReturn(promise);
        mockResponse(response, "", 401);

        ImmutableList.copyOf(client.getRepositories().iterator());
    }

    @Test(expected = UpstreamServerRequestFailedException.class)
    public void testGetRepositoriesNotFound() throws ExecutionException, InterruptedException {
        mockJwtRequest(mirroringNavBuilder.cloudUpstream(upstream).repositories(PAGE_SIZE));
        when(requestBuilder.get()).thenReturn(promise);
        mockResponse(response, "", 404);
        when(response.getContentType()).thenReturn(MediaType.APPLICATION_JSON);

        ImmutableList.copyOf(client.getRepositories().iterator());
    }

    @Test(expected = RepositoryRequestRateExceededException.class)
    public void testGetRepositoriesRateLimitExceeded() throws ExecutionException, InterruptedException {
        mockJwtRequest(mirroringNavBuilder.cloudUpstream(upstream).repositories(PAGE_SIZE));
        when(requestBuilder.get()).thenReturn(promise);
        mockResponse(response, "", 429);

        client.getRepositories().collect(Collectors.toList());
    }

    @Test
    public void testIsMirrorInstalled() throws Exception {
        mockJwtRequest(mirroringNavBuilder.cloudUpstream(upstream).addonStatus(ADDON_KEY));
        when(requestBuilder.get()).thenReturn(promise);
        mockResponse(response, "", 204);
        assertTrue(client.isMirrorInstalled().claim());
    }

    @Test
    public void testIsMirrorNoLongerInstalled() throws Exception {
        mockJwtRequest(mirroringNavBuilder.cloudUpstream(upstream).addonStatus(ADDON_KEY));
        when(requestBuilder.get()).thenReturn(promise);
        mockResponse(response, "", 401);
        assertFalse(client.isMirrorInstalled().claim());
    }

    @Test
    public void testIsMirrorRemoved() throws Exception {
        mockJwtRequest(mirroringNavBuilder.cloudUpstream(upstream).addonStatus(ADDON_KEY));
        when(requestBuilder.get()).thenReturn(promise);
        mockResponse(response, "", 404);
        assertFalse(client.isMirrorInstalled().claim());
    }

    @Test
    public void testGetRepositoryNotFound() throws ExecutionException, InterruptedException {
        mockJwtRequest(mirroringNavBuilder.upstream(upstream).repositoryById("1"));
        when(requestBuilder.get()).thenReturn(promise);
        mockResponse(response, "", 404);

        assertNull(client.getRepository("1").claim());
    }

    @Test
    public void testNewScmHttpRequest()  throws Exception {
        String farIntoTheFuture = "999999999";
        String relativePath =  "relativePath";

        setupScmHttpRequest(farIntoTheFuture);

        client.newScmHttpRequest(relativePath);
        client.newScmHttpRequest(relativePath);
        client.newScmHttpRequest(relativePath);

        verify(httpClient, times(3)).newRequest(mirroringNavBuilder.upstream(upstream).relativeToBase(relativePath).getAbsolute());
        verify(requestBuilder, times(3)).setHeader("Authorization", TOKEN_VALUE_AUTH_HEADER);

        //Verify that the cache was used and the token was requested twice
        verify(requestFactory, times(2)).newRequest(upstream, mirroringNavBuilder.cloudUpstream(upstream).oauth2().accessToken());
    }

    private void setupScmHttpRequest(String expiryTimestamp) throws Exception {
        mockJwtPostResponse(mirroringNavBuilder.cloudUpstream(upstream).oauth2().accessToken(), mockAuthToken(TOKEN_VALUE, expiryTimestamp), 200);
        when(httpClient.newRequest(anyString())).thenReturn(requestBuilder);
    }

    @Test
    public void testNewScmHttpRequestCacheExpiry()  throws Exception {
        String relativePath = "relativePath";

        long start = System.currentTimeMillis();
        long expiresIn = 1L;

        setupScmHttpRequest(String.valueOf(expiresIn));

        client.newScmHttpRequest(relativePath);
        client.newScmHttpRequest(relativePath);

        while (System.currentTimeMillis() < (start + TimeUnit.SECONDS.toMillis(expiresIn) + 200)) {
            // wait for token expiry
        }
        client.newScmHttpRequest(relativePath);

        verify(httpClient, times(3)).newRequest(mirroringNavBuilder.upstream(upstream).relativeToBase(relativePath).getAbsolute());
        verify(requestBuilder, times(3)).setHeader("Authorization", TOKEN_VALUE_AUTH_HEADER);

        //Verify that the cache expired and re-requested a fresh token
        verify(requestFactory, times(3)).newRequest(upstream, mirroringNavBuilder.cloudUpstream(upstream).oauth2().accessToken());
    }

    @Override
    @SuppressWarnings("Duplicates")
    protected void assertRepo(Integer id, String name, String slug, String scmId, boolean isPublic, ExternalRepository actual) {
        assertEquals(String.valueOf(id), actual.getId());
        // name and slug are equivalent in cloud - use slug
        assertEquals(slug, actual.getName());
        assertEquals(slug, actual.getSlug());
        assertEquals(scmId, actual.getScmId());
        assertEquals(isPublic, actual.isPublic());
    }

    @Override
    protected String jsonProject(Object id, String name, String key, boolean isPublic) {
        return "{\"uuid\": \"" + id + "\"" +
                ", \"name\": \"" + name + "\"" +
                ", \"key\": \"" + key + "\"" +
                ", \"is_private\": " + !isPublic +
                "}";
    }

    @Override
    protected String jsonRepo(Object id, String name, String slug, String scmId, boolean isPublic, String jsonProject) {
        // name and slug are equivalent in cloud - use slug
        return "{\"uuid\": \"" + id + "\"" +
                ", \"name\": \"" + slug + "\"" +
                ", \"full_name\": \"owner/" + slug + "\"" +
                ", \"scm\": \"" + scmId + "\"" +
                ", \"project\": " + jsonProject +
                ", \"is_private\": " + !isPublic +
                "}";
    }

    private static String mockAuthToken(String value) {
        return mockAuthToken(value, "42");
    }

    private static String mockAuthToken(String value, String expiresIn) {
        return "{ \"access_token\": \"" + value + "\"," +
                " \"expires_in\": " + expiresIn + " }";
    }

    private void mockHttpRequest(String absoluteUrl) {
        when(httpClient.newRequest(eq(absoluteUrl))).thenReturn(requestBuilder);
    }

    private void mockHttpHeadResponse(MirroringUrl url, int statusCode) throws Exception {
        mockHttpRequest(url.getAbsolute());
        when(requestBuilder.setHeaders(anyMapOf(String.class, String.class))).thenReturn(requestBuilder);
        when(requestBuilder.head()).thenReturn(promise);
        mockResponse(response, "", statusCode);
    }

    private void mockJsonJwtPostResponse(MirroringUrl url, int statusCode) throws Exception {
        mockJsonJwtPostResponse(url, "", statusCode);
    }

    private void mockJsonJwtPostResponse(MirroringUrl url, String responseEntity, int statusCode) throws Exception {
        mockJwtPostResponse(url, responseEntity, statusCode);
        when(requestBuilder.setContentType(anyString())).thenReturn(requestBuilder);
        when(requestBuilder.setEntity(anyString())).thenReturn(requestBuilder);
    }

    private String jsonPage(int limit, String nextPageUrl, String... values) {
        return "{\"pagelen\": " + limit +
                ", \"size\": " + values.length +
                (nextPageUrl == null ? "" : ", \"next\": \"" + nextPageUrl + "\"") +
                ", \"values\": [" + StringUtils.join(values, ",") + "]" +
                "}";
    }

    private class AuthTokenMatcher extends BaseMatcher {

        private final String value;

        AuthTokenMatcher(String value) {
            this.value = value;
        }

        @Override
        public boolean matches(Object item) {
            return item instanceof OAuthAccessToken &&
                    value.equals(((OAuthAccessToken) item).getValue());
        }

        @Override
        public void describeTo(Description description) {
            description.appendText(String.format("OAuth token with value of %s", value));
        }
    }

    private String jsonRepo(Integer id, String slug, String scmId, boolean isPublic, String jsonProject) {
        return jsonRepo(id, slug, slug, scmId, isPublic, jsonProject);
    }
}