package com.evanlennick.retry4j;

import com.evanlennick.retry4j.backoff.BackoffStrategy;
import com.evanlennick.retry4j.config.RetryConfig;
import com.evanlennick.retry4j.config.RetryConfigBuilder;
import com.evanlennick.retry4j.exception.RetriesExhaustedException;
import com.evanlennick.retry4j.exception.UnexpectedException;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Random;
import java.util.concurrent.Callable;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import static org.assertj.core.api.Assertions.within;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class CallExecutorTest {

    @Mock
    private BackoffStrategy mockBackOffStrategy;

    private RetryConfigBuilder retryConfigBuilder;

    @BeforeMethod
    public void setup() {
        MockitoAnnotations.initMocks(this);

        boolean configValidationEnabled = false;
        this.retryConfigBuilder = new RetryConfigBuilder(configValidationEnabled);
    }

    @Test
    public void verifyReturningObjectFromCallSucceeds() throws Exception {
        Callable<Boolean> callable = () -> true;

        RetryConfig retryConfig = retryConfigBuilder
                .withMaxNumberOfTries(5)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        Status status = new CallExecutorBuilder().config(retryConfig).build()
                .execute(callable);
        assertThat(status.wasSuccessful());
    }

    @Test(expectedExceptions = {RetriesExhaustedException.class})
    public void verifyExceptionFromCallThrowsCallFailureException() throws Exception {
        Callable<Boolean> callable = () -> {
            throw new RuntimeException();
        };

        RetryConfig retryConfig = retryConfigBuilder
                .retryOnAnyException()
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        new CallExecutorBuilder().config(retryConfig).build()
                .execute(callable);
    }

    @Test(expectedExceptions = {RetriesExhaustedException.class})
    public void shouldMatchExceptionCauseAndRetry() throws Exception {
        Callable<Boolean> callable = () -> {
            throw new Exception(new CustomTestException("message", 3));
        };

        RetryConfig retryConfig = retryConfigBuilder
                .retryOnCausedBy()
                .retryOnSpecificExceptions(CustomTestException.class)
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        new CallExecutorBuilder().config(retryConfig).build().execute(callable);
    }

    @Test(expectedExceptions = {RetriesExhaustedException.class})
    public void shouldMatchExceptionCauseAtGreaterThanALevelDeepAndRetry() throws Exception {
        class CustomException extends Exception {

            CustomException(Throwable cause) {
                super(cause);
            }
        }
        Callable<Boolean> callable = () -> {
            throw new Exception(new CustomException(new RuntimeException(new IOException())));
        };

        RetryConfig retryConfig = retryConfigBuilder
                .retryOnCausedBy()
                .retryOnSpecificExceptions(IOException.class)
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        new CallExecutorBuilder().config(retryConfig).build().execute(callable);
    }

    @Test(expectedExceptions = {UnexpectedException.class})
    public void shouldThrowUnexpectedIfThrownExceptionCauseDoesNotMatchRetryExceptions() throws Exception {
        Callable<Boolean> callable = () -> {
            throw new Exception(new CustomTestException("message", 3));
        };

        RetryConfig retryConfig = retryConfigBuilder
                .retryOnCausedBy()
                .retryOnSpecificExceptions(IOException.class)
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        new CallExecutorBuilder().config(retryConfig).build().execute(callable);
    }

    @Test(expectedExceptions = {UnexpectedException.class})
    public void verifySpecificSuperclassExceptionThrowsUnexpectedException() throws Exception {
        Callable<Boolean> callable = () -> {
            throw new Exception();
        };

        RetryConfig retryConfig = retryConfigBuilder
                .retryOnSpecificExceptions(IOException.class)
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        new CallExecutorBuilder().config(retryConfig).build().execute(callable);
    }

    @Test(expectedExceptions = {RetriesExhaustedException.class})
    public void verifySpecificSubclassExceptionRetries() throws Exception {
        Callable<Boolean> callable = () -> {
            throw new IOException();
        };

        RetryConfig retryConfig = retryConfigBuilder
                .retryOnSpecificExceptions(Exception.class)
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        new CallExecutorBuilder().config(retryConfig).build().execute(callable);
    }

    @Test(expectedExceptions = {RetriesExhaustedException.class})
    public void verifyExactSameSpecificExceptionThrowsCallFailureException() throws Exception {
        Callable<Boolean> callable = () -> {
            throw new IllegalArgumentException();
        };

        RetryConfig retryConfig = retryConfigBuilder
                .retryOnSpecificExceptions(IllegalArgumentException.class)
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        new CallExecutorBuilder().config(retryConfig).build().execute(callable);
    }

    @Test(expectedExceptions = {UnexpectedException.class})
    public void verifyUnspecifiedExceptionCausesUnexpectedCallFailureException() throws Exception {
        Callable<Boolean> callable = () -> {
            throw new IllegalArgumentException();
        };

        RetryConfig retryConfig = retryConfigBuilder
                .retryOnSpecificExceptions(UnsupportedOperationException.class)
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        new CallExecutorBuilder().config(retryConfig).build().execute(callable);
    }

    @Test
    public void verifyStatusIsPopulatedOnSuccessfulCall() throws Exception {
        Callable<Boolean> callable = () -> true;

        RetryConfig retryConfig = retryConfigBuilder
                .withMaxNumberOfTries(5)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        Status<Boolean> status = new CallExecutorBuilder().config(retryConfig).build()
                .execute(callable);

        assertThat(status.getResult()).isNotNull();
        assertThat(status.wasSuccessful());
        assertThat(status.getCallName()).isNullOrEmpty();
        assertThat(status.getTotalElapsedDuration().toMillis()).isCloseTo(0, within(25L));
        assertThat(status.getTotalTries()).isEqualTo(1);
    }

    @Test
    public void verifyStatusIsPopulatedOnFailedCall() throws Exception {
        Callable<Boolean> callable = () -> {
            throw new FileNotFoundException();
        };

        RetryConfig retryConfig = retryConfigBuilder
                .withMaxNumberOfTries(5)
                .retryOnAnyException()
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .withFixedBackoff()
                .build();

        try {
            CallExecutor<Boolean> executor = new CallExecutorBuilder().config(retryConfig).build();
            executor.execute(callable, "TestCall");
            fail("RetriesExhaustedException wasn't thrown!");
        } catch (RetriesExhaustedException e) {
            Status status = e.getStatus();
            assertThat(status.getResult()).isNull();
            assertThat(status.wasSuccessful()).isFalse();
            assertThat(status.getCallName()).isEqualTo("TestCall");
            assertThat(status.getTotalElapsedDuration().toMillis()).isCloseTo(0, within(25L));
            assertThat(status.getTotalTries()).isEqualTo(5);
            assertThat(e.getCause()).isExactlyInstanceOf(FileNotFoundException.class);
        }
    }

    @Test
    public void verifyReturningObjectFromCallable() throws Exception {
        Callable<String> callable = () -> "test";

        RetryConfig retryConfig = retryConfigBuilder
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .build();

        Status status = new CallExecutorBuilder().config(retryConfig).build()
                .execute(callable);

        assertThat(status.getResult()).isEqualTo("test");
    }

    @Test
    public void verifyNullCallResultCountsAsValidResult() throws Exception {
        Callable<String> callable = () -> null;

        RetryConfig retryConfig = retryConfigBuilder
                .withMaxNumberOfTries(1)
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .build();

        try {
            new CallExecutorBuilder().config(retryConfig).build()
                    .execute(callable);
        } catch (RetriesExhaustedException e) {
            Status status = e.getStatus();
            assertThat(status.getResult()).isNull();
            assertThat(status.wasSuccessful()).isTrue();
        }
    }

    @Test
    public void verifyRetryingIndefinitely() throws Exception {
        Callable<Boolean> callable = () -> {
            Random random = new Random();
            if (random.nextInt(10000) == 0) {
                return true;
            }
            throw new IllegalArgumentException();
        };

        RetryConfig retryConfig = retryConfigBuilder
                .retryIndefinitely()
                .retryOnAnyException()
                .withFixedBackoff()
                .withDelayBetweenTries(0, ChronoUnit.SECONDS)
                .build();

        try {
            new CallExecutorBuilder().config(retryConfig).build()
                    .execute(callable);
        } catch (RetriesExhaustedException e) {
            fail("Retries should never be exhausted!");
        }
    }

    @Test
    public void verifyRetryPolicyTimeoutIsUsed() {
        Callable<Object> callable = () -> {
            throw new RuntimeException();
        };

        Duration delayBetweenTriesDuration = Duration.ofSeconds(17);
        when(mockBackOffStrategy.getDurationToWait(1, delayBetweenTriesDuration)).thenReturn(Duration.ofSeconds(5));

        RetryConfig retryConfig = retryConfigBuilder
                .withMaxNumberOfTries(2)
                .retryOnAnyException()
                .withDelayBetweenTries(delayBetweenTriesDuration)
                .withBackoffStrategy(mockBackOffStrategy)
                .build();

        final long before = System.currentTimeMillis();
        try {
            CallExecutor executor = new CallExecutorBuilder().config(retryConfig).build();
            executor.execute(callable);
        } catch (RetriesExhaustedException ignored) {
        }

        assertThat(System.currentTimeMillis() - before).isGreaterThan(5000);
        verify(mockBackOffStrategy).getDurationToWait(1, delayBetweenTriesDuration);
    }

    @Test
    public void verifyNoDurationSpecifiedSucceeds() {
        Callable<String> callable = () -> "test";

        RetryConfig noWaitConfig = new RetryConfigBuilder()
                .withMaxNumberOfTries(1)
                .withNoWaitBackoff()
                .build();

        CallExecutor executor = new CallExecutorBuilder().config(noWaitConfig).build();
        Status status = executor.execute(callable);

        assertThat(status.getResult()).isEqualTo("test");
    }
}