/*
 * 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.test.version;

import static java.util.Collections.unmodifiableList;

import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.function.BiConsumer;

import org.apache.commons.lang3.JavaVersion;
import org.apache.commons.lang3.SystemUtils;

import org.apache.geode.annotations.VisibleForTesting;

/**
 * VersionManager loads the class-paths for all of the releases of Geode configured for
 * backward-compatibility testing in the geode-core build.gradle file.
 *
 * <p>
 * Tests may use these versions in launching VMs to run older clients or servers.
 *
 * <p>
 * See VM.getVM(String version, int whichVM). Example:
 *
 * <pre>
 * import static org.apache.geode.test.dunit.VM.getVM;
 *
 * VM oldServerVM = getVM("1.3.0", 0);
 * </pre>
 */
public class VersionManager {
  public static final String CURRENT_VERSION = "10240.0.0";

  private static final String DEFAULT_VERSIONS_FILE = "geodeOldVersionClasspaths.txt";
  private static final String DEFAULT_INSTALLS_FILE = "geodeOldVersionInstalls.txt";

  private static final VersionManager INSTANCE = newInstance();

  private static VersionManager newInstance() {
    VersionManager versionManager = new VersionManager();
    versionManager.findVersions(DEFAULT_VERSIONS_FILE);
    versionManager.findInstalls(DEFAULT_INSTALLS_FILE);
    versionManager.establishGeodeVersionOrdinal();
    // System.out.println(String.format("VersionManager has loaded the following classpaths:%s%s",
    // lineSeparator(), versionManager.classPaths));
    return versionManager;
  }

  public static VersionManager getInstance() {
    return INSTANCE;
  }

  /**
   * For unit testing, this creates a VersionManager with paths loaded from the given file, which
   * may or may not exist. The instance is not retained
   */
  @VisibleForTesting
  static VersionManager getInstance(String classpathsFileName) {
    VersionManager result = new VersionManager();
    result.findVersions(classpathsFileName);
    return result;
  }

  /*
   * Returns true if the version is equal to the CURRENT_VERSION constant
   */
  public static boolean isCurrentVersion(String version) {
    return version.equals(CURRENT_VERSION);
  }

  private volatile String loadFailure = "";
  private volatile short geodeCurrentVersionOrdinal = -1;

  /**
   * Classpaths for old versions loaded from a file generated by Gradle
   */
  private final Map<String, String> classPaths = new HashMap<>();
  private final List<String> testVersions = new ArrayList<>(10);
  private final Map<String, String> installs = new HashMap<>();

  /**
   * Returns the ordinal of the version of Geode used in this JVM. Use this instead of
   * {@code Version.CURRENT} or {@code Version.CURRENT_ORDINAL} in test code.
   *
   * @return the ordinal of the version of Geode used in this JVM
   */
  public short getCurrentVersionOrdinal() {
    return geodeCurrentVersionOrdinal;
  }

  /**
   * Check to see if a version string is known to VersionManager. Versions are either
   * {@link #CURRENT_VERSION} or one of the versions returned by
   * {@link VersionManager#getVersions()}.
   *
   * @param version the version to check
   * @return whether a version string is known to VersionManager
   */
  public boolean isValidVersion(String version) {
    return version.equals(CURRENT_VERSION) || classPaths.containsKey(version);
  }

  /**
   * Returns the classpath for the given version, or null if the version is not valid. Use
   * {@link #CURRENT_VERSION} for the version in development.
   *
   * @param version the version for which to return the classpath
   * @return the classpath for the given version
   */
  public String getClasspath(String version) {
    return classPaths.get(version);
  }

  public String getInstall(String version) {
    if (!installs.containsKey(version) || installs.get(version) == null) {
      throw new IllegalArgumentException("unable to locate installation directory for " + version);
    }
    return installs.get(version);
  }

  /**
   * Returns a list of older versions available for testing, sorted from oldest to newest.
   *
   * <p>
   * Note: if you need to compare version strings use {@link TestVersion#compare(String, String)} or
   * {@link TestVersion#equals}.
   *
   * @return a list of older versions available for testing, sorted from oldest to newest
   */
  public List<String> getVersions() {
    checkForLoadFailure();
    return unmodifiableList(testVersions);
  }

  public List<String> getVersionsLaterThanAndEqualTo(String version) {
    checkForLoadFailure();
    List<String> result = new ArrayList<>(testVersions);
    result.removeIf(s -> TestVersion.compare(s, version) < 0);
    result.add(CURRENT_VERSION);
    return result;
  }

  /**
   * Returns a list of testable versions sorted from oldest to newest
   *
   * <p>
   * Note: if you need to compare version strings use {@link TestVersion#compare(String, String)} or
   * {@link TestVersion#equals}.
   *
   * @return a list of testable versions sorted from oldest to newest
   */
  public List<String> getVersionsWithoutCurrent() {
    checkForLoadFailure();
    List<String> result = new ArrayList<>(testVersions);
    result.remove(CURRENT_VERSION);
    return result;
  }


  private void checkForLoadFailure() {
    if (!loadFailure.isEmpty()) {
      throw new InternalError(loadFailure);
    }
  }

  private void findVersions(String fileName) {
    // this file is created by the gradle task :geode-old-versions:createGeodeClasspathsFile
    readVersionsFile(fileName, (version, path) -> {
      Optional<String> parsedVersion = parseVersion(version);
      if (parsedVersion.isPresent()) {
        if (parsedVersion.get().equals("1.4.0")
            && SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_9)) {
          // Serialization filtering was added in 140, but the support for them in java 9+ was added
          // in 1.5.0. As a result, 1.4.0 servers and clients will fail categorically when run in
          // Java 9+ even with the additional libs (jaxb and activation) in the classpath
          System.err.println(
              "Geode version 1.4.0 is incompatible with Java 9 and higher.  Skipping this version.");
        } else {
          classPaths.put(parsedVersion.get(), path);
          testVersions.add(parsedVersion.get());
        }
      }
    });
    testVersions.sort(TestVersion::compare);
  }

  private void findInstalls(String fileName) {
    readVersionsFile(fileName, (version, install) -> {
      Optional<String> parsedVersion = parseVersion(version);
      parsedVersion.ifPresent(s -> installs.put(s, install));
    });
  }

  private Optional<String> parseVersion(String version) {
    String parsedVersion = null;
    if (!version.isEmpty()
        && Character.isDigit(version.charAt(0))
        && version.length() >= "1.2.3".length()) {
      parsedVersion = version;
    }
    return Optional.ofNullable(parsedVersion);
  }

  private void readVersionsFile(String fileName, BiConsumer<String, String> consumer) {
    Properties props = readPropertiesFile(fileName);
    props.forEach((k, v) -> consumer.accept(k.toString(), v.toString()));
  }

  private Properties readPropertiesFile(String fileName) {
    // this file is created by the gradle task :geode-old-versions:createGeodeClasspathsFile
    Properties props = new Properties();
    URL url = VersionManager.class.getResource("/" + fileName);
    if (url == null) {
      loadFailure = "VersionManager: unable to locate " + fileName + " in class-path";
      return props;
    }

    try (InputStream in = url.openStream()) {
      props.load(in);
    } catch (IOException e) {
      loadFailure = "VersionManager: unable to read resource " + fileName;
    }
    return props;
  }

  private void establishGeodeVersionOrdinal() {
    try {
      geodeCurrentVersionOrdinal = findOrdinalField(findVersionClass()).getShort(null);
    } catch (IllegalAccessException e) {
      throw new IllegalStateException(
          "Unable to retrieve Version.java's CURRENT_ORDINAL field in order to establish the product's serialization version",
          e);
    }
  }

  private Class<?> findVersionClass() {
    /*
     * The known versions "enum" class has moved packages and been renamed over time:
     *
     * 1. org.apache.geode.internal.Version
     * 2. org.apache.geode.internal.serialization.Version
     * 3. org.apache.geode.internal.serialization.KnownVersion
     *
     * We search in order 3,1,2 because when we renamed the class "KnownVersion"
     * we also had an interface in the same package, called "Version" so we want to
     * avoid finding that interface when we really want the "enum" class.
     */
    try {
      return Class.forName("org.apache.geode.internal.serialization.KnownVersion");
    } catch (ClassNotFoundException e) {
      try {
        return Class.forName("org.apache.geode.internal.Version");
      } catch (ClassNotFoundException e2) {
        try {
          return Class.forName("org.apache.geode.internal.serialization.Version");
        } catch (ClassNotFoundException e3) {
          System.out.println("classpath is " + System.getProperty("java.class.path"));
          throw new IllegalStateException(
              "Unable to locate Version or KnownVersion in order to establish the product's serialization version",
              e3);
        }
      }
    }
  }

  private Field findOrdinalField(Class<?> versionClass) {
    try {
      Field currentOrdinalField = versionClass.getDeclaredField("CURRENT_ORDINAL");
      currentOrdinalField.setAccessible(true);
      return currentOrdinalField;
    } catch (NoSuchFieldException e) {
      throw new IllegalStateException(
          "Unable to locate Version.java's CURRENT_ORDINAL field in order to establish the product's serialization version",
          e);
    }
  }

  @VisibleForTesting
  String getLoadFailure() {
    return loadFailure;
  }
}
