/**
 * 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.jooby.assets;

import static java.util.Objects.requireNonNull;

import java.io.Closeable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;

import org.jooby.Route;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.eclipsesource.v8.NodeJS;
import com.eclipsesource.v8.V8;
import com.eclipsesource.v8.utils.MemoryManager;
import com.google.common.base.Throwables;
import com.google.common.collect.Maps;

import javaslang.control.Try;
import javaslang.control.Try.CheckedConsumer;

/**
 * Helper class that allows you extract and unpack a nodejs library from classpath.
 *
 * It provides a nice and safe nodejs execution environment.
 *
 * @author edgar
 *
 */
public class Nodejs {

  private static class Library implements Closeable {

    private Closeable closer;

    private Path root;

    private Library(final Path path) {
      this.closer = null;
      this.root = path;
    }

    private Library(final FileSystem fs) throws IOException {
      this.closer = fs;
      this.root = fs.getPath("/");
    }

    @Override
    public void close() throws IOException {
      if (closer != null) {
        closer.close();
      }
    }

    public Stream<Path> stream() throws IOException {
      return Files.walk(root);
    }

  }

  /** The logging system. */
  private final Logger log = LoggerFactory.getLogger(getClass());

  private ClassLoader loader;

  private File basedir;

  private NodeJS node;

  private MemoryManager scope;

  private Set<StandardCopyOption> coptions = Collections.emptySet();

  /**
   * Creates a new {@link Nodejs}.
   *
   * @param basedir Base dir where to deploy a library.
   * @param loader Class loader to use.
   */
  public Nodejs(final File basedir, final ClassLoader loader) {
    this.basedir = requireNonNull(basedir, "Basedir required.");
    this.loader = requireNonNull(loader, "ClassLoader required.");
    this.node = NodeJS.createNodeJS();
    V8 v8 = node.getRuntime();
    this.scope = new MemoryManager(v8);
  }

  /**
   * Creates a new {@link Nodejs}.
   *
   * @param basedir Base dir where to deploy a library.
   */
  public Nodejs(final File basedir) {
    this(basedir, Nodejs.class.getClassLoader());
  }

  /**
   * Creates a new {@link Nodejs} and use the <code>java.io.tmpdir</code> as base dir.
   */
  public Nodejs() {
    this(new File(System.getProperty("java.io.tmpdir")));
  }

  /**
   * Force to unpack library files every {@link #exec(String)} call. This is useful for development
   * and testing. By default files are unpacked just once (overwrite = false).
   *
   * @param overwrite True, to force overwrite.
   * @return
   */
  public Nodejs overwrite(final boolean overwrite) {
    if (overwrite) {
      this.coptions = EnumSet.of(StandardCopyOption.REPLACE_EXISTING);
    } else {
      this.coptions = Collections.emptySet();
    }
    return this;
  }

  /**
   * Unpack and execute a nodejs library. Once unpack this method will execute one of these scripts
   * in the following order: 1) [library].js; 2) main.js; 3) index.js.
   *
   * The first file found will be executed.
   *
   * @param library Library to unpack and execute this library.
   * @throws Throwable If something goes wrong.
   */
  public void exec(final String library) throws Throwable {
    exec(library, v8 -> {
    });
  }

  /**
   * Unpack and execute a nodejs library. Once unpack this method will execute one of these scripts
   * in the following order: 1) [library].js; 2) main.js; 3) index.js.
   *
   * The first file found will be executed.
   *
   * @param library Library to unpack and execute this library.
   * @throws Throwable If something goes wrong.
   */
  public void exec(final String library, final CheckedConsumer<V8> callback) throws Throwable {
    Path basedir = deploy(library);
    List<String> candidates = Arrays.asList(
        basedir.getFileName().toString() + ".js",
        "main.js",
        "index.js");

    Path main = candidates.stream()
        .map(it -> basedir.resolve(it))
        .filter(it -> it.toFile().exists())
        .findFirst()
        .orElseThrow(() -> new FileNotFoundException(candidates.toString()));

    callback.accept(node.getRuntime());

    node.exec(main.toFile());

    while (node.isRunning()) {
      node.handleMessage();
    }
  }

  /**
   * Release nodejs and v8 resources.
   */
  public void release() {
    Try.run(scope::release);
    node.release();
  }

  public Path deploy(final String library) throws Exception {
    URL url = loader.getResource(library);
    if (url == null) {
      throw new FileNotFoundException(library);
    }
    URI uri = url.toURI();
    log.debug("{}", uri);
    Path outdir = this.basedir.toPath().resolve("node_modules").resolve(library.replace("/", "."));
    Optional<Path> basedir = Try.of(() -> Paths.get(uri)).toJavaOptional();
    String libroot = Route.normalize("/" + library);
    try (Library lib = loadLibrary(uri)) {
      try (Stream<Path> stream = lib.stream()) {
        stream.filter(it -> !Files.isDirectory(it))
            .forEach(it -> {
              String relative = basedir.map(d -> d.relativize(it).toString())
                  .orElseGet(() -> {
                    String fname = it.toString();
                    if (fname.startsWith(libroot)) {
                      fname = fname.substring(libroot.length());
                    }
                    return fname.substring(1);
                  });
              Path output = outdir.resolve(relative);
              File fout = output.toFile();
              boolean copy = !fout.exists()
                  || coptions.contains(StandardCopyOption.REPLACE_EXISTING);
              if (copy) {
                log.debug("copying {} to {}", it, fout);
                fout.getParentFile().mkdirs();
                StandardCopyOption[] coptions = this.coptions
                    .toArray(new StandardCopyOption[this.coptions.size()]);

                Try.run(() -> Files.copy(it, output, coptions))
                    .onFailure(x -> log.error("can't copy {}", it, x));
              }
            });
      }
    }
    return outdir;
  }

  private Library loadLibrary(final URI lib) {
    return Try.of(() -> FileSystems.newFileSystem(lib, Maps.newHashMap()))
        .map(it -> Try.of(() -> new Library(it)).get())
        .recoverWith(x -> Try.of(() -> new Library(Paths.get(lib))))
        .get();
  }

  /**
   * Execute the given nodejs callback and automatically release v8 and nodejs resources.
   *
   * @param callback Nodejs callback.
   */
  public static void run(final CheckedConsumer<Nodejs> callback) {
    run(new File(System.getProperty("java.io.tmpdir")), callback);
  }

  /**
   * Execute the given nodejs callback and automatically release v8 and nodejs resources.
   *
   * @param basedir Base dir where to deploy a library.
   * @param callback Nodejs callback.
   */
  public static void run(final File basedir, final CheckedConsumer<Nodejs> callback) {
    Nodejs node = new Nodejs(basedir);
    try {
      callback.accept(node);
    } catch (Throwable x) {
      throw Throwables.propagate(x);
    } finally {
      node.release();
    }
  }
}
