package org.mozilla.geckoview.test;

import org.mozilla.gecko.util.ThreadUtils;
import org.mozilla.geckoview.GeckoResult;
import org.mozilla.geckoview.test.util.Environment;
import org.mozilla.geckoview.test.util.UiThreadUtils;

import android.os.Handler;
import android.os.Looper;
import androidx.test.annotation.UiThreadTest;
import androidx.test.filters.MediumTest;
import androidx.test.ext.junit.runners.AndroidJUnit4;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;

import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertThat;

@RunWith(AndroidJUnit4.class)
@MediumTest
public class GeckoResultTest {
    private static class MockException extends RuntimeException {
    }

    private boolean mDone;

    private final Environment mEnv = new Environment();

    private void waitUntilDone() {
        assertThat("We should not be done", mDone, equalTo(false));
        UiThreadUtils.waitForCondition(() -> mDone, mEnv.getDefaultTimeoutMillis());
    }

    private void done() {
        UiThreadUtils.HANDLER.post(() -> mDone = true);
    }

    @Before
    public void setup() {
        mDone = false;
    }

    @Test
    @UiThreadTest
    public void thenWithResult() {
        GeckoResult.fromValue(42).accept(value -> {
            assertThat("Value should match", value, equalTo(42));
            done();
        });

        waitUntilDone();
    }

    @Test
    @UiThreadTest
    public void thenWithException() {
        final Throwable boom = new Exception("boom");
        GeckoResult.fromException(boom).accept(null, error -> {
            assertThat("Exception should match", error, equalTo(boom));
            done();
        });

        waitUntilDone();
    }

    @Test(expected = IllegalArgumentException.class)
    @UiThreadTest
    public void thenNoListeners() {
        GeckoResult.fromValue(42).then(null, null);
    }

    @Test
    @UiThreadTest
    public void testCopy() {
        final GeckoResult<Integer> result = new GeckoResult<>(GeckoResult.fromValue(42));
        result.accept(value -> {
            assertThat("Value should match", value, equalTo(42));
            done();
        });

        waitUntilDone();
    }

    @Test
    @UiThreadTest
    public void allOfError() throws Throwable {
        final GeckoResult<List<Integer>> result = GeckoResult.allOf(
                new GeckoResult<>(GeckoResult.fromValue(12)),
                new GeckoResult<>(GeckoResult.fromValue(35)),
                new GeckoResult<>(GeckoResult.fromException(
                        new RuntimeException("Sorry not sorry"))),
                new GeckoResult<>(GeckoResult.fromValue(0)));

        UiThreadUtils.waitForResult(result.accept(
            value -> { throw new AssertionError("result should fail"); },
            error -> {
                assertThat("Error should match", error instanceof RuntimeException, is(true));
                assertThat("Error should match", error.getMessage(), equalTo("Sorry not sorry"));
            }), mEnv.getDefaultTimeoutMillis());
    }

    @Test
    @UiThreadTest
    public void allOfEmpty() {
        final GeckoResult<List<Integer>> result = GeckoResult.allOf();

        result.accept(value -> {
            assertThat("Value should match", value.isEmpty(), is(true));
            done();
        });

        waitUntilDone();
    }

    @Test
    @UiThreadTest
    public void allOfNull() {
        final GeckoResult<List<Integer>> result = GeckoResult.allOf(
                (List<GeckoResult<Integer>>) null);

        result.accept(value -> {
            assertThat("Value should match", value, equalTo(null));
            done();
        });

        waitUntilDone();
    }

    @Test
    @UiThreadTest
    public void allOfMany() {
        final GeckoResult<Integer> pending1 = new GeckoResult<>();
        final GeckoResult<Integer> pending2 = new GeckoResult<>();

        final GeckoResult<List<Integer>> result = GeckoResult.allOf(
                pending1,
                new GeckoResult<>(GeckoResult.fromValue(12)),
                pending2,
                new GeckoResult<>(GeckoResult.fromValue(35)),
                new GeckoResult<>(GeckoResult.fromValue(9)),
                new GeckoResult<>(GeckoResult.fromValue(0)));

        result.accept(value -> {
            assertThat("Value should match", value, equalTo(
                    Arrays.asList(123, 12, 321, 35, 9, 0)));
            done();
        });

        try {
            Thread.sleep(50);
        } catch (final InterruptedException ex) {
        }

        // Complete the results out of order so that we can verify the input order is preserved
        pending2.complete(321);
        pending1.complete(123);
        waitUntilDone();
    }

    @Test(expected = IllegalStateException.class)
    @UiThreadTest
    public void completeMultiple() {
        final GeckoResult<Integer> deferred = new GeckoResult<>();
        deferred.complete(42);
        deferred.complete(43);
    }

    @Test(expected = IllegalStateException.class)
    @UiThreadTest
    public void completeMultipleExceptions() {
        final GeckoResult<Integer> deferred = new GeckoResult<>();
        deferred.completeExceptionally(new Exception("boom"));
        deferred.completeExceptionally(new Exception("boom again"));
    }

    @Test(expected = IllegalStateException.class)
    @UiThreadTest
    public void completeMixed() {
        final GeckoResult<Integer> deferred = new GeckoResult<>();
        deferred.complete(42);
        deferred.completeExceptionally(new Exception("boom again"));
    }

    @Test(expected = IllegalArgumentException.class)
    @UiThreadTest
    public void completeExceptionallyNull() {
        new GeckoResult<Integer>().completeExceptionally(null);
    }

    @Test
    @UiThreadTest
    public void completeThreaded() {
        final GeckoResult<Integer> deferred = new GeckoResult<>();
        final Thread thread = new Thread(() -> deferred.complete(42));

        deferred.accept(value -> {
            assertThat("Value should match", value, equalTo(42));
            ThreadUtils.assertOnUiThread();
            done();
        });

        thread.start();
        waitUntilDone();
    }

    @Test
    @UiThreadTest
    public void dispatchOnInitialThread() throws InterruptedException {
        final Thread thread = new Thread(() -> {
            Looper.prepare();
            final Thread dispatchThread = Thread.currentThread();

            GeckoResult.fromValue(42).accept(value -> {
                assertThat("Thread should match", Thread.currentThread(),
                        equalTo(dispatchThread));
                Looper.myLooper().quit();
            });

            Looper.loop();
        });

        thread.start();
        thread.join();
    }

    @Test
    @UiThreadTest
    public void completeExceptionallyThreaded() {
        final GeckoResult<Integer> deferred = new GeckoResult<>();
        final Throwable boom = new Exception("boom");
        final Thread thread = new Thread(() -> deferred.completeExceptionally(boom));

        deferred.exceptionally(error -> {
            assertThat("Exception should match", error, equalTo(boom));
            ThreadUtils.assertOnUiThread();
            done();
            return null;
        });

        thread.start();
        waitUntilDone();
    }

    @UiThreadTest
    @Test
    public void resultMapChaining() {
        assertThat("We're on the UI thread", Thread.currentThread(), equalTo(Looper.getMainLooper().getThread()));

        GeckoResult.fromValue(42).map(value -> {
            assertThat("Value should match", value, equalTo(42));
            return "hello";
        }).map(value -> {
            assertThat("Value should match", value, equalTo("hello"));
            return 42.0f;
        }).map(value -> {
            assertThat("Value should match", value, equalTo(42.0f));
            throw new Exception("boom");
        }).map(null, error -> {
            assertThat("Error message should match", error.getMessage(), equalTo("boom"));
            return new MockException();
        }).accept(null, exception -> {
            assertThat("Exception should be MockException", exception, instanceOf(MockException.class));
            done();
        });

        waitUntilDone();
    }

    @UiThreadTest
    @Test
    public void resultChaining() {
        assertThat("We're on the UI thread", Thread.currentThread(), equalTo(Looper.getMainLooper().getThread()));

        GeckoResult.fromValue(42).then(value -> {
            assertThat("Value should match", value, equalTo(42));
            return GeckoResult.fromValue("hello");
        }).then(value -> {
            assertThat("Value should match", value, equalTo("hello"));
            return GeckoResult.fromValue(42.0f);
        }).then(value -> {
            assertThat("Value should match", value, equalTo(42.0f));
            return GeckoResult.fromException(new Exception("boom"));
        }).exceptionally(error -> {
            assertThat("Error message should match", error.getMessage(), equalTo("boom"));
            throw new MockException();
        }).accept(null, exception -> {
            assertThat("Exception should be MockException", exception, instanceOf(MockException.class));
            done();
        });

        waitUntilDone();
    }

    @UiThreadTest
    @Test
    public void then_propagatedValue() {
        // The first GeckoResult only has an exception listener, so when the value 42 is
        // propagated to subsequent GeckoResult instances, the propagated value is coerced to null.
        GeckoResult.fromValue(42).exceptionally(error -> null)
        .accept(value -> {
            assertThat("Propagated value is null", value, nullValue());
            done();
        });

        waitUntilDone();
    }

    @UiThreadTest
    @Test(expected = GeckoResult.UncaughtException.class)
    public void then_uncaughtException() {
        GeckoResult.fromValue(42).then(value -> {
            throw new MockException();
        });

        waitUntilDone();
    }

    @UiThreadTest
    @Test(expected = GeckoResult.UncaughtException.class)
    public void then_propagatedUncaughtException() {
        GeckoResult.fromValue(42).then(value -> {
            throw new MockException();
        }).accept(value -> {});

        waitUntilDone();
    }

    @UiThreadTest
    @Test
    public void then_caughtException() {
        GeckoResult.fromValue(42).then(value -> { throw new MockException(); })
            .accept(value -> {})
            .exceptionally(exception -> {
                assertThat("Exception should be expected",
                           exception, instanceOf(MockException.class));
                done();
                return null;
            });

        waitUntilDone();
    }

    @Test(expected = IllegalThreadStateException.class)
    public void noLooperThenThrows() {
        assertThat("We shouldn't have a Looper", Looper.myLooper(), nullValue());
        GeckoResult.fromValue(42).then(value -> null);
    }

    @Test
    public void noLooperPoll() throws Throwable {
        assertThat("We shouldn't have a Looper", Looper.myLooper(), nullValue());
        assertThat("Value should match",
                GeckoResult.fromValue(42).poll(0), equalTo(42));
    }

    @Test
    public void withHandler() {

        final SynchronousQueue<Handler> queue = new SynchronousQueue<>();
        final Thread thread = new Thread(() -> {
            Looper.prepare();

            try {
                queue.put(new Handler());
            } catch (final InterruptedException e) {
                throw new RuntimeException(e);
            }

            Looper.loop();
        });

        thread.start();

        final GeckoResult<Integer> result = GeckoResult.fromValue(42);
        assertThat("We shouldn't have a Looper", result.getLooper(), nullValue());

        try {
            result.withHandler(queue.take()).accept(value -> {
                assertThat("Thread should match", Thread.currentThread(), equalTo(thread));
                assertThat("Value should match", value, equalTo(42));
                Looper.myLooper().quit();
            });

            thread.join();
        } catch (final InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    @Test
    public void pollCompleteWithValue() throws Throwable {
        assertThat("Value should match",
                GeckoResult.fromValue(42).poll(0), equalTo(42));
    }

    @Test(expected = MockException.class)
    public void pollCompleteWithError() throws Throwable {
        GeckoResult.fromException(new MockException()).poll(0);
    }


    @Test(expected = TimeoutException.class)
    public void pollTimeout() throws Throwable {
        new GeckoResult<Void>().poll(1);
    }

    @UiThreadTest
    @Test(expected = TimeoutException.class)
    public void pollTimeoutWithLooper() throws Throwable {
        new GeckoResult<Void>().poll(1);
    }

    @UiThreadTest
    @Test(expected = IllegalThreadStateException.class)
    public void pollWithLooper() throws Throwable {
        new GeckoResult<Void>().poll();
    }

    @UiThreadTest
    @Test
    public void cancelNoDelegate() {
        final GeckoResult<Void> result = new GeckoResult<Void>();
        result.cancel().accept(value -> {
            assertThat("Cancellation should fail", value, equalTo(false));
            done();
        });
        waitUntilDone();
    }

    private GeckoResult<Integer> createCancellableResult() {
        final GeckoResult<Integer> result = new GeckoResult<>();
        result.setCancellationDelegate(new GeckoResult.CancellationDelegate() {
            @Override
            public GeckoResult<Boolean> cancel() {
                return GeckoResult.fromValue(true);
            }
        });

        return result;
    }

    @UiThreadTest
    @Test
    public void cancelSuccess() {
        final GeckoResult<Integer> result = createCancellableResult();

        result.cancel().accept(value -> {
            assertThat("Cancel should succeed", value, equalTo(true));
            result.exceptionally(exception -> {
                assertThat("Exception should match", exception, instanceOf(CancellationException.class));
                done();

                return null;
            });
        });

        waitUntilDone();
    }

    @UiThreadTest
    @Test
    public void cancelCompleted() {
        final GeckoResult<Integer> result = createCancellableResult();
        result.complete(42);
        result.cancel().accept(value -> {
            assertThat("Cancel should fail", value, equalTo(false));
            done();
        });

        waitUntilDone();
    }

    @UiThreadTest
    @Test
    public void cancelParent() {
        final GeckoResult<Integer> result = createCancellableResult();
        final GeckoResult<Integer> result2 = result.then(value -> GeckoResult.fromValue(42));

        result.cancel().accept(value -> {
            assertThat("Cancel should succeed", value, equalTo(true));
            result2.exceptionally(exception -> {
                assertThat("Exception should match", exception, instanceOf(CancellationException.class));
                done();

                return null;
            });
        });

        waitUntilDone();
    }

    @UiThreadTest
    @Test
    public void cancelChildParentNotComplete() {
        final GeckoResult<Integer> result = new GeckoResult<Integer>()
                .then(value -> createCancellableResult())
                .then(value -> new GeckoResult<Integer>());

        result.cancel().accept(value -> {
            assertThat("Cancel should fail", value, equalTo(false));
            done();
        });

        waitUntilDone();
    }

    @UiThreadTest
    @Test
    public void cancelChildParentComplete() {
        final GeckoResult<Integer> result = GeckoResult.fromValue(42)
                .then(value -> createCancellableResult())
                .then(value -> new GeckoResult<Integer>());

        final Handler handler = new Handler();
        handler.post(() -> {
            result.cancel().accept(value -> {
                assertThat("Cancel should succeed", value, equalTo(true));
                done();
            });
        });

        waitUntilDone();
    }

    @UiThreadTest
    @Test
    public void getOrAccept() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        final Method ai = GeckoResult.class.getDeclaredMethod("getOrAccept", GeckoResult.Consumer.class);
        ai.setAccessible(true);

        final AtomicBoolean ran = new AtomicBoolean(false);
        ai.invoke(GeckoResult.fromValue(42), (GeckoResult.Consumer<Integer>) o -> ran.set(true));
        assertThat("Should've ran", ran.get(), equalTo(true));
    }
}
