/*
 * 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.hadoop.groovy.plugin;

import org.apache.tinkerpop.gremlin.AbstractGremlinTest;
import org.apache.tinkerpop.gremlin.LoadGraphWith;
import org.apache.tinkerpop.gremlin.TestHelper;
import org.apache.tinkerpop.gremlin.groovy.loaders.GremlinLoader;
import org.apache.tinkerpop.gremlin.groovy.plugin.RemoteAcceptor;
import org.apache.tinkerpop.gremlin.groovy.util.SugarTestHelper;
import org.apache.tinkerpop.gremlin.groovy.util.TestableConsolePluginAcceptor;
import org.apache.tinkerpop.gremlin.hadoop.Constants;
import org.apache.tinkerpop.gremlin.hadoop.HadoopGremlinSuite;
import org.apache.tinkerpop.gremlin.process.traversal.Traversal;
import org.apache.tinkerpop.gremlin.util.Gremlin;
import org.apache.tinkerpop.gremlin.util.iterator.IteratorUtils;
import org.junit.Before;
import org.junit.Test;

import javax.tools.JavaCompiler;
import javax.tools.SimpleJavaFileObject;
import javax.tools.ToolProvider;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

/**
 * This is an test that is mean to be used in the context of the {@link HadoopGremlinSuite} and shouldn't be
 * executed on its own.
 *
 * @author Marko A. Rodriguez (http://markorodriguez.com)
 */
public class HadoopGremlinPluginCheck extends AbstractGremlinTest {

    @Before
    public void setupTest() {
        try {
            this.console = new TestableConsolePluginAcceptor();
            final HadoopGremlinPlugin plugin = new HadoopGremlinPlugin();
            plugin.pluginTo(this.console);
            this.remote = (HadoopRemoteAcceptor) plugin.remoteAcceptor().get();
        } catch (final Exception e) {
            throw new IllegalStateException(e.getMessage(), e);
        }
    }

    ///////////////////

    private HadoopRemoteAcceptor remote;
    private TestableConsolePluginAcceptor console;

    @Test
    @LoadGraphWith(LoadGraphWith.GraphData.MODERN)
    public void shouldSupportRemoteTraversal() throws Exception {
        this.console.addBinding("graph", this.graph);
        this.console.addBinding("g", this.g);
        this.remote.connect(Arrays.asList("graph", "g"));
        //
        Traversal<?, ?> traversal = (Traversal<?, ?>) this.remote.submit(Arrays.asList("g.V().count()"));
        assertEquals(6L, traversal.next());
        assertFalse(traversal.hasNext());
        assertNotNull(this.console.getBindings().get(RemoteAcceptor.RESULT));
    }

    @Test
    @LoadGraphWith(LoadGraphWith.GraphData.MODERN)
    public void shouldSupportRemoteSugarTraversal() throws Exception {
        SugarTestHelper.clearRegistry(this.graphProvider);
        this.console.addBinding("graph", this.graph);
        this.console.addBinding("g", this.g);
        //
        this.remote.connect(Arrays.asList("graph", "g"));
        try {
            this.remote.submit(Arrays.asList("g.V.name.map{it.length()}.sum"));
            fail("Should not allow sugar usage");
        } catch (final Exception e) {
            // this is good
        }
        //
        this.remote.configure(Arrays.asList("useSugar", "true"));
        this.remote.connect(Arrays.asList("graph", "g"));
        Traversal<?, ?> traversal = (Traversal<?, ?>) this.remote.submit(Arrays.asList("g.V.name.map{it.length()}.sum"));
        assertEquals(28l, traversal.next());
        assertFalse(traversal.hasNext());
        assertNotNull(this.console.getBindings().get(RemoteAcceptor.RESULT));
    }

    @Test
    @LoadGraphWith(LoadGraphWith.GraphData.MODERN)
    public void shouldSupportRemoteGroupTraversal() throws Exception {
        SugarTestHelper.clearRegistry(this.graphProvider);
        GremlinLoader.load();
        this.console.addBinding("graph", this.graph);
        this.console.addBinding("g", this.g);
        this.remote.connect(Arrays.asList("graph"));
        //
        this.remote.connect(Arrays.asList("graph", "g"));
        Traversal<?, Map<String, List<String>>> traversal = (Traversal<?, Map<String, List<String>>>) this.remote.submit(Arrays.asList("g.V().out().group().by{it.value('name')[1]}.by('name')"));
        Map<String, List<String>> map = traversal.next();
        assertEquals(3, map.size());
        assertEquals(1, map.get("a").size());
        assertEquals("vadas", map.get("a").get(0));
        assertEquals(1, map.get("i").size());
        assertEquals("ripple", map.get("i").get(0));
        assertEquals(4, map.get("o").size());
        assertTrue(map.get("o").contains("josh"));
        assertTrue(map.get("o").contains("lop"));
        assertNotNull(this.console.getBindings().get(RemoteAcceptor.RESULT));
        //
        traversal = (Traversal<?, Map<String, List<String>>>) this.remote.submit(Arrays.asList("g.V().out().group().by(label).by{it.value('name')[1]}"));
        map = traversal.next();
        assertEquals(2, map.size());
        assertEquals(4, map.get("software").size());
        assertTrue(map.get("software").contains("o"));
        assertTrue(map.get("software").contains("i"));
        assertEquals(2, map.get("person").size());
        assertTrue(map.get("person").contains("o"));
        assertTrue(map.get("person").contains("a"));
        assertNotNull(this.console.getBindings().get(RemoteAcceptor.RESULT));
    }


    @Test
    @LoadGraphWith(LoadGraphWith.GraphData.MODERN)
    public void shouldSupportHDFSMethods() throws Exception {
        List<String> ls = (List<String>) this.console.eval("hdfs.ls()");
        for (final String line : ls) {
            assertTrue(line.startsWith("-") || line.startsWith("r") || line.startsWith("w") || line.startsWith("x"));
            assertEquals(" ", line.substring(9, 10));
        }
        ls = (List<String>) this.console.eval("fs.ls()");
        for (final String line : ls) {
            assertTrue(line.startsWith("-") || line.startsWith("r") || line.startsWith("w") || line.startsWith("x"));
            assertEquals(" ", line.substring(9, 10));
        }
    }

    @Test
    @LoadGraphWith(LoadGraphWith.GraphData.MODERN)
    public void shouldGracefullyHandleBadGremlinHadoopLibs() throws Exception {
        System.setProperty(Constants.HADOOP_GREMLIN_LIBS, TestHelper.makeTestDataDirectory(HadoopGremlinPluginCheck.class, "shouldGracefullyHandleBadGremlinHadoopLibs"));
        this.graph.configuration().setProperty(Constants.GREMLIN_HADOOP_JARS_IN_DISTRIBUTED_CACHE, true);
        this.console.addBinding("graph", this.graph);
        this.console.addBinding("g", this.g);
        this.remote.connect(Arrays.asList("graph", "g"));
        Traversal<?, ?> traversal = (Traversal<?, ?>) this.remote.submit(Arrays.asList("g.V()"));
        assertEquals(6, IteratorUtils.count(traversal));
        assertNotNull(this.console.getBindings().get(RemoteAcceptor.RESULT));
    }

    @Test
    @LoadGraphWith(LoadGraphWith.GraphData.MODERN)
    public void shouldSupportVariousFileSystemsInGremlinHadoopLibs() throws Exception {

        // The whole point of this test is to verify that HADOOP_GREMLIN_LIBS may contain paths with or without
        // a file system scheme prefix and that either path is properly handled. If all jar files, that were specified
        // in HADOOP_GREMLIN_LIBS, are found in the GraphComputers temporary directory after using the GraphComputer
        // (by submitting a traversal), the test is considered to be successful.
        //
        // The traversal will likely never fail, since both - Spark and Giraph - run in the same JVM during tests. This
        // is unfortunate as it doesn't allow us to verify that GraphComputers load jars properly in a distributed
        // environment. The test would fail in a distributed environment, IF loading the jars specified in
        // HADOOP_GREMLIN_LIBS wouldn't work. That is because we generate new jar files on the fly that are definitely
        // not part of any existing directory or the current classpath.

        final String testDataDirectory = TestHelper.makeTestDataDirectory(HadoopGremlinPluginCheck.class, "shouldHandleLocalGremlinHadoopLibs");
        final File jarFile1 = createJarFile(testDataDirectory + File.separator + "1", "Greeter1");
        final File jarFile2 = createJarFile(testDataDirectory + File.separator + "2", "Greeter2");
        final String graphComputerJarTargetBasePath = System.getProperty("java.io.tmpdir") + File.separator +
                "hadoop-gremlin-" + Gremlin.version() + "-libs" + File.separator;
        final File graphComputerJarTargetPath1 = new File(graphComputerJarTargetBasePath + "1" + File.separator + "Greeter1.jar");
        final File graphComputerJarTargetPath2 = new File(graphComputerJarTargetBasePath + "2" + File.separator + "Greeter2.jar");

        for (final boolean withScheme : Arrays.asList(false, true)) {

            Stream<String> hadoopGremlinLibs = Arrays.asList(jarFile1, jarFile2).stream().map(f -> f.getParentFile().getAbsolutePath());
            if (withScheme) {
                hadoopGremlinLibs = hadoopGremlinLibs.map(path -> "file://" + path);
            }
            System.setProperty(Constants.HADOOP_GREMLIN_LIBS, String.join(File.pathSeparator, hadoopGremlinLibs.collect(Collectors.toList())));

            this.graph.configuration().setProperty(Constants.GREMLIN_HADOOP_JARS_IN_DISTRIBUTED_CACHE, true);
            this.console.addBinding("graph", this.graph);
            this.console.addBinding("g", this.g);
            this.remote.connect(Arrays.asList("graph", "g"));

            Traversal<?, ?> traversal = (Traversal<?, ?>) this.remote.submit(Arrays.asList(
                    "ClassLoader.getSystemClassLoader().addURL('" + jarFile1.toURI().toURL() + "'.toURL());",
                    "ClassLoader.getSystemClassLoader().addURL('" + jarFile2.toURI().toURL() + "'.toURL());",
                    "g.V().choose(hasLabel('person'), " +
                            "values('name').map {Class.forName('Greeter1').hello(it.get())}, " +
                            "values('name').map {Class.forName('Greeter2').hello(it.get())})"));

            final List<String> expectedMessages = Arrays.asList("marko", "josh", "peter", "vadas").stream().
                    map(name -> "Greeter1 says: Hello " + name + "!").collect(Collectors.toList());

            expectedMessages.addAll(Arrays.asList("lop", "ripple").stream().
                    map(name -> "Greeter2 says: Hello " + name + "!").collect(Collectors.toList()));

            while (traversal.hasNext()) {
                final String message = (String) traversal.next();
                assertTrue(expectedMessages.remove(message));
            }

            assertEquals(0, expectedMessages.size());
        }

        assertTrue(graphComputerJarTargetPath1.exists());
        assertTrue(graphComputerJarTargetPath2.exists());

        assert graphComputerJarTargetPath1.delete();
        assert graphComputerJarTargetPath2.delete();
    }

    private File createJarFile(final String directory, final String className) throws IOException {

        new File(directory).mkdirs();

        final File classFile = new File(directory + File.separator + className + ".class");
        final File jarFile = new File(directory + File.separator + className + ".jar");

        jarFile.deleteOnExit();

        final JavaStringObject source = new JavaStringObject(className,
                "public class " + className + " {\n" +
                        "    public static String hello(final String name) {\n" +
                        "        return \"" + className + " says: Hello \" + name + \"!\";\n" +
                        "    }\n" +
                        "}");

        final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        final List<String> options = Arrays.asList(
                "-d", classFile.getParentFile().getAbsolutePath()
        );
        assert compiler.getTask(null, null, null, options, null, Collections.singletonList(source)).call();

        final Manifest manifest = new Manifest();
        manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");

        try (final JarOutputStream target = new JarOutputStream(new FileOutputStream(jarFile), manifest)) {
            final JarEntry entry = new JarEntry(classFile.getName());
            entry.setTime(classFile.lastModified());
            target.putNextEntry(entry);
            try (final FileInputStream fis = new FileInputStream(classFile);
                 final BufferedInputStream in = new BufferedInputStream(fis)) {
                final byte buffer[] = new byte[1024];
                while (true) {
                    final int count = in.read(buffer);
                    if (count < 0) break;
                    target.write(buffer, 0, count);
                }
            }
            target.closeEntry();
        }

        assert classFile.delete();

        return jarFile;
    }

    private static class JavaStringObject extends SimpleJavaFileObject {

        private final String code;

        JavaStringObject(final String className, final String code) {
            super(URI.create("string:///" + className.replace(".", "/") + Kind.SOURCE.extension), Kind.SOURCE);
            this.code = code;
        }

        @Override
        public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
            return code;
        }
    }
}
