package org.junit.internal.runners.statements;

import static java.lang.Long.MAX_VALUE;
import static java.lang.Math.atan;
import static java.lang.System.currentTimeMillis;
import static java.lang.Thread.currentThread;
import static java.lang.Thread.interrupted;
import static java.lang.Thread.sleep;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assume.assumeFalse;
import static org.junit.Assume.assumeTrue;

import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

import org.junit.Test;
import org.junit.function.ThrowingRunnable;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;
import org.junit.runners.Parameterized.Parameters;
import org.junit.runners.model.Statement;
import org.junit.runners.model.TestTimedOutException;


/**
 * @author Asaf Ary, Stefan Birkner
 */
@RunWith(Parameterized.class)
public class FailOnTimeoutTest {

    @Parameters(name = "lookingForStuckThread = {0}")
    public static Iterable<Boolean> getParameters() {
        return Arrays.asList(Boolean.TRUE, Boolean.FALSE);
    }

    @Parameter
    public boolean lookingForStuckThread;

    @Test
    public void noExceptionIsThrownWhenWrappedStatementFinishesBeforeTimeoutWithoutThrowingException()
            throws Throwable {
        FailOnTimeout failOnTimeout = failAfter50Ms(new FastStatement());

        failOnTimeout.evaluate();

        // test is successful when no exception is thrown
    }

    @Test
    public void throwsTestTimedOutExceptionWithMeaningfulMessage() {
        Exception e = assertThrows(
                TestTimedOutException.class,
                run(failAfter50Ms(new RunForASecond())));
        assertEquals("test timed out after 50 milliseconds", e.getMessage());
    }

    @Test
    public void sendUpExceptionThrownByStatement() {
        Exception exception = new RuntimeException();
        Exception e = assertThrows(
                Exception.class,
                run(failAfter50Ms(new Fail(exception))));
        assertSame(exception, e);
    }

    @Test
    public void throwExceptionIfTheSecondCallToEvaluateNeedsTooMuchTime()
            throws Throwable {
        DelegatingStatement statement = new DelegatingStatement();
        FailOnTimeout failOnTimeout = failAfter50Ms(statement);

        statement.delegate = new FastStatement();
        failOnTimeout.evaluate();

        statement.delegate = new RunForASecond();
        assertThrows(
                TestTimedOutException.class,
                run(failOnTimeout));
    }

    @Test
    public void throwTimeoutExceptionOnSecondCallAlthoughFirstCallThrowsException() {
        DelegatingStatement statement = new DelegatingStatement();
        FailOnTimeout failOnTimeout = failAfter50Ms(statement);

        statement.delegate = new Fail(new AssertionError("first execution failed"));
        assertThrows(
                AssertionError.class,
                run(failOnTimeout)
        );

        statement.delegate = new RunForASecond();
        assertThrows(
                TestTimedOutException.class,
                run(failOnTimeout));
    }

    @Test
    public void throwsExceptionWithTimeoutValueAndTimeUnitSet() {
        TestTimedOutException e = assertThrows(
                TestTimedOutException.class,
                run(failAfter50Ms(new RunForASecond())));
        assertEquals(50, e.getTimeout());
        assertEquals(MILLISECONDS, e.getTimeUnit());
    }

    @Test
    public void statementThatCanBeInterruptedIsStoppedAfterTimeout() throws Throwable {
        // RunForASecond can be interrupted because it checks the Thread's
        // interrupted flag.
        RunForASecond runForASecond = new RunForASecond();
        assertThrows(
                TestTimedOutException.class,
                run(failAfter50Ms(runForASecond)));

        // Thread is explicitly stopped if it finishes faster than its
        // pre-defined execution time of one second.
        boolean stopped = runForASecond.finished.await(50, MILLISECONDS);
        assertTrue("Thread has not been stopped.", stopped);
    }

    @Test
    public void stackTraceContainsRealCauseOfTimeout() {
        TestTimedOutException timedOutException = assertThrows(
                TestTimedOutException.class,
                run(failAfter50Ms(new StuckStatement())));

        StackTraceElement[] stackTrace = timedOutException.getStackTrace();
        boolean stackTraceContainsTheRealCauseOfTheTimeout = false;
        boolean stackTraceContainsOtherThanTheRealCauseOfTheTimeout = false;
        for (StackTraceElement element : stackTrace) {
            String methodName = element.getMethodName();
            if ("theRealCauseOfTheTimeout".equals(methodName)) {
                stackTraceContainsTheRealCauseOfTheTimeout = true;
            }
            if ("notTheRealCauseOfTheTimeout".equals(methodName)) {
                stackTraceContainsOtherThanTheRealCauseOfTheTimeout = true;
            }
        }
        assertTrue(
                "Stack trace does not contain the real cause of the timeout",
                stackTraceContainsTheRealCauseOfTheTimeout);
        assertFalse(
                "Stack trace contains other than the real cause of the timeout, which can be very misleading",
                stackTraceContainsOtherThanTheRealCauseOfTheTimeout);
    }

    private static final class StuckStatement extends Statement {

        @Override
        public void evaluate() throws Throwable {
            try {
                // Must show up in stack trace
                theRealCauseOfTheTimeout();
            } catch (InterruptedException e) {
            } finally {
                // Must _not_ show up in stack trace
                notTheRealCauseOfTheTimeout();
            }
        }

        private void theRealCauseOfTheTimeout() throws InterruptedException {
            sleep(MAX_VALUE);
        }

        private void notTheRealCauseOfTheTimeout() {
            for (long now = currentTimeMillis(), eta = now + 1000L; now < eta; now = currentTimeMillis()) {
                // Doesn't matter, just pretend to be busy
                atan(now);
            }
        }
    }

    @Test
    public void lookingForStuckThread_threadGroupNotLeaked() throws Throwable {
        assumeTrue(lookingForStuckThread);
        final AtomicReference<ThreadGroup> innerThreadGroup = new AtomicReference<ThreadGroup>();
        final AtomicReference<Thread> innerThread = new AtomicReference<Thread>();
        final ThreadGroup outerThreadGroup = currentThread().getThreadGroup();
        FailOnTimeout failOnTimeout = failAfter50Ms(new Statement() {
            @Override
            public void evaluate() {
                innerThread.set(currentThread());
                ThreadGroup group = currentThread().getThreadGroup();
                assertNotSame("inner thread should use a different thread group",
                        outerThreadGroup, group);
                innerThreadGroup.set(group);
                assertTrue("the 'FailOnTimeoutGroup' thread group should be a daemon thread group",
                        group.isDaemon());
            }
        });

        failOnTimeout.evaluate();

        assertNotNull("the Statement was never run", innerThread.get());
        innerThread.get().join();
        assertTrue("the 'FailOnTimeoutGroup' thread group should be destroyed after running the test",
                innerThreadGroup.get().isDestroyed());
    }

    @Test
    public void notLookingForStuckThread_usesSameThreadGroup() throws Throwable {
        assumeFalse(lookingForStuckThread);
        final AtomicBoolean statementWasExecuted = new AtomicBoolean();
        final ThreadGroup outerThreadGroup = currentThread().getThreadGroup();
        FailOnTimeout failOnTimeout = failAfter50Ms(new Statement() {
            @Override
            public void evaluate() {
                statementWasExecuted.set(true);
                ThreadGroup group = currentThread().getThreadGroup();
                assertSame("inner thread should use the same thread group", outerThreadGroup, group);
            }
        });

        failOnTimeout.evaluate();

        assertTrue("the Statement was never run", statementWasExecuted.get());
    }

    private FailOnTimeout failAfter50Ms(Statement statement) {
        return FailOnTimeout.builder()
                .withTimeout(50, MILLISECONDS)
                .withLookingForStuckThread(lookingForStuckThread)
                .build(statement);
    }

    private ThrowingRunnable run(final FailOnTimeout failOnTimeout) {
        return new ThrowingRunnable() {
            public void run() throws Throwable {
                failOnTimeout.evaluate();
            }
        };
    }

    private static class DelegatingStatement extends Statement {
        volatile Statement delegate;

        @Override
        public void evaluate() throws Throwable {
            delegate.evaluate();
        }
    }

    private static class FastStatement extends Statement {
        @Override
        public void evaluate() throws Throwable {
        }
    }

    private static final class RunForASecond extends Statement {
        final CountDownLatch finished = new CountDownLatch(1);

        @Override
        public void evaluate() throws Throwable {
            long timeout = currentTimeMillis() + 1000L;
            while (!interrupted() && currentTimeMillis() < timeout) {
            }
            finished.countDown();
        }
    }
}
