/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.tinkerpop.gremlin;

import org.apache.tinkerpop.gremlin.process.traversal.TraversalEngine;
import org.apache.tinkerpop.gremlin.structure.Graph;
import org.javatuples.Pair;
import org.junit.runner.Description;
import org.junit.runner.Runner;
import org.junit.runner.manipulation.Filter;
import org.junit.runner.manipulation.NoTestsRemainException;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.Suite;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.RunnerBuilder;
import org.junit.runners.model.Statement;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Base Gremlin test suite from which different classes of tests can be exposed to implementers.
 *
 * @author Stephen Mallette (http://stephen.genoprime.com)
 */
public abstract class AbstractGremlinSuite extends Suite {
    private static final Logger logger = LoggerFactory.getLogger(AbstractGremlinSuite.class);
    /**
     * Indicates that this suite is for testing a gremlin flavor and is therefore not responsible for validating
     * the suite against what the {@link Graph} implementation opts-in for. This setting will let Gremlin flavor
     * developers run their test cases against a {@link Graph} without the need for the {@link Graph} to supply
     * an {@link org.apache.tinkerpop.gremlin.structure.Graph.OptIn} annotation.  Not having that annotation is a
     * likely case for flavors while they are under development and a
     * {@link org.apache.tinkerpop.gremlin.structure.Graph.OptIn} is not possible.
     */
    private final boolean gremlinFlavorSuite;

    /**
     * Constructs a Gremlin Test Suite implementation.
     *
     * @param klass               Required for JUnit Suite construction
     * @param builder             Required for JUnit Suite construction
     * @param testsToExecute      The list of tests to execute
     * @param testsToEnforce      The list of tests to "enforce" such that a check is made to ensure that in this list,
     *                            there exists an implementation in the testsToExecute (use {@code null} for no
     *                            enforcement).
     * @param gremlinFlavorSuite  Ignore validation of {@link org.apache.tinkerpop.gremlin.structure.Graph.OptIn}
     *                            annotations which is typically reserved for structure tests
     * @param traversalEngineType The {@link org.apache.tinkerpop.gremlin.process.traversal.TraversalEngine.Type} to
     *                            enforce on this suite
     */
    public AbstractGremlinSuite(final Class<?> klass, final RunnerBuilder builder, final Class<?>[] testsToExecute,
                                final Class<?>[] testsToEnforce, final boolean gremlinFlavorSuite,
                                final TraversalEngine.Type traversalEngineType) throws InitializationError {
        super(builder, klass, enforce(testsToExecute, testsToEnforce));

        this.gremlinFlavorSuite = gremlinFlavorSuite;

        // figures out what the implementer assigned as the GraphProvider class and make it available to tests.
        // the klass is the Suite that implements this suite (e.g. GroovyTinkerGraphProcessStandardTest).
        // this class should be annotated with GraphProviderClass.  Failure to do so will toss an InitializationError
        final Pair<Class<? extends GraphProvider>, Class<? extends Graph>> pair = getGraphProviderClass(klass);

        // the GraphProvider.Descriptor is only needed right now if the test if for a computer engine - an
        // exception is thrown if it isn't present.
        final Optional<GraphProvider.Descriptor> graphProviderDescriptor = getGraphProviderDescriptor(traversalEngineType, pair.getValue0());

        // validate public acknowledgement of the test suite and filter out tests ignored by the implementation
        validateOptInToSuite(pair.getValue1());
        validateOptInAndOutAnnotations(pair.getValue0());
        validateOptInAndOutAnnotations(pair.getValue1());

        registerOptOuts(pair.getValue0(), graphProviderDescriptor, traversalEngineType);
        registerOptOuts(pair.getValue1(), graphProviderDescriptor, traversalEngineType);

        try {
            final GraphProvider graphProvider = pair.getValue0().newInstance();
            GraphManager.setGraphProvider(graphProvider);
            GraphManager.setTraversalEngineType(traversalEngineType);
        } catch (Exception ex) {
            throw new InitializationError(ex);
        }
    }

    private Optional<GraphProvider.Descriptor> getGraphProviderDescriptor(final TraversalEngine.Type traversalEngineType,
                                                                          final Class<? extends GraphProvider> klass) throws InitializationError {
        final GraphProvider.Descriptor descriptorAnnotation = klass.getAnnotation(GraphProvider.Descriptor.class);
        if (traversalEngineType == TraversalEngine.Type.COMPUTER) {
            // can't be null if this is graph computer business
            if (null == descriptorAnnotation)
                throw new InitializationError(String.format("For 'computer' tests, '%s' must have a GraphProvider.Descriptor annotation", klass.getName()));
        }

        return Optional.ofNullable(descriptorAnnotation);
    }

    private void validateOptInToSuite(final Class<? extends Graph> klass) throws InitializationError {
        final Graph.OptIn[] optIns = klass.getAnnotationsByType(Graph.OptIn.class);
        if (!Arrays.stream(optIns).anyMatch(optIn -> optIn.value().equals(this.getClass().getCanonicalName())))
            if (gremlinFlavorSuite)
                logger.error(String.format("The %s will run for this Graph as it is testing a Gremlin flavor but the Graph does not publicly acknowledged it yet with the @OptIn annotation.", this.getClass().getSimpleName()));
            else
                throw new InitializationError(String.format("The %s will not run for this Graph until it is publicly acknowledged with the @OptIn annotation on the Graph instance itself", this.getClass().getSimpleName()));
    }

    private void registerOptOuts(final Class<?> classWithOptOuts,
                                 final Optional<GraphProvider.Descriptor> graphProviderDescriptor,
                                 final TraversalEngine.Type traversalEngineType) throws InitializationError {
        // don't get why getAnnotationsByType() refuses to pick up OptOuts on the superclass. doing it manually and
        // only for the immediate superclass for now
        final List<Graph.OptOut> optOuts = getAllOptOuts(classWithOptOuts);

        if (!optOuts.isEmpty()) {
            // validate annotation - test class and reason must be set
            if (!optOuts.stream().allMatch(ignore -> ignore.test() != null && ignore.reason() != null && !ignore.reason().isEmpty()))
                throw new InitializationError("Check @IgnoreTest annotations - all must have a 'test' and 'reason' set");

            try {
                final Graph.OptOut[] oos = new Graph.OptOut[optOuts.size()];
                optOuts.toArray(oos);
                filter(new OptOutTestFilter(oos, graphProviderDescriptor, traversalEngineType));
            } catch (NoTestsRemainException ex) {
                throw new InitializationError(ex);
            }
        }
    }

    private static List<Graph.OptOut> getAllOptOuts(final Class<?> clazz) {
        // we typically get a null class if this is called recursively and the original clazz was an interface
        if (clazz == Object.class || null == clazz)
            return Collections.emptyList();

        return Stream.concat(getAllOptOuts(clazz.getSuperclass()).stream(),
                Stream.of(clazz.getAnnotationsByType(Graph.OptOut.class))).distinct().collect(Collectors.toList());

    }

    private static Class<?>[] enforce(final Class<?>[] unfilteredTestsToExecute, final Class<?>[] unfilteredTestsToEnforce) {
        // Allow env var to filter the test lists.
        final Class<?>[] testsToExecute = filterSpecifiedTests(unfilteredTestsToExecute);
        final Class<?>[] testsToEnforce = filterSpecifiedTests(unfilteredTestsToEnforce);

        // If there are no tests specified to enforce, enforce them all.
        if (null == testsToEnforce) return testsToExecute;

        // examine each test to enforce and ensure an instance of it is in the list of testsToExecute
        final List<Class<?>> notSupplied = Stream.of(testsToEnforce)
                .filter(t -> Stream.of(testsToExecute).noneMatch(t::isAssignableFrom))
                .collect(Collectors.toList());

        if (notSupplied.size() > 0)
            logger.error(String.format("Review the testsToExecute given to the test suite as the following are missing: %s", notSupplied));

        return testsToExecute;
    }

    /**
     * Filter a list of test classes through the GREMLIN_TESTS environment variable list.
     */
    private static Class<?>[] filterSpecifiedTests(final Class<?>[] allTests) {
        if (null == allTests) return null;

        Class<?>[] filteredTests;
        final String override = System.getenv().getOrDefault("GREMLIN_TESTS", "");
        if (override.equals(""))
            filteredTests = allTests;
        else {
            final List<String> filters = Arrays.asList(override.split(","));
            final List<Class<?>> allowed = Stream.of(allTests)
                    .filter(c -> filters.contains(c.getName()))
                    .collect(Collectors.toList());
            filteredTests = allowed.toArray(new Class<?>[allowed.size()]);
        }
        return filteredTests;
    }

    public static Pair<Class<? extends GraphProvider>, Class<? extends Graph>> getGraphProviderClass(final Class<?> klass) throws InitializationError {
        final GraphProviderClass annotation = klass.getAnnotation(GraphProviderClass.class);
        if (null == annotation)
            throw new InitializationError(String.format("class '%s' must have a GraphProviderClass annotation", klass.getName()));
        return Pair.with(annotation.provider(), annotation.graph());
    }

    static void validateOptInAndOutAnnotations(final Class<?> klass) throws InitializationError {
        // sometimes test names change and since they are String representations they can easily break if a test
        // is renamed. this test will validate such things.  it is not possible to @OptOut of this test.
        final Graph.OptOut[] optOuts = klass.getAnnotationsByType(Graph.OptOut.class);
        for (Graph.OptOut optOut : optOuts) {
            final Class testClass;
            try {
                testClass = Class.forName(optOut.test());
            } catch (Exception ex) {
                throw new InitializationError(String.format("Invalid @OptOut on Graph instance.  Could not instantiate test class (it may have been renamed): %s", optOut.test()));
            }

            if (!optOut.method().equals("*") && !Arrays.stream(testClass.getMethods()).anyMatch(m -> m.getName().equals(optOut.method())))
                throw new InitializationError(String.format("Invalid @OptOut on Graph instance.  Could not match @OptOut test name %s on test class %s (it may have been renamed)", optOut.method(), optOut.test()));
        }
    }

    @Override
    protected void runChild(final Runner runner, final RunNotifier notifier) {
        if (beforeTestExecution((Class<? extends AbstractGremlinTest>) runner.getDescription().getTestClass()))
            super.runChild(runner, notifier);
        afterTestExecution((Class<? extends AbstractGremlinTest>) runner.getDescription().getTestClass());
    }

    @Override
    protected Statement withAfterClasses(final Statement statement) {
        final Statement wrappedStatement = new Statement() {
            @Override
            public void evaluate() throws Throwable {
                statement.evaluate();

                // release resources in GraphProviders that implement AutoCloseable
                final GraphProvider gp = GraphManager.getGraphProvider();
                if (gp instanceof AutoCloseable)
                    ((AutoCloseable) gp).close();
            }
        };

        return super.withAfterClasses(wrappedStatement);
    }

    /**
     * Called just prior to test class execution.  Return false to ignore test class. By default this always returns
     * true.
     */
    public boolean beforeTestExecution(final Class<? extends AbstractGremlinTest> testClass) {
        return true;
    }

    /**
     * Called just after test class execution.
     */
    public void afterTestExecution(final Class<? extends AbstractGremlinTest> testClass) {
    }

    /**
     * Filter for tests in the suite which is controlled by the {@link org.apache.tinkerpop.gremlin.structure.Graph.OptOut} annotation.
     */
    public static class OptOutTestFilter extends Filter {

        /**
         * Ignores a specific test in a specific test case.
         */
        private final List<Description> individualSpecificTestsToIgnore;

        /**
         * Defines a group of tests to ignore which is useful with some of the Gremlin process tests which all
         * have the same name.  It is only possible to ignore tests that extend from certain pre-defined classes.
         */
        private final List<Description> testGroupToIgnore;

        /**
         * Ignores an entire specific test case.
         */
        private final List<Graph.OptOut> entireTestCaseToIgnore;

        private final Optional<GraphProvider.Descriptor> graphProviderDescriptor;
        private final TraversalEngine.Type traversalEngineType;

        public OptOutTestFilter(final Graph.OptOut[] optOuts,
                                final Optional<GraphProvider.Descriptor> graphProviderDescriptor,
                                final TraversalEngine.Type traversalEngineType) {
            this.graphProviderDescriptor = graphProviderDescriptor;
            this.traversalEngineType = traversalEngineType;

            // split the tests to filter into two groups - true represents those that should ignore a whole
            final Map<Boolean, List<Graph.OptOut>> split = Arrays.stream(optOuts)
                    .filter(this::checkGraphProviderDescriptorForComputer)
                    .collect(Collectors.groupingBy(optOut -> optOut.method().equals("*")));

            final List<Graph.OptOut> optOutsOfIndividualTests = split.getOrDefault(Boolean.FALSE, Collections.emptyList());
            individualSpecificTestsToIgnore = optOutsOfIndividualTests.stream()
                    .filter(ignoreTest -> !ignoreTest.method().equals("*"))
                    .filter(allowAbstractMethod(false))
                    .<Pair>map(ignoreTest -> Pair.with(ignoreTest.test(), ignoreTest.specific().isEmpty() ? ignoreTest.method() : String.format("%s[%s]", ignoreTest.method(), ignoreTest.specific())))
                    .<Description>map(p -> Description.createTestDescription(p.getValue0().toString(), p.getValue1().toString()))
                    .collect(Collectors.toList());

            testGroupToIgnore = optOutsOfIndividualTests.stream()
                    .filter(ignoreTest -> !ignoreTest.method().equals("*"))
                    .filter(allowAbstractMethod(true))
                    .<Pair>map(ignoreTest -> Pair.with(ignoreTest.test(), ignoreTest.specific().isEmpty() ? ignoreTest.method() : String.format("%s[%s]", ignoreTest.method(), ignoreTest.specific())))
                    .<Description>map(p -> Description.createTestDescription(p.getValue0().toString(), p.getValue1().toString()))
                    .collect(Collectors.toList());

            entireTestCaseToIgnore = split.getOrDefault(Boolean.TRUE, Collections.emptyList());
        }

        @Override
        public boolean shouldRun(final Description description) {
            // first check if all tests from a class should be ignored - where "OptOut.method" is set to "*". the
            // description appears to be null in some cases of parameterized tests, but if the entire test case
            // was ignored it would have been caught earlier and these parameterized tests wouldn't be considered
            // for a call to shouldRun
            if (description.getTestClass() != null) {
                final boolean ignoreWholeTestCase = entireTestCaseToIgnore.stream().map(this::transformToClass)
                        .anyMatch(claxx -> claxx.isAssignableFrom(description.getTestClass()));
                if (ignoreWholeTestCase) return false;
            }

            if (description.isTest()) {
                // next check if there is a test group to consider. if not then check for a  specific test to ignore
                return !(!testGroupToIgnore.isEmpty() && testGroupToIgnore.stream().anyMatch(optOut -> optOut.getTestClass().isAssignableFrom(description.getTestClass()) && description.getMethodName().equals(optOut.getMethodName())))
                        && !individualSpecificTestsToIgnore.contains(description);
            }

            // explicitly check if any children want to run
            for (Description each : description.getChildren()) {
                if (shouldRun(each)) {
                    return true;
                }
            }
            return false;
        }

        @Override
        public String describe() {
            return String.format("Method %s",
                    String.join(",", individualSpecificTestsToIgnore.stream().map(Description::getDisplayName).collect(Collectors.toList())));
        }

        private Predicate<Graph.OptOut> allowAbstractMethod(final boolean allow) {
            return optOut -> {
                try {
                    // ignore those methods in process that are defined as abstract methods
                    final Class testClass = Class.forName(optOut.test());
                    if (allow)
                        return Modifier.isAbstract(testClass.getModifiers());
                    else
                        return !Modifier.isAbstract(testClass.getModifiers());
                } catch (Exception ex) {
                    throw new RuntimeException(ex);
                }
            };
        }

        private boolean checkGraphProviderDescriptorForComputer(final Graph.OptOut optOut) {
            // immediately include the ignore if this is a standard tests suite (i.e. not computer)
            // or if the OptOut doesn't specify any computers to filter
            if (traversalEngineType == TraversalEngine.Type.STANDARD
                    || optOut.computers().length == 0) {
                return true;
            }
            // can assume that that GraphProvider.Descriptor is not null at this point.  a test should
            // only opt out if it matches the expected computer
            return Stream.of(optOut.computers()).map(c -> {
                try {
                    return Class.forName(c);
                } catch (ClassNotFoundException e) {
                    return Object.class;
                }
            }).filter(c -> !c.equals(Object.class)).anyMatch(c -> c == graphProviderDescriptor.get().computer());
        }

        private Class<?> transformToClass(final Graph.OptOut optOut) {
            try {
                return Class.forName(optOut.test());
            } catch (Exception ex) {
                throw new RuntimeException(ex);
            }
        }
    }
}
