package com.atlassian.bitbucket.mesh;

import com.atlassian.bitbucket.mesh.cluster.WiredClusterNode;
import com.atlassian.bitbucket.mesh.config.ApplicationConfig;
import com.atlassian.bitbucket.mesh.grpc.MeshGrpcWiring;
import com.atlassian.bitbucket.mesh.test.cluster.MeshCluster;
import com.atlassian.bitbucket.mesh.test.context.DelegatingTestExecutionListener;
import com.atlassian.bitbucket.mesh.util.Version;
import com.google.common.collect.ImmutableMap;
import org.hamcrest.Matcher;
import org.hamcrest.StringDescription;
import org.junit.runner.RunWith;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.Resource;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.ServerSocket;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;

import static com.atlassian.bitbucket.mesh.MeshConstants.PROFILE_SIDECAR;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.fail;

@ActiveProfiles({PROFILE_SIDECAR}) //Required for MeshCoreWiring
@ContextConfiguration(classes = {MeshTestWiring.class, MeshGrpcWiring.class},
        initializers = AbstractWiredTest.Initializer.class)
@MeshCluster(2) //Ensure a 2 node cluster + the local node is made available to the test
@RunWith(SpringJUnit4ClassRunner.class)
@TestExecutionListeners(DelegatingTestExecutionListener.class)
@TestPropertySource(properties = "authentication.enabled=false")
public abstract class AbstractWiredTest {

    @Resource
    protected ApplicationConfig applicationConfig;
    @Resource
    protected WiredClusterNode localNode;

    /**
     * Waits up to {@code timeout} for {@code assertAction} to pass and triggers a JUnit failure
     * if the assertion does not pass after the timeout.
     *
     * @param message      optional JUnit assertion failure message
     * @param timeout      maximum time to wait for the assertAction to pass
     * @param assertAction the action that tests whether the assertion passes
     */
    protected static void awaitTrue(@Nullable String message, @Nonnull Duration timeout,
                                    @Nonnull BooleanSupplier assertAction) {
        awaitUntil(message, assertAction::getAsBoolean, is(true), timeout);
    }

    /**
     * Waits up to {@code timeout} for {@code object} to match {@code condition} and throws an {@link AssertionError}
     * if the condition is not met after the timeout.
     *
     * @param object    Object supplier
     * @param condition The condition that has to be met
     * @param timeout   Stop waiting after this timeout and throw an error
     * @param <T>       type of the condition
     * @param <U>       type of the object
     */
    protected static <T, U extends T> void awaitUntil(@Nonnull Supplier<U> object,
                                                      @Nonnull Matcher<T> condition,
                                                      @Nonnull Duration timeout) {
        awaitUntil(null, object, condition, timeout);
    }

    /**
     * Waits up to {@code timeout} for {@code object} to match {@code condition} and throws an {@link AssertionError}
     * if the condition is not met after the timeout.
     *
     * @param object        Object supplier
     * @param condition     The condition that has to be met
     * @param timeout       Stop waiting after this timeout and throw an error
     * @param checkInterval How long to wait between checks
     * @param <T>           type of the condition
     * @param <U>           type of the object
     */
    protected static <T, U extends T> void awaitUntil(@Nonnull Supplier<U> object,
                                                      @Nonnull Matcher<T> condition,
                                                      @Nonnull Duration timeout,
                                                      @Nonnull Duration checkInterval) {
        awaitUntil(null, object, condition, timeout, checkInterval);
    }

    /**
     * Waits up to {@code timeout} for {@code object} to match {@code condition} and throws an {@link AssertionError}
     * if the condition is not met after the timeout.
     *
     * @param message   The message to print with the error
     * @param object    Object supplier
     * @param condition The condition that has to be met
     * @param timeout   Stop waiting after this timeout and throw an error
     * @param <T>       type of the condition
     * @param <U>       type of the object
     */
    protected static <T, U extends T> void awaitUntil(@Nullable String message, @Nonnull Supplier<U> object,
                                                      @Nonnull Matcher<T> condition,
                                                      @Nonnull Duration timeout) {
        awaitUntil(message, object, condition, timeout, Duration.ofMillis(10L));
    }

    /**
     * Waits up to {@code timeout} for {@code object} to match {@code condition} and throws an {@link AssertionError}
     * if the condition is not met after the timeout.
     *
     * @param message       The message to print with the error
     * @param object        Object supplier
     * @param condition     The condition that has to be met
     * @param timeout       Stop waiting after this timeout and throw an error
     * @param checkInterval How long to wait between checks
     * @param <T>           type of the condition
     * @param <U>           type of the object
     */
    protected static <T, U extends T> void awaitUntil(@Nullable String message, @Nonnull Supplier<U> object,
                                                      @Nonnull Matcher<T> condition,
                                                      @Nonnull Duration timeout,
                                                      @Nonnull Duration checkInterval) {
        Instant timeoutTimestamp = Instant.now().plus(timeout);
        while (true) {
            Throwable throwable = null;
            U value = null;
            try {
                value = object.get();
            } catch (Throwable t) {
                throwable = t;
            }
            if (condition.matches(value)) {
                return;
            }

            if (Instant.now().isAfter(timeoutTimestamp)) {
                StringDescription description = new StringDescription();
                if (message != null) {
                    description.appendText(message).appendText("\n");
                }
                description.appendText("Timeout waiting for: \n\t");
                description.appendValue(value);
                description.appendText(" ");
                condition.describeTo(description);
                if (throwable != null) {
                    description.appendText("\n\tbecause an exception occurred:\n");
                    description.appendValue(throwable);
                } else {
                    description.appendText("\n\tbecause it ");
                    condition.describeMismatch(value, description);
                }
                fail(description.toString());
            }
            try {
                Thread.sleep(checkInterval.toMillis());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                fail("Interrupted while waiting for assertion to pass");
            }
        }
    }

    protected boolean isGitAtLeast(Version version) {
        Version actualVersion = applicationConfig.getGit().getBinary().getVersion();
        return actualVersion.compareTo(version) >= 0;
    }

    static class Initializer extends AbstractDynamicPropertyInitializer {

        @Override
        protected Map<String, Object> getDynamicProperties() {
            return ImmutableMap.of("grpc.server.port", newServerPort());
        }

        private static int newServerPort() {
            try (ServerSocket socket = new ServerSocket(0)) {
                return socket.getLocalPort();
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }
    }
}
