/*
 * Copyright 2000-2016 JetBrains s.r.o.
 *
 * Licensed 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.
 */

/*
 * Created by IntelliJ IDEA.
 * User: mike
 * Date: Aug 19, 2002
 * Time: 8:21:52 PM
 * To change template for new class use
 * Code Style | Class Templates options (Tools | IDE Options).
 */
package com.intellij.openapi.application.ex;

import com.intellij.openapi.application.PathManager;
import com.intellij.openapi.module.impl.ModuleManagerImpl;
import com.intellij.openapi.module.impl.ModulePath;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.JDOMUtil;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.testFramework.Parameterized;
import com.intellij.testFramework.TestRunnerUtil;
import com.intellij.util.containers.ContainerUtil;
import gnu.trove.THashSet;
import junit.framework.TestCase;
import org.jdom.Element;
import org.jdom.JDOMException;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.jps.model.serialization.JDomSerializationUtil;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Modifier;
import java.util.*;
import java.util.concurrent.ConcurrentMap;

import static com.intellij.openapi.util.io.FileUtil.toSystemDependentName;
import static java.util.Arrays.asList;

public class PathManagerEx {

  /**
   * All IDEA project files may be logically divided by the following criteria:
   * <ul>
   *   <li>files that are contained at <code>'community'</code> directory;</li>
   *   <li>all other files;</li>
   * </ul>
   * <p/>
   * File location types implied by criteria mentioned above are enumerated here.
   */
  private enum FileSystemLocation {
    ULTIMATE, COMMUNITY
  }

  /**
   * Caches test data lookup strategy by class.
   */
  private static final ConcurrentMap<Class, TestDataLookupStrategy> CLASS_STRATEGY_CACHE = ContainerUtil.newConcurrentMap();
  private static final ConcurrentMap<String, Class> CLASS_CACHE = ContainerUtil.newConcurrentMap();
  private static Set<String> ourCommunityModules;

  private PathManagerEx() { }

  /**
   * Enumerates possible strategies of test data lookup.
   * <p/>
   * Check member-level javadoc for more details.
   */
  public enum TestDataLookupStrategy {
    /**
     * Stands for algorithm that retrieves <code>'test data'</code> stored at the <code>'ultimate'</code> project level assuming
     * that it's used from the test running in context of <code>'ultimate'</code> project as well.
     * <p/>
     * Is assumed to be default strategy for all <code>'ultimate'</code> tests.
     */
    ULTIMATE,

    /**
     * Stands for algorithm that retrieves <code>'test data'</code> stored at the <code>'community'</code> project level assuming
     * that it's used from the test running in context of <code>'community'</code> project as well.
     * <p/>
     * Is assumed to be default strategy for all <code>'community'</code> tests.
     */
    COMMUNITY,

    /**
     * Stands for algorithm that retrieves <code>'test data'</code> stored at the <code>'community'</code> project level assuming
     * that it's used from the test running in context of <code>'ultimate'</code> project.
     */
    COMMUNITY_FROM_ULTIMATE
  }

  /**
   * It's assumed that test data location for both <code>community</code> and <code>ultimate</code> tests follows the same template:
   * <code>'<IDEA_HOME>/<RELATIVE_PATH>'</code>.
   * <p/>
   * <code>'IDEA_HOME'</code> here stands for path to IDEA installation; <code>'RELATIVE_PATH'</code> defines a path to
   * test data relative to IDEA installation path. That relative path may be different for <code>community</code>
   * and <code>ultimate</code> tests.
   * <p/>
   * This collection contains mappings from test group type to relative paths to use, i.e. it's possible to define more than one
   * relative path for the single test group. It's assumed that path definition algorithm iterates them and checks if
   * resulting absolute path points to existing directory. The one is returned in case of success; last path is returned otherwise.
   * <p/>
   * Hence, the order of relative paths for the single test group matters.
   */
  private static final Map<TestDataLookupStrategy, List<String>> TEST_DATA_RELATIVE_PATHS
    = new EnumMap<>(TestDataLookupStrategy.class);

  static {
    TEST_DATA_RELATIVE_PATHS.put(TestDataLookupStrategy.ULTIMATE, Collections.singletonList(toSystemDependentName("testData")));
    TEST_DATA_RELATIVE_PATHS.put(
      TestDataLookupStrategy.COMMUNITY,
      Collections.singletonList(toSystemDependentName("java/java-tests/testData"))
    );
    TEST_DATA_RELATIVE_PATHS.put(
      TestDataLookupStrategy.COMMUNITY_FROM_ULTIMATE,
      Collections.singletonList(toSystemDependentName("community/java/java-tests/testData"))
    );
  }

  /**
   * Shorthand for calling {@link #getTestDataPath(TestDataLookupStrategy)} with
   * {@link #guessTestDataLookupStrategy() guessed} lookup strategy.
   *
   * @return    test data path with {@link #guessTestDataLookupStrategy() guessed} lookup strategy
   * @throws IllegalStateException    as defined by {@link #getTestDataPath(TestDataLookupStrategy)}
   */
  @NonNls
  public static String getTestDataPath() throws IllegalStateException {
    TestDataLookupStrategy strategy = guessTestDataLookupStrategy();
    return getTestDataPath(strategy);
  }

  public static String getTestDataPath(String path) throws IllegalStateException {
    return getTestDataPath() + path.replace('/', File.separatorChar);
  }

  /**
   * Shorthand for calling {@link #getTestDataPath(TestDataLookupStrategy)} with strategy obtained via call to
   * {@link #determineLookupStrategy(Class)} with the given class.
   * <p/>
   * <b>Note:</b> this method receives explicit class argument in order to solve the following limitation - we analyze calling
   * stack trace in order to guess test data lookup strategy ({@link #guessTestDataLookupStrategyOnClassLocation()}). However,
   * there is a possible case that super-class method is called on sub-class object. Stack trace shows super-class then.
   * There is a possible situation that actual test is <code>'ultimate'</code> but its abstract super-class is
   * <code>'community'</code>, hence, test data lookup is performed incorrectly. So, this method should be called from abstract
   * base test class if its concrete sub-classes doesn't explicitly occur at stack trace.
   *
   *
   * @param testClass     target test class for which test data should be obtained
   * @return              base test data directory to use for the given test class
   * @throws IllegalStateException    as defined by {@link #getTestDataPath(TestDataLookupStrategy)}
   */
  public static String getTestDataPath(Class<?> testClass) throws IllegalStateException {
    TestDataLookupStrategy strategy = isLocatedInCommunity() ? TestDataLookupStrategy.COMMUNITY : determineLookupStrategy(testClass);
    return getTestDataPath(strategy);
  }

  /**
   * @return path to 'community' project home irrespective of current project
   */
  @NotNull
  public static String getCommunityHomePath() {
    String path = PathManager.getHomePath();
    return isLocatedInCommunity() ? path : path + File.separator + "community";
  }

  /**
   * @return path to 'community' project home if {@code testClass} is located in the community project and path to 'ultimate' project otherwise
   */
  public static String getHomePath(Class<?> testClass) {
    TestDataLookupStrategy strategy = isLocatedInCommunity() ? TestDataLookupStrategy.COMMUNITY : determineLookupStrategy(testClass);
    return strategy == TestDataLookupStrategy.COMMUNITY_FROM_ULTIMATE ? getCommunityHomePath() : PathManager.getHomePath();
  }

  /**
   * Find file by its path relative to 'community' directory irrespective of current project
   * @param relativePath path to file relative to 'community' directory
   * @return file under the home directory of 'community' project
   */
  public static File findFileUnderCommunityHome(String relativePath) {
    File file = new File(getCommunityHomePath(), toSystemDependentName(relativePath));
    if (!file.exists()) {
      throw new IllegalArgumentException("Cannot find file '" + relativePath + "' under '" + getCommunityHomePath() + "' directory");
    }
    return file;
  }

  /**
   * Find file by its path relative to project home directory (the 'community' project if {@code testClass} is located
   * in the community project, and the 'ultimate' project otherwise)
   */
  public static File findFileUnderProjectHome(String relativePath, Class<? extends TestCase> testClass) {
    String homePath = getHomePath(testClass);
    File file = new File(homePath, toSystemDependentName(relativePath));
    if (!file.exists()) {
      throw new IllegalArgumentException("Cannot find file '" + relativePath + "' under '" + homePath + "' directory");
    }
    return file;
  }

  private static boolean isLocatedInCommunity() {
    FileSystemLocation projectLocation = parseProjectLocation();
    return projectLocation == FileSystemLocation.COMMUNITY;
    // There is no other options then.
  }

  /**
   * Tries to return test data path for the given lookup strategy.
   *
   * @param strategy    lookup strategy to use
   * @return            test data path for the given strategy
   * @throws IllegalStateException    if it's not possible to find valid test data path for the given strategy
   */
  @NonNls
  public static String getTestDataPath(TestDataLookupStrategy strategy) throws IllegalStateException {
    String homePath = PathManager.getHomePath();

    List<String> relativePaths = TEST_DATA_RELATIVE_PATHS.get(strategy);
    if (relativePaths.isEmpty()) {
      throw new IllegalStateException(
        String.format("Can't determine test data path. Reason: no predefined relative paths are configured for test data "
                      + "lookup strategy %s. Configured mappings: %s", strategy, TEST_DATA_RELATIVE_PATHS)
      );
    }

    File candidate = null;
    for (String relativePath : relativePaths) {
      candidate = new File(homePath, relativePath);
      if (candidate.isDirectory()) {
        return candidate.getPath();
      }
    }

    if (candidate == null) {
      throw new IllegalStateException("Can't determine test data path. Looks like programming error - reached 'if' block that was "
                                      + "never expected to be executed");
    }
    return candidate.getPath();
  }

  /**
   * Tries to guess test data lookup strategy for the current execution.
   *
   * @return    guessed lookup strategy for the current execution; defaults to {@link TestDataLookupStrategy#ULTIMATE}
   */
  public static TestDataLookupStrategy guessTestDataLookupStrategy() {
    TestDataLookupStrategy result = guessTestDataLookupStrategyOnClassLocation();
    if (result == null) {
      result = guessTestDataLookupStrategyOnDirectoryAvailability();
    }
    return result;
  }

  @SuppressWarnings({"ThrowableInstanceNeverThrown"})
  @Nullable
  private static TestDataLookupStrategy guessTestDataLookupStrategyOnClassLocation() {
    if (isLocatedInCommunity()) return TestDataLookupStrategy.COMMUNITY;

    // The general idea here is to find test class at the bottom of hierarchy and try to resolve test data lookup strategy
    // against it. Rationale is that there is a possible case that, say, 'ultimate' test class extends basic test class
    // that remains at 'community'. We want to perform the processing against 'ultimate' test class then.

    // About special abstract classes processing - there is a possible case that target test class extends abstract base
    // test class and call to this method is rooted from that parent. We need to resolve test data lookup against super
    // class then, hence, we keep track of found abstract test class as well and fallback to it if no non-abstract class is found.

    Class<?> testClass = null;
    Class<?> abstractTestClass = null;
    StackTraceElement[] stackTrace = new Exception().getStackTrace();
    for (StackTraceElement stackTraceElement : stackTrace) {
      String className = stackTraceElement.getClassName();
      Class<?> clazz = loadClass(className);
      if (clazz == null || TestCase.class == clazz || !isJUnitClass(clazz)) {
        continue;
      }

      if (determineLookupStrategy(clazz) == TestDataLookupStrategy.ULTIMATE) return TestDataLookupStrategy.ULTIMATE;
      if ((clazz.getModifiers() & Modifier.ABSTRACT) == 0) {
        testClass = clazz;
      }
      else {
        abstractTestClass = clazz;
      }
    }

    Class<?> classToUse = testClass == null ? abstractTestClass : testClass;
    return classToUse == null ? null : determineLookupStrategy(classToUse);
  }

  @Nullable
  private static Class<?> loadClass(String className) {
    ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();

    Class<?> clazz = CLASS_CACHE.get(className);
    if (clazz != null) {
      return clazz;
    }

    ClassLoader definingClassLoader = PathManagerEx.class.getClassLoader();
    ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();

    for (ClassLoader classLoader : asList(contextClassLoader, definingClassLoader, systemClassLoader)) {
      clazz = loadClass(className, classLoader);
      if (clazz != null) {
        CLASS_CACHE.put(className, clazz);
        return clazz;
      }
    }

    CLASS_CACHE.put(className, TestCase.class); //dummy
    return null;
  }

  @Nullable
  private static Class<?> loadClass(String className, ClassLoader classLoader) {
    try {
      return Class.forName(className, true, classLoader);
    }
    catch (NoClassDefFoundError | ClassNotFoundException e) {
      return null;
    }
  }

  @SuppressWarnings("TestOnlyProblems")
  private static boolean isJUnitClass(Class<?> clazz) {
    return TestCase.class.isAssignableFrom(clazz) || TestRunnerUtil.isJUnit4TestClass(clazz) || Parameterized.class.isAssignableFrom(clazz);
  }

  @Nullable
  private static TestDataLookupStrategy determineLookupStrategy(Class<?> clazz) {
    // Check if resulting strategy is already cached for the target class.
    TestDataLookupStrategy result = CLASS_STRATEGY_CACHE.get(clazz);
    if (result != null) {
      return result;
    }

    FileSystemLocation classFileLocation = computeClassLocation(clazz);

    // We know that project location is ULTIMATE if control flow reaches this place.
    result = classFileLocation == FileSystemLocation.COMMUNITY ? TestDataLookupStrategy.COMMUNITY_FROM_ULTIMATE
                                                               : TestDataLookupStrategy.ULTIMATE;
    CLASS_STRATEGY_CACHE.put(clazz, result);
    return result;
  }

  public static void replaceLookupStrategy(Class<?> substitutor, Class<?>... initial) {
    CLASS_STRATEGY_CACHE.clear();
    for (Class<?> aClass : initial) {
      CLASS_STRATEGY_CACHE.put(aClass, determineLookupStrategy(substitutor));
    }
  }

  private static FileSystemLocation computeClassLocation(Class<?> clazz) {
    String classRootPath = PathManager.getJarPathForClass(clazz);
    if (classRootPath == null) {
      throw new IllegalStateException("Cannot find root directory for " + clazz);
    }
    File root = new File(classRootPath);
    if (!root.exists()) {
      throw new IllegalStateException("Classes root " + root + " doesn't exist");
    }
    if (!root.isDirectory()) {
      //this means that clazz is located in a library, perhaps we should throw exception here
      return FileSystemLocation.ULTIMATE;
    }

    String moduleName = root.getName();
    String chunkPrefix = "ModuleChunk(";
    if (moduleName.startsWith(chunkPrefix)) {
      //todo[nik] this is temporary workaround to fix tests on TeamCity which compiles the whole modules cycle to a single output directory
      moduleName = StringUtil.trimStart(moduleName, chunkPrefix);
      moduleName = moduleName.substring(0, moduleName.indexOf(','));
    }
    return getCommunityModules().contains(moduleName) ? FileSystemLocation.COMMUNITY : FileSystemLocation.ULTIMATE;
  }

  private synchronized static Set<String> getCommunityModules() {
    if (ourCommunityModules != null) {
      return ourCommunityModules;
    }

    ourCommunityModules = new THashSet<>();
    File modulesXml = findFileUnderCommunityHome(Project.DIRECTORY_STORE_FOLDER + "/modules.xml");
    if (!modulesXml.exists()) {
      throw new IllegalStateException("Cannot obtain test data path: " + modulesXml.getAbsolutePath() + " not found");
    }

    try {
      Element element = JDomSerializationUtil.findComponent(JDOMUtil.load(modulesXml), ModuleManagerImpl.COMPONENT_NAME);
      assert element != null;
      for (ModulePath file : ModuleManagerImpl.getPathsToModuleFiles(element)) {
        ourCommunityModules.add(file.getModuleName());
      }
      return ourCommunityModules;
    }
    catch (JDOMException | IOException e) {
      throw new RuntimeException("Cannot read modules from " + modulesXml.getAbsolutePath(), e);
    }
  }

  /**
   * Allows to determine project type by its file system location.
   *
   * @return    project type implied by its file system location
   */
  private static FileSystemLocation parseProjectLocation() {
    return new File(PathManager.getHomePath(), "community/.idea").isDirectory() ? FileSystemLocation.ULTIMATE : FileSystemLocation.COMMUNITY;
  }

  /**
   * Tries to check test data lookup strategy by target test data directories availability.
   * <p/>
   * Such an approach has a drawback that it doesn't work correctly at number of scenarios, e.g. when
   * <code>'community'</code> test is executed under <code>'ultimate'</code> project.
   *
   * @return    test data lookup strategy based on target test data directories availability
   */
  private static TestDataLookupStrategy guessTestDataLookupStrategyOnDirectoryAvailability() {
    String homePath = PathManager.getHomePath();
    for (Map.Entry<TestDataLookupStrategy, List<String>> entry : TEST_DATA_RELATIVE_PATHS.entrySet()) {
      for (String relativePath : entry.getValue()) {
        if (new File(homePath, relativePath).isDirectory()) {
          return entry.getKey();
        }
      }
    }
    return TestDataLookupStrategy.ULTIMATE;
  }
}
