package org.zalando.riptide.chaos;

import com.github.restdriver.clientdriver.ClientDriver;
import com.github.restdriver.clientdriver.ClientDriverFactory;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.zalando.riptide.Http;

import java.io.IOException;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executors;

import static com.github.restdriver.clientdriver.RestClientDriver.giveEmptyResponse;
import static com.github.restdriver.clientdriver.RestClientDriver.onRequestTo;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.anEmptyMap;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.lessThan;
import static org.hamcrest.Matchers.oneOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE;
import static org.zalando.riptide.PassRoute.pass;
import static org.zalando.riptide.chaos.FailureInjection.composite;

final class ChaosPluginTest {

    private final ClientDriver driver = new ClientDriverFactory().createClientDriver();

    private final Probability latencyProbability = mock(Probability.class);
    private final Probability exceptionProbability = mock(Probability.class);
    private final Probability errorResponseProbability = mock(Probability.class);

    private final CloseableHttpClient client = HttpClientBuilder.create()
            .setDefaultRequestConfig(RequestConfig.custom()
                    .setSocketTimeout(1500)
                    .build())
            .build();

    private final Http unit = Http.builder()
            .executor(Executors.newSingleThreadExecutor())
            .requestFactory(new HttpComponentsClientHttpRequestFactory(client))
            .baseUrl(driver.getBaseUrl())
            .plugin(new ChaosPlugin(composite(
                    new LatencyInjection(latencyProbability, Clock.systemUTC(), Duration.ofSeconds(1)),
                    new ExceptionInjection(exceptionProbability, Arrays.asList(
                            ConnectException::new,
                            NoRouteToHostException::new
                    )),
                    new ErrorResponseInjection(errorResponseProbability, Arrays.asList(
                            INTERNAL_SERVER_ERROR,
                            SERVICE_UNAVAILABLE
                    ))
            )))
            .build();

    @AfterEach
    void tearDown() throws IOException {
        client.close();
    }

    @Test
    void shouldNotInjectError() {
        driver.addExpectation(onRequestTo("/foo"), giveEmptyResponse());

        unit.get("/foo")
                .call(pass())
                .join();
    }

    @Test
    void shouldInjectLatency() {
        when(latencyProbability.test()).thenReturn(true);

        driver.addExpectation(onRequestTo("/foo"), giveEmptyResponse());

        final Clock clock = Clock.systemUTC();
        final Instant start = clock.instant();

        unit.get("/foo")
                .call(pass())
                .join();

        final Instant end = clock.instant();

        assertThat(Duration.between(start, end), is(greaterThanOrEqualTo(Duration.ofSeconds(1))));
    }

    @Test
    void shouldNotInjectLatencyIfDelayedAlready() {
        when(latencyProbability.test()).thenReturn(true);

        driver.addExpectation(onRequestTo("/foo"), giveEmptyResponse().after(1, SECONDS));

        final Clock clock = Clock.systemUTC();
        final Instant start = clock.instant();

        unit.get("/foo")
                .call(pass())
                .join();

        final Instant end = clock.instant();

        assertThat(Duration.between(start, end), is(lessThan(Duration.ofSeconds(2))));
    }

    @Test
    void shouldInjectErrorResponse() throws IOException {
        when(errorResponseProbability.test()).thenReturn(true);

        driver.addExpectation(onRequestTo("/foo"), giveEmptyResponse());

        final ClientHttpResponse response = unit.get("/foo")
                .call(pass())
                .join();

        // users are required to close Closeable resources by contract
        response.close();

        assertThat(response.getStatusCode(), is(oneOf(INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE)));
        assertThat(response.getRawStatusCode(), is(oneOf(500, 503)));
        assertThat(response.getStatusText(), is(oneOf("Internal Server Error", "Service Unavailable")));
        assertThat(response.getHeaders(), is(anEmptyMap())); // TODO can we do better?
    }

    @Test
    void shouldNotInjectErrorResponseIfFailedAlready() throws IOException {
        when(errorResponseProbability.test()).thenReturn(true);

        driver.addExpectation(onRequestTo("/foo"), giveEmptyResponse().withStatus(400));

        final ClientHttpResponse response = unit.get("/foo")
                .call(pass())
                .join();

        assertThat(response.getStatusCode(), is(BAD_REQUEST));
    }

    @Test
    void shouldInjectException() {
        when(exceptionProbability.test()).thenReturn(true);

        driver.addExpectation(onRequestTo("/foo"), giveEmptyResponse());

        final CompletableFuture<ClientHttpResponse> future = unit.get("/foo")
                .call(pass());

        final CompletionException exception = assertThrows(CompletionException.class, future::join);

        assertThat(exception.getCause(), anyOf(
                instanceOf(ConnectException.class),
                instanceOf(NoRouteToHostException.class)));
    }

    @Test
    void shouldNotInjectExceptionIfThrownAlready() {
        when(exceptionProbability.test()).thenReturn(true);

        driver.addExpectation(onRequestTo("/foo"), giveEmptyResponse().after(2, SECONDS));

        final CompletableFuture<ClientHttpResponse> future = unit.get("/foo")
                .call(pass());

        final CompletionException exception = assertThrows(CompletionException.class, future::join);
        final Throwable cause = exception.getCause();

        assertThat(cause, is(instanceOf(SocketTimeoutException.class)));
    }

    @Test
    void shouldInjectLatencyAndErrorResponse() throws IOException {
        when(latencyProbability.test()).thenReturn(true);
        when(errorResponseProbability.test()).thenReturn(true);

        driver.addExpectation(onRequestTo("/foo"), giveEmptyResponse());

        final Clock clock = Clock.systemUTC();
        final Instant start = clock.instant();

        final ClientHttpResponse response = unit.get("/foo")
                .call(pass())
                .join();

        final Instant end = clock.instant();

        assertThat(Duration.between(start, end), is(greaterThanOrEqualTo(Duration.ofSeconds(1))));
        assertThat(response.getRawStatusCode(), is(oneOf(500, 503)));
    }

}