/*
 * 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.geode.internal;

import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Serializable;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.Logger;

import org.apache.geode.internal.logging.LogService;

public class JarDeployer implements Serializable {
  private static final long serialVersionUID = 1L;
  private static final Logger logger = LogService.getLogger();
  public static final String JAR_PREFIX_FOR_REGEX = "";
  private static final Lock lock = new ReentrantLock();

  private final Map<String, DeployedJar> deployedJars = new ConcurrentHashMap<>();


  // Split a versioned filename into its name and version
  public static final Pattern versionedPattern =
      Pattern.compile(JAR_PREFIX_FOR_REGEX + "(.*)\\.v(\\d++).jar$");

  private final File deployDirectory;

  public JarDeployer() {
    this.deployDirectory = new File(System.getProperty("user.dir"));
  }

  public JarDeployer(final File deployDirectory) {
    this.deployDirectory = deployDirectory;
  }

  public File getDeployDirectory() {
    return this.deployDirectory;
  }

  /**
   * Writes the jarBytes for the given jarName to the next version of that jar file (if the bytes do
   * not match the latest deployed version)
   * 
   * @return the DeployedJar that was written from jarBytes, or null if those bytes matched the
   *         latest deployed version
   */
  public DeployedJar deployWithoutRegistering(final String jarName, final byte[] jarBytes)
      throws IOException {
    lock.lock();

    try {
      boolean shouldDeployNewVersion = shouldDeployNewVersion(jarName, jarBytes);
      if (!shouldDeployNewVersion) {
        logger.debug("No need to deploy a new version of {}", jarName);
        return null;
      }

      verifyWritableDeployDirectory();

      File newVersionedJarFile = getNextVersionedJarFile(jarName);
      writeJarBytesToFile(newVersionedJarFile, jarBytes);

      return new DeployedJar(newVersionedJarFile, jarName, jarBytes);
    } finally {
      lock.unlock();
    }
  }

  /**
   * Get a list of all currently deployed jars.
   * 
   * @return The list of DeployedJars
   */
  public List<DeployedJar> findDeployedJars() {
    return getDeployedJars().values().stream().collect(toList());
  }


  /**
   * Suspend all deploy and undeploy operations. This is done by acquiring and holding the lock
   * needed in order to perform a deploy or undeploy and so it will cause all threads attempting to
   * do one of these to block. This makes it somewhat of a time sensitive call as forcing these
   * other threads to block for an extended period of time may cause other unforeseen problems. It
   * must be followed by a call to {@link #resumeAll()}.
   */
  public void suspendAll() {
    lock.lock();
  }

  /**
   * Release the lock that controls entry into the deploy/undeploy methods which will allow those
   * activities to continue.
   */
  public void resumeAll() {
    lock.unlock();
  }


  protected File getNextVersionedJarFile(String unversionedJarName) {
    File[] oldVersions = findSortedOldVersionsOfJar(unversionedJarName);

    String nextVersionedJarName;
    if (oldVersions == null || oldVersions.length == 0) {
      nextVersionedJarName = removeJarExtension(unversionedJarName) + ".v1.jar";
    } else {
      String latestVersionedJarName = oldVersions[0].getName();
      int nextVersion = extractVersionFromFilename(latestVersionedJarName) + 1;
      nextVersionedJarName = removeJarExtension(unversionedJarName) + ".v" + nextVersion + ".jar";
    }

    logger.debug("Next versioned jar name for {} is {}", unversionedJarName, nextVersionedJarName);

    return new File(deployDirectory, nextVersionedJarName);
  }

  /**
   * Attempt to write the given bytes to the given file. If this VM is able to successfully write
   * the contents to the file, or another VM writes the exact same contents, then the write is
   * considered to be successful.
   * 
   * @param file File of the JAR file to deploy.
   * @param jarBytes Contents of the JAR file to deploy.
   * @return True if the file was successfully written, false otherwise
   */
  private boolean writeJarBytesToFile(final File file, final byte[] jarBytes) throws IOException {
    final boolean isDebugEnabled = logger.isDebugEnabled();
    if (file.createNewFile()) {
      if (isDebugEnabled) {
        logger.debug("Successfully created new JAR file: {}", file.getAbsolutePath());
      }
      final OutputStream outStream = new FileOutputStream(file);
      outStream.write(jarBytes);
      outStream.close();
      return true;
    }
    return doesFileMatchBytes(file, jarBytes);
  }

  /**
   * Determine if the contents of the file referenced is an exact match for the bytes provided. The
   * method first checks to see if the file is actively being written by checking the length over
   * time. If it appears that the file is actively being written, then it loops waiting for that to
   * complete before doing the comparison.
   * 
   * @param file File to compare
   * @param bytes Bytes to compare
   * @return True if there's an exact match, false otherwise
   * @throws IOException If there's a problem reading the file
   */
  private boolean doesFileMatchBytes(final File file, final byte[] bytes) throws IOException {
    // First check to see if the file is actively being written (if it's not big enough)
    final String absolutePath = file.getAbsolutePath();
    boolean keepTrying = true;
    final boolean isDebugEnabled = logger.isDebugEnabled();
    while (file.length() < bytes.length && keepTrying) {
      if (isDebugEnabled) {
        logger.debug("Loop waiting for another to write file: {}", absolutePath);
      }
      long startingFileLength = file.length();
      try {
        Thread.sleep(500);
      } catch (InterruptedException iex) {
        // Just keep looping
      }
      if (startingFileLength == file.length()) {
        if (isDebugEnabled) {
          logger.debug("Done waiting for another to write file: {}", absolutePath);
        }
        // Assume the other process has finished writing
        keepTrying = false;
      }
    }

    // If they don't have the same number of bytes then nothing to do
    if (file.length() != bytes.length) {
      if (isDebugEnabled) {
        logger.debug("Unmatching file length when waiting for another to write file: {}",
            absolutePath);
      }
      return false;
    }

    // Open the file then loop comparing each byte
    BufferedInputStream inStream = new BufferedInputStream(new FileInputStream(file));
    int index = 0;
    try {
      for (; index < bytes.length; index++) {
        if (((byte) inStream.read()) != bytes[index]) {
          if (isDebugEnabled) {
            logger.debug("Did not find a match when waiting for another to write file: {}",
                absolutePath);
          }
          return false;
        }
      }
    } finally {
      inStream.close();
    }

    return true;
  }

  /**
   * Find the version number that's embedded in the name of this file
   * 
   * @param filename Filename to get the version number from
   * @return The version number embedded in the filename
   */
  public static int extractVersionFromFilename(final String filename) {
    final Matcher matcher = versionedPattern.matcher(filename);
    if (matcher.find()) {
      return Integer.parseInt(matcher.group(2));
    } else {
      return 0;
    }
  }

  protected Set<String> findDistinctDeployedJarsOnDisk() {
    // Find all deployed JAR files
    final File[] oldFiles =
        this.deployDirectory.listFiles((file, name) -> versionedPattern.matcher(name).matches());

    // Now add just the original JAR name to the set
    final Set<String> jarNames = new HashSet<>();
    for (File oldFile : oldFiles) {
      Matcher matcher = versionedPattern.matcher(oldFile.getName());
      matcher.find();
      jarNames.add(matcher.group(1) + ".jar");
    }
    return jarNames;
  }

  /**
   * Find all versions of the JAR file that are currently on disk and return them sorted from newest
   * (highest version) to oldest
   * 
   * @param unversionedJarName Name of the JAR file that we want old versions of
   * @return Sorted array of files that are older versions of the given JAR
   */
  protected File[] findSortedOldVersionsOfJar(final String unversionedJarName) {
    logger.debug("Finding sorted old versions of {}", unversionedJarName);
    // Find all matching files
    final Pattern pattern = Pattern.compile(
        JAR_PREFIX_FOR_REGEX + removeJarExtension(unversionedJarName) + "\\.v\\d++\\.jar$");
    final File[] oldJarFiles =
        this.deployDirectory.listFiles((file, name) -> (pattern.matcher(name).matches()));

    // Sort them in order from newest (highest version) to oldest
    Arrays.sort(oldJarFiles, (file1, file2) -> {
      int file1Version = extractVersionFromFilename(file1.getName());
      int file2Version = extractVersionFromFilename(file2.getName());
      return file2Version - file1Version;
    });

    logger.debug("Found [{}]",
        Arrays.stream(oldJarFiles).map(File::getAbsolutePath).collect(joining(",")));
    return oldJarFiles;
  }

  protected String removeJarExtension(String jarName) {
    if (jarName != null && jarName.endsWith(".jar")) {
      return jarName.replaceAll("\\.jar$", "");
    } else {
      return jarName;
    }
  }

  /**
   * Make sure that the deploy directory is writable.
   * 
   * @throws IOException If the directory isn't writable
   */
  public void verifyWritableDeployDirectory() throws IOException {
    try {
      if (this.deployDirectory.canWrite()) {
        return;
      }
    } catch (SecurityException ex) {
      throw new IOException("Unable to write to deploy directory", ex);
    }

    throw new IOException(
        "Unable to write to deploy directory: " + this.deployDirectory.getCanonicalPath());
  }

  final Pattern oldNamingPattern = Pattern.compile("^vf\\.gf#(.*)\\.jar#(\\d+)$");

  /*
   * In Geode 1.1.0, the deployed version of 'myjar.jar' would be named 'vf.gf#myjar.jar#1'. Now it
   * is be named 'myjar.v1.jar'. We need to rename all existing deployed jars to the new convention
   * if this is the first time starting up with the new naming format.
   */
  protected void renameJarsWithOldNamingConvention() throws IOException {
    Set<File> jarsWithOldNamingConvention = findJarsWithOldNamingConvention();

    if (jarsWithOldNamingConvention.isEmpty()) {
      return;
    }

    for (File jar : jarsWithOldNamingConvention) {
      renameJarWithOldNamingConvention(jar);
    }
  }

  protected Set<File> findJarsWithOldNamingConvention() {
    return Stream.of(this.deployDirectory.listFiles())
        .filter((File file) -> isOldNamingConvention(file.getName())).collect(toSet());
  }

  protected boolean isOldNamingConvention(String fileName) {
    return oldNamingPattern.matcher(fileName).matches();
  }

  private void renameJarWithOldNamingConvention(File oldJar) throws IOException {
    Matcher matcher = oldNamingPattern.matcher(oldJar.getName());
    if (!matcher.matches()) {
      throw new IllegalArgumentException("The given jar " + oldJar.getCanonicalPath()
          + " does not match the old naming convention");
    }

    String unversionedJarNameWithoutExtension = matcher.group(1);
    String jarVersion = matcher.group(2);
    String newJarName = unversionedJarNameWithoutExtension + ".v" + jarVersion + ".jar";

    File newJar = new File(this.deployDirectory, newJarName);
    logger.debug("Renaming deployed jar from {} to {}", oldJar.getCanonicalPath(),
        newJar.getCanonicalPath());

    FileUtils.moveFile(oldJar, newJar);
  }

  /**
   * Re-deploy all previously deployed JAR files on disk.
   */
  public void loadPreviouslyDeployedJarsFromDisk() {
    logger.info("Loading previously deployed jars");
    lock.lock();
    try {
      verifyWritableDeployDirectory();
      renameJarsWithOldNamingConvention();

      final Set<String> jarNames = findDistinctDeployedJarsOnDisk();
      if (jarNames.isEmpty()) {
        return;
      }

      List<DeployedJar> latestVersionOfEachJar = new ArrayList<>();

      for (String jarName : jarNames) {
        DeployedJar deployedJar = findLatestValidDeployedJarFromDisk(jarName);

        if (deployedJar != null) {
          latestVersionOfEachJar.add(deployedJar);
          deleteOtherVersionsOfJar(deployedJar);
        }
      }

      registerNewVersions(latestVersionOfEachJar);
    } catch (Exception e) {
      throw new RuntimeException(e);
    } finally {
      lock.unlock();
    }
  }

  /**
   * Deletes all versions of this jar on disk other than the given version
   */
  public void deleteOtherVersionsOfJar(DeployedJar deployedJar) {
    logger.info("Deleting all versions of " + deployedJar.getJarName() + " other than "
        + deployedJar.getFileName());
    final File[] jarFiles = findSortedOldVersionsOfJar(deployedJar.getJarName());

    Stream.of(jarFiles).filter(jarFile -> !jarFile.equals(deployedJar.getFile()))
        .forEach(jarFile -> {
          logger.info("Deleting old version of jar: " + jarFile.getAbsolutePath());
          FileUtils.deleteQuietly(jarFile);
        });
  }

  public DeployedJar findLatestValidDeployedJarFromDisk(String unversionedJarName)
      throws IOException {
    final File[] jarFiles = findSortedOldVersionsOfJar(unversionedJarName);

    Optional<File> latestValidDeployedJarOptional =
        Arrays.stream(jarFiles).filter(Objects::nonNull).filter(jarFile -> {
          try {
            return DeployedJar.hasValidJarContent(FileUtils.readFileToByteArray(jarFile));
          } catch (IOException e) {
            return false;
          }
        }).findFirst();

    if (!latestValidDeployedJarOptional.isPresent()) {
      // No valid version of this jar
      return null;
    }

    File latestValidDeployedJar = latestValidDeployedJarOptional.get();

    return new DeployedJar(latestValidDeployedJar, unversionedJarName);
  }

  public URL[] getDeployedJarURLs() {
    return this.deployedJars.values().stream().map(DeployedJar::getFileURL).toArray(URL[]::new);

  }

  public List<DeployedJar> registerNewVersions(List<DeployedJar> deployedJars)
      throws ClassNotFoundException {
    lock.lock();
    try {
      Map<DeployedJar, DeployedJar> newVersionToOldVersion = new HashMap<>();

      for (DeployedJar deployedJar : deployedJars) {
        if (deployedJar != null) {
          logger.info("Registering new version of jar: {}", deployedJar);
          DeployedJar oldJar = this.deployedJars.put(deployedJar.getJarName(), deployedJar);
          newVersionToOldVersion.put(deployedJar, oldJar);
        }
      }

      ClassPathLoader.getLatest().rebuildClassLoaderForDeployedJars();

      // Finally, unregister functions that were removed
      for (Map.Entry<DeployedJar, DeployedJar> entry : newVersionToOldVersion.entrySet()) {
        DeployedJar newjar = entry.getKey();
        DeployedJar oldJar = entry.getValue();

        newjar.registerFunctions();

        if (oldJar != null) {
          oldJar.cleanUp(newjar);
        }
      }

    } finally {
      lock.unlock();
    }

    return deployedJars;
  }

  /**
   * Deploy the given JAR files.
   * 
   * @param jarNames Array of names of the JAR files to deploy.
   * @param jarBytes Array of contents of the JAR files to deploy.
   * @return An array of newly created JAR class loaders. Entries will be null for an JARs that were
   *         already deployed.
   * @throws IOException When there's an error saving the JAR file to disk
   */
  public List<DeployedJar> deploy(final String jarNames[], final byte[][] jarBytes)
      throws IOException, ClassNotFoundException {
    DeployedJar[] deployedJars = new DeployedJar[jarNames.length];

    for (int i = 0; i < jarNames.length; i++) {
      if (!DeployedJar.hasValidJarContent(jarBytes[i])) {
        throw new IllegalArgumentException(
            "File does not contain valid JAR content: " + jarNames[i]);
      }
    }

    lock.lock();
    try {
      for (int i = 0; i < jarNames.length; i++) {
        String jarName = jarNames[i];
        byte[] newJarBytes = jarBytes[i];

        deployedJars[i] = deployWithoutRegistering(jarName, newJarBytes);
      }

      return registerNewVersions(Arrays.asList(deployedJars));
    } finally {
      lock.unlock();
    }
  }

  private boolean shouldDeployNewVersion(String jarName, byte[] newJarBytes) throws IOException {
    DeployedJar oldDeployedJar = this.deployedJars.get(jarName);

    if (oldDeployedJar == null) {
      return true;
    }

    if (oldDeployedJar.hasSameContentAs(newJarBytes)) {
      logger.warn("Jar is identical to the latest deployed version: {}",
          oldDeployedJar.getFileCanonicalPath());

      return false;
    }

    return true;
  }

  /**
   * Returns the latest registered {@link DeployedJar} for the given JarName
   * 
   * @param jarName - the unversioned jar name, e.g. myJar.jar
   */
  public DeployedJar findDeployedJar(String jarName) {
    return this.deployedJars.get(jarName);
  }

  public DeployedJar deploy(final String jarName, final byte[] jarBytes)
      throws IOException, ClassNotFoundException {
    lock.lock();

    try {
      List<DeployedJar> deployedJars = deploy(new String[] {jarName}, new byte[][] {jarBytes});
      if (deployedJars == null || deployedJars.size() == 0) {
        return null;
      }

      return deployedJars.get(0);
    } finally {
      lock.unlock();
    }

  }

  public Map<String, DeployedJar> getDeployedJars() {
    return Collections.unmodifiableMap(this.deployedJars);
  }

  /**
   * Undeploy the given JAR file.
   * 
   * @param jarName The name of the JAR file to undeploy
   * @return The path to the location on disk where the JAR file had been deployed
   * @throws IOException If there's a problem deleting the file
   */
  public String undeploy(final String jarName) throws IOException {
    lock.lock();

    try {
      DeployedJar deployedJar = deployedJars.remove(jarName);
      if (deployedJar == null) {
        throw new IllegalArgumentException("JAR not deployed");
      }

      ClassPathLoader.getLatest().rebuildClassLoaderForDeployedJars();

      deployedJar.cleanUp(null);

      deleteAllVersionsOfJar(jarName);
      return deployedJar.getFileCanonicalPath();
    } finally {
      lock.unlock();
    }
  }

  public void deleteAllVersionsOfJar(String unversionedJarName) {
    lock.lock();
    try {
      File[] jarFiles = findSortedOldVersionsOfJar(unversionedJarName);
      for (File jarFile : jarFiles) {
        logger.info("Deleting: {}", jarFile.getAbsolutePath());
        FileUtils.deleteQuietly(jarFile);
      }
    } finally {
      lock.unlock();
    }

  }
}
