/*
 * 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.ignite.testframework.junits.spi;

import java.io.Serializable;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.ignite.IgniteCheckedException;
import org.apache.ignite.cache.CacheMetrics;
import org.apache.ignite.cluster.ClusterMetrics;
import org.apache.ignite.cluster.ClusterNode;
import org.apache.ignite.internal.ClusterMetricsSnapshot;
import org.apache.ignite.internal.IgniteNodeAttributes;
import org.apache.ignite.internal.util.typedef.internal.U;
import org.apache.ignite.lang.IgniteProductVersion;
import org.apache.ignite.plugin.security.SecurityPermission;
import org.apache.ignite.plugin.security.SecurityPermissionSet;
import org.apache.ignite.spi.IgniteSpi;
import org.apache.ignite.spi.communication.CommunicationSpi;
import org.apache.ignite.spi.communication.tcp.TcpCommunicationSpi;
import org.apache.ignite.spi.discovery.DiscoveryDataBag;
import org.apache.ignite.spi.discovery.DiscoveryMetricsProvider;
import org.apache.ignite.spi.discovery.DiscoverySpi;
import org.apache.ignite.spi.discovery.DiscoverySpiDataExchange;
import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi;
import org.apache.ignite.spi.discovery.tcp.ipfinder.TcpDiscoveryIpFinder;
import org.apache.ignite.spi.discovery.tcp.ipfinder.multicast.TcpDiscoveryMulticastIpFinder;
import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder;
import org.apache.ignite.testframework.GridSpiTestContext;
import org.apache.ignite.testframework.GridTestNode;
import org.apache.ignite.testframework.GridTestUtils;
import org.apache.ignite.testframework.junits.GridAbstractTest;
import org.apache.ignite.testframework.junits.IgniteTestResources;
import org.apache.ignite.testframework.junits.spi.GridSpiTestConfig.ConfigType;
import org.jetbrains.annotations.Nullable;
import org.junit.ClassRule;
import org.junit.rules.RuleChain;
import org.junit.rules.TestRule;
import org.junit.runners.model.Statement;

import static org.apache.ignite.lang.IgniteProductVersion.fromString;

/**
 * Base SPI test class.
 * @param <T> SPI implementation class.
 */
public abstract class GridSpiAbstractTest<T extends IgniteSpi> extends GridAbstractTest {
    /** */
    private static final IgniteProductVersion VERSION = fromString("99.99.99");

    /** */
    private static final Map<Class<?>, TestData<?>> tests = new ConcurrentHashMap<>();

    /** */
    private static final TestRule firstLastTestRuleSpi = (base, description) -> new Statement() {
        @Override public void evaluate() throws Throwable {
            GridSpiAbstractTest testClsInstance = (GridSpiAbstractTest)description.getTestClass().newInstance();
            try {
                testClsInstance.beforeFirstTest();

                base.evaluate();
            }
            finally {
                testClsInstance.afterLastTest();
            }
        }
    };

    /** Manages first and last test execution. */
    @SuppressWarnings({"TransientFieldInNonSerializableClass"})
    @ClassRule public static transient RuleChain firstLastTestRule
        = RuleChain.outerRule(firstLastTestRuleSpi).around(GridAbstractTest.firstLastTestRule);

    /** */
    private final boolean autoStart;

    /** Original context classloader. */
    private static ClassLoader cl;

    /** */
    protected GridSpiAbstractTest() {
        super(false);

        autoStart = true;
    }

    /**
     * @param autoStart Start automatically.
     */
    protected GridSpiAbstractTest(boolean autoStart) {
        super(false);

        this.autoStart = autoStart;
    }

    /** {@inheritDoc} */
    @Override protected final boolean isJunitFrameworkClass() {
        return true;
    }

    /**
     * @return Test data.
     */
    protected TestData<T> getTestData() throws IgniteCheckedException {
        TestData<T> data = (TestData<T>)tests.get(getClass());

        if (data == null)
            tests.put(getClass(), data = new TestData<>());

        return data;
    }

    /**
     * @return Allow all permission security set.
     */
    private static SecurityPermissionSet getAllPermissionSet() {
        return new SecurityPermissionSetImpl();
    }

    /**
     * @throws Exception If failed.
     */
    private void resetTestData() throws Exception {
        tests.put(getClass(), new TestData<T>());
    }

    /** {@inheritDoc} */
    @Override protected void beforeTest() throws Exception {
        // Need to change classloader here, although it also handled in the parent class
        // the current test initialisation procedure doesn't allow us to setUp the parent first.
        cl = Thread.currentThread().getContextClassLoader();

        Thread.currentThread().setContextClassLoader(getClass().getClassLoader());

        super.beforeTest();
    }

    /** */
    private void beforeFirstTest() throws Exception {
        if (autoStart) {
            GridSpiTest spiTest = GridTestUtils.getAnnotation(getClass(), GridSpiTest.class);

            assert spiTest != null;

            beforeSpiStarted();

            if (spiTest.trigger())
                spiStart();

            info("==== Started spi test [test=" + getClass().getSimpleName() + "] ====");
        }
    }

    /**
     * @throws Exception If failed.
     */
    protected void beforeSpiStarted() throws Exception {
        // No-op.
    }

    /** {@inheritDoc} */
    @Override protected final IgniteTestResources getTestResources() throws IgniteCheckedException {
        return getTestData().getTestResources();
    }

    /**
     * @throws Exception If failed.
     */
    protected final void spiStart() throws Exception {
        GridSpiTest spiTest = GridTestUtils.getAnnotation(getClass(), GridSpiTest.class);

        assert spiTest != null;

        T spi = (T)spiTest.spi().newInstance();

        // Set spi into test data.
        getTestData().setSpi(spi);

        if (!(spi instanceof DiscoverySpi)) {
            if (spiTest.triggerDiscovery())
                configureDiscovery(spiTest);
        }
        else
            getTestData().setDiscoverySpi((DiscoverySpi)spi);

        getTestResources().inject(spi);

        spiConfigure(spi);

        // Set up SPI class name and SPI version.
        Map<String, Serializable> spiAttrs = initSpiClassAndVersionAttributes(spi);

        if (spiAttrs != null)
            getTestData().getAttributes().putAll(spiAttrs);

        Map<String, Serializable> nodeAttrs = getNodeAttributes();

        if (nodeAttrs != null)
            getTestData().getAttributes().putAll(nodeAttrs);

        DiscoverySpi discoSpi = getTestData().getDiscoverySpi();

        if (spiTest.triggerDiscovery() && !getTestData().isDiscoveryTest()) {
            getTestData().getAttributes().putAll(
                initSpiClassAndVersionAttributes(discoSpi));

            // Set all local node attributes into discovery SPI.
            discoSpi.setNodeAttributes(getTestData().getAttributes(), VERSION);

            discoSpi.setMetricsProvider(createMetricsProvider());

            discoSpi.setDataExchange(new DiscoverySpiDataExchange() {

                @Override public DiscoveryDataBag collect(DiscoveryDataBag dataBag) {
                    return dataBag;
                }

                @Override public void onExchange(DiscoveryDataBag dataBag) {
                    // No-op.
                }
            });

            try {
                spiStart(discoSpi);
            }
            catch (Exception e) {
                spiStop(discoSpi);

                throw e;
            }
        }

        if (spi instanceof DiscoverySpi) {
            getTestData().getAttributes().putAll(initSpiClassAndVersionAttributes(spi));

            ((DiscoverySpi)spi).setNodeAttributes(getTestData().getAttributes(), VERSION);

            ((DiscoverySpi)spi).setMetricsProvider(createMetricsProvider());
        }

        try {
            spiStart(spi);

            Map<String, Object> attrs = spi.getNodeAttributes();

            if (attrs != null)
                getTestData().getAttributes().putAll(attrs);
        }
        catch (Exception e) {
            spiStop(spi);

            if (discoSpi != null && !discoSpi.equals(spi))
                spiStop(discoSpi);

            afterSpiStopped();

            throw e;
        }

        // Initialize SPI context.
        getTestData().setSpiContext(initSpiContext());

        // Initialize discovery SPI only once.
        if (discoSpi != null && !discoSpi.equals(spi))
            discoSpi.onContextInitialized(getSpiContext());

        spi.onContextInitialized(getTestData().getSpiContext());
    }

    /**
     * @return SPI context.
     * @throws Exception If anything failed.
     */
    protected GridSpiTestContext initSpiContext() throws Exception {
        GridSpiTestContext spiCtx = new GridSpiTestContext();

        if (getTestData().getDiscoverySpi() != null) {
            spiCtx.setLocalNode(getTestData().getDiscoverySpi().getLocalNode());

            for (ClusterNode node : getTestData().getDiscoverySpi().getRemoteNodes())
                spiCtx.addNode(node);
        }
        else {
            GridTestNode node = new GridTestNode(UUID.randomUUID());

            spiCtx.setLocalNode(node);

            if (getSpi() != null) {
                // Set up SPI class name and SPI version.
                Map<String, Serializable> attrs = initSpiClassAndVersionAttributes(getSpi());

                for (Map.Entry<String, Serializable> entry: attrs.entrySet())
                    node.addAttribute(entry.getKey(), entry.getValue());
            }
        }

        return spiCtx;
    }

    /**
     * @param spi SPI to create attributes for.
     * @return Map of attributes.
     */
    private Map<String, Serializable> initSpiClassAndVersionAttributes(IgniteSpi spi) {
        Map<String, Serializable> attrs = new HashMap<>();

        attrs.put(U.spiAttribute(spi, IgniteNodeAttributes.ATTR_SPI_CLASS), spi.getClass().getName());

        return attrs;
    }

    /**
     * @return SPI context.
     * @throws Exception If anything failed.
     */
    protected final GridSpiTestContext getSpiContext() throws Exception {
        return getTestData().getSpiContext();
    }

    /**
     * @param spiTest SPI test annotation.
     * @throws Exception If anything failed.
     */
    private void configureDiscovery(GridSpiTest spiTest) throws Exception {
        DiscoverySpi discoSpi = spiTest.discoverySpi().newInstance();

        if (discoSpi instanceof TcpDiscoverySpi) {
            TcpDiscoverySpi tcpDisco = (TcpDiscoverySpi)discoSpi;

            tcpDisco.setIpFinder(new TcpDiscoveryVmIpFinder(true));
        }

        getTestData().setDiscoverySpi(discoSpi);

        getTestResources().inject(discoSpi);

        configure(discoSpi);

        if (discoSpi.getNodeAttributes() != null)
            getTestData().getAttributes().putAll(discoSpi.getNodeAttributes());
    }

    /**
     * Creates metrics provider just for testing purposes. The provider
     * will not return valid node metrics.
     *
     * @return Dummy metrics provider.
     */
    protected DiscoveryMetricsProvider createMetricsProvider() {
        return new DiscoveryMetricsProvider() {
            /** {@inheritDoc} */
            @Override public ClusterMetrics metrics() { return new ClusterMetricsSnapshot(); }

            /** {@inheritDoc} */
            @Override public Map<Integer, CacheMetrics> cacheMetrics() {
                return Collections.emptyMap();
            }
        };
    }

    /**
     * @param spi SPI.
     * @throws Exception If failed.
     */
    protected void spiConfigure(T spi) throws Exception {
        configure(spi);
    }

    /**
     * @param spi SPI.
     * @throws Exception If failed.
     * @throws IllegalAccessException If failed.
     * @throws InvocationTargetException If failed.
     */
    private void configure(IgniteSpi spi) throws Exception {
        // Inject Configuration.
        for (Method m : getClass().getMethods()) {
            GridSpiTestConfig cfg = m.getAnnotation(GridSpiTestConfig.class);

            if (cfg != null) {
                if (getTestData().isDiscoveryTest() ||
                    (cfg.type() != ConfigType.DISCOVERY && !(spi instanceof DiscoverySpi)) ||
                    (cfg.type() != ConfigType.SELF && spi instanceof DiscoverySpi)) {
                    assert m.getName().startsWith("get") : "Test configuration must be a getter [method=" +
                        m.getName() + ']';

                    // Determine getter name.
                    String name = cfg.setterName();

                    if (name == null || name.isEmpty())
                        name = 's' + m.getName().substring(1);

                    Method setter = getMethod(spi.getClass(), name);

                    assert setter != null : "Spi does not have setter for configuration property [spi=" +
                        spi.getClass().getName() + ", config-prop=" + name + ']';

                    // Inject configuration parameter into spi.
                    setter.invoke(spi, m.invoke(this));
                }
            }
        }

        // Our SPI tests should not have the same parameters otherwise they
        // will find each other.
        if (spi instanceof TcpCommunicationSpi)
            ((TcpCommunicationSpi)spi).setLocalPort(GridTestUtils.getNextCommPort(getClass()));

        if (spi instanceof TcpDiscoverySpi) {
            TcpDiscoveryIpFinder ipFinder = ((TcpDiscoverySpi)spi).getIpFinder();

            if (ipFinder instanceof TcpDiscoveryMulticastIpFinder) {
                String mcastAddr = GridTestUtils.getNextMulticastGroup(getClass());

                if (mcastAddr != null && !mcastAddr.isEmpty()) {
                    ((TcpDiscoveryMulticastIpFinder)ipFinder).setMulticastGroup(mcastAddr);
                    ((TcpDiscoveryMulticastIpFinder)ipFinder).setMulticastPort(
                        GridTestUtils.getNextMulticastPort(getClass()));
                }
            }
        }
    }

    /**
     * @param spi SPI.
     * @throws Exception If failed.
     */
    protected void spiStart(IgniteSpi spi) throws Exception {
        // Start SPI with unique Ignite instance name.
        spi.spiStart(getTestIgniteInstanceName());

        info("SPI started [spi=" + spi.getClass() + ']');
    }

    /**
     * @return  Fully initialized and started SPI implementation.
     */
    protected final T getSpi() throws IgniteCheckedException {
        return getTestData().getSpi();
    }

    /**
     * Gets class of the SPI implementation.
     *
     * @return Class of the SPI implementation.
     */
    protected final Class<? extends T> getSpiClass() {
        GridSpiTest spiTest = GridTestUtils.getAnnotation(getClass(), GridSpiTest.class);

        assert spiTest != null;

        return (Class<? extends T>)spiTest.spi();
    }

    /**
     * @return Node UUID.
     * @throws Exception If failed.
     */
    protected UUID getNodeId() throws Exception {
        return getTestResources().getNodeId();
    }

    /**
     * @return Discovery SPI.
     * @throws Exception If failed.
     */
    @Nullable protected final DiscoverySpi getDiscoverySpi() throws Exception {
        if (getTestData() != null)
            return getTestData().getDiscoverySpi();

        return null;
    }

    /**
     * Override this method for put local node attributes to discovery SPI.
     *
     * @return Always {@code null}.
     */
    @Nullable protected Map<String, Serializable> getNodeAttributes() {
        return null;
    }

    /**
     * @throws Exception If failed.
     */
    @Override protected void afterTest() throws Exception {
        super.afterTest();

        Thread.currentThread().setContextClassLoader(cl);
    }

    /** */
    private void afterLastTest() throws Exception {
        if (autoStart) {
            GridSpiTest spiTest = GridTestUtils.getAnnotation(getClass(), GridSpiTest.class);

            assert spiTest != null;

            if (spiTest.trigger()) {
                spiStop();

                afterSpiStopped();
            }

            info("==== Stopped spi test [test=" + getClass().getSimpleName() + "] ====");
        }
    }

    /**
     * @throws Exception If failed..
     */
    protected void afterSpiStopped() throws Exception {
        // No-op.
    }

    /**
     * @throws Exception If failed.
     */
    protected final void spiStop() throws Exception {
        TestData<T> testData = getTestData();

        if (testData.getSpi() == null)
            return;

        testData.getSpi().onContextDestroyed();

        spiStop(testData.getSpi());

        GridSpiTest spiTest = GridTestUtils.getAnnotation(getClass(), GridSpiTest.class);

        assert spiTest != null;

        if (!testData.isDiscoveryTest() && spiTest.triggerDiscovery()) {
            testData.getDiscoverySpi().onContextDestroyed();

            spiStop(testData.getDiscoverySpi());
        }

        getTestResources().stopThreads();

        resetTestData();
    }

    /**
     * @param spi SPI.
     * @throws Exception If failed.
     */
    protected void spiStop(IgniteSpi spi) throws Exception {
        spi.spiStop();

        info("SPI stopped [spi=" + spi.getClass().getName() + ']');
    }

    /**
     * @param cls Class.
     * @param name Method name.
     * @return Method.
     */
    @Nullable private Method getMethod(Class<?> cls, String name) {
        for (; !cls.equals(Object.class); cls = cls.getSuperclass()) {
            for (Method m : cls.getMethods()) {
                if (m.getName().equals(name))
                    return m;
            }
        }

        return null;
    }

    /**
     *
     * @param <T> SPI implementation class.
     */
    protected static class TestData<T> {
        /** */
        private T spi;

        /** */
        private DiscoverySpi discoSpi;

        /** */
        private CommunicationSpi commSpi;

        /** */
        private GridSpiTestContext spiCtx;

        /** */
        private Map<String, Object> allAttrs = new HashMap<>();

        /** */
        private IgniteTestResources rsrcs;

        /**
         *
         */
        TestData() throws IgniteCheckedException {
            rsrcs = new IgniteTestResources();
        }

        /**
         * @return Test resources.
         *
         */
        public IgniteTestResources getTestResources() {
            return rsrcs;
        }

        /**
         * @return {@code true} in case it is a discovery test.
         */
        @SuppressWarnings({"ObjectEquality"})
        public boolean isDiscoveryTest() {
            return spi == discoSpi;
        }

        /**
         * @return {@code true} in case it is a communication test.
         */
        @SuppressWarnings({"ObjectEquality"})
        public boolean isCommunicationTest() {
            return spi == commSpi;
        }

        /**
         * @return SPI.
         */
        public T getSpi() {
            return spi;
        }

        /**
         * @param spi SPI.
         */
        public void setSpi(T spi) {
            this.spi = spi;
        }

        /**
         * @param commSpi Communication SPI.
         */
        public void setCommSpi(CommunicationSpi commSpi) {
            this.commSpi = commSpi;
        }

        /**
         * @return Attributes.
         */
        public Map<String, Object> getAttributes() {
            return allAttrs;
        }

        /**
         * @param allAttrs Attributes.
         */
        public void setAllAttrs(Map<String, Object> allAttrs) {
            this.allAttrs = allAttrs;
        }

        /**
         * @return Discovery SPI.
         */
        public DiscoverySpi getDiscoverySpi() {
            return discoSpi;
        }

        /**
         * @param discoSpi Discovery SPI.
         */
        public void setDiscoverySpi(DiscoverySpi discoSpi) {
            this.discoSpi = discoSpi;
        }

        /**
         * @return SPI context.
         */
        public GridSpiTestContext getSpiContext() {
            return spiCtx;
        }

        /**
         * @param spiCtx SPI context.
         */
        public void setSpiContext(GridSpiTestContext spiCtx) {
            this.spiCtx = spiCtx;
        }

        /** {@inheritDoc} */
        @Override public String toString() {
            return getClass().getSimpleName() +
                " [spi=" + spi +
                ", discoSpi=" + discoSpi +
                ", allAttrs=" + allAttrs + ']';
        }
    }

    private static class SecurityPermissionSetImpl implements SecurityPermissionSet {
        /** Serial version uid. */
        private static final long serialVersionUID = 0L;

        /** {@inheritDoc} */
        @Override public boolean defaultAllowAll() {
            return true;
        }

        /** {@inheritDoc} */
        @Override public Map<String, Collection<SecurityPermission>> taskPermissions() {
            return Collections.emptyMap();
        }

        /** {@inheritDoc} */
        @Override public Map<String, Collection<SecurityPermission>> cachePermissions() {
            return Collections.emptyMap();
        }

        /** {@inheritDoc} */
        @Override public Map<String, Collection<SecurityPermission>> servicePermissions() {
            return Collections.emptyMap();
        }

        /** {@inheritDoc} */
        @Nullable @Override public Collection<SecurityPermission> systemPermissions() {
            return null;
        }
    }
}
