/*
 * Copyright 2020 The Bazel Authors. All rights reserved.
 *
 * 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.
 */

package com.google.devtools.build.android.desugar.testing.junit;

import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Multimap;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.jar.JarEntry;
import javax.inject.Inject;
import javax.tools.ToolProvider;
import org.junit.runner.RunWith;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FieldNode;
import org.objectweb.asm.tree.MethodNode;

/** The builder class for {@link DesugarRule}. */
public class DesugarRuleBuilder {

  private static final ImmutableSet<Class<?>> SUPPORTED_ASM_NODE_TYPES =
      ImmutableSet.of(ClassNode.class, FieldNode.class, MethodNode.class);

  private static final String ANDROID_RUNTIME_JAR_JVM_FLAG_KEY = "android_runtime_jar";
  private static final String JACOCO_AGENT_JAR_JVM_FLAG_KEY = "jacoco_agent_jar";
  private static final String DUMP_PROXY_CLASSES_JVM_FLAG_KEY =
      "jdk.internal.lambda.dumpProxyClasses";

  private final Object testInstance;
  private final MethodHandles.Lookup testInstanceLookup;
  private final ImmutableList<Field> injectableClassLiterals;
  private final ImmutableList<Field> injectableAsmNodes;
  private final ImmutableList<Field> injectableMethodHandles;
  private final ImmutableList<Field> injectableJarEntries;
  private String workingJavaPackage = "";
  private int maxNumOfTransformations = 1;
  private final List<Path> inputs = new ArrayList<>();
  private final List<Path> sourceInputs = new ArrayList<>();
  private final List<String> javacOptions = new ArrayList<>();
  private final List<Path> classPathEntries = new ArrayList<>();
  private final List<Path> bootClassPathEntries = new ArrayList<>();
  private final Multimap<String, String> customCommandOptions = LinkedHashMultimap.create();
  private final ErrorMessenger errorMessenger = new ErrorMessenger();

  private final String androidRuntimeJarJvmFlagValue;
  private final String jacocoAgentJarJvmFlagValue;

  DesugarRuleBuilder(Object testInstance, MethodHandles.Lookup testInstanceLookup) {
    this.testInstance = testInstance;
    this.testInstanceLookup = testInstanceLookup;
    Class<?> testClass = testInstance.getClass();

    androidRuntimeJarJvmFlagValue = getExplicitJvmFlagValue(ANDROID_RUNTIME_JAR_JVM_FLAG_KEY);
    jacocoAgentJarJvmFlagValue = getExplicitJvmFlagValue(JACOCO_AGENT_JAR_JVM_FLAG_KEY);

    if (testClass != testInstanceLookup.lookupClass()) {
      errorMessenger.addError(
          "Expected testInstanceLookup has private access to (%s), but get (%s). Have you"
              + " passed MethodHandles.lookup() to testInstanceLookup in test class?",
          testClass, testInstanceLookup.lookupClass());
    }
    if (!testClass.isAnnotationPresent(RunWith.class)) {
      errorMessenger.addError(
          "Expected a test instance whose class is annotated with @RunWith. %s", testClass);
    }

    injectableClassLiterals =
        findAllInjectableFieldsWithQualifier(testClass, DynamicClassLiteral.class);
    injectableAsmNodes = findAllInjectableFieldsWithQualifier(testClass, AsmNode.class);
    injectableMethodHandles =
        findAllInjectableFieldsWithQualifier(testClass, RuntimeMethodHandle.class);
    injectableJarEntries = findAllInjectableFieldsWithQualifier(testClass, RuntimeJarEntry.class);
  }

  static ImmutableList<Field> findAllInjectableFieldsWithQualifier(
      Class<?> testClass, Class<? extends Annotation> annotationClass) {
    ImmutableList.Builder<Field> fields = ImmutableList.builder();
    for (Class<?> currentClass = testClass;
        currentClass != null;
        currentClass = currentClass.getSuperclass()) {
      for (Field field : currentClass.getDeclaredFields()) {
        if (field.isAnnotationPresent(Inject.class) && field.isAnnotationPresent(annotationClass)) {
          fields.add(field);
        }
      }
    }
    return fields.build();
  }

  @CanIgnoreReturnValue
  public DesugarRuleBuilder setWorkingJavaPackage(String workingJavaPackage) {
    this.workingJavaPackage = workingJavaPackage;
    return this;
  }

  @CanIgnoreReturnValue
  public DesugarRuleBuilder enableIterativeTransformation(int maxNumOfTransformations) {
    this.maxNumOfTransformations = maxNumOfTransformations;
    return this;
  }

  @CanIgnoreReturnValue
  public DesugarRuleBuilder addInputs(Path... inputJars) {
    for (Path path : inputJars) {
      if (!path.toString().endsWith(".jar")) {
        errorMessenger.addError("Expected a JAR file (*.jar): Actual (%s)", path);
      }
      if (!Files.exists(path)) {
        errorMessenger.addError("File does not exist: %s", path);
      }
      inputs.add(path);
    }
    return this;
  }

  /** Add Java source files subject to be compiled during test execution. */
  @CanIgnoreReturnValue
  public DesugarRuleBuilder addSourceInputs(Path... inputSourceFiles) {
    for (Path path : inputSourceFiles) {
      if (!path.toString().endsWith(".java")) {
        errorMessenger.addError("Expected a Java source file (*.java): Actual (%s)", path);
      }
      if (!Files.exists(path)) {
        errorMessenger.addError("File does not exist: %s", path);
      }
      sourceInputs.add(path);
    }
    return this;
  }

  /**
   * Add JVM-flag-specified Java source files subject to be compiled during test execution. It is
   * expected the value associated with `jvmFlagKey` to be a space-separated Strings. E.g. on the
   * command line you would set it like: -Dinput_srcs="path1 path2 path3", and use <code>
   *  .addSourceInputsFromJvmFlag("input_srcs").</code> in your test class.
   */
  public DesugarRuleBuilder addSourceInputsFromJvmFlag(String jvmFlagKey) {
    return addSourceInputs(DesugarTestHelpers.getRuntimePathsFromJvmFlag(jvmFlagKey));
  }

  /**
   * Add JVM-flag-specified Java source files subject to be compiled during test execution. It is
   * expected the value associated with `jvmFlagKey` to be a space-separated Strings. E.g. on the
   * command line you would set it like: -Dinput_srcs="path1 path2 path3", and use <code>
   *  .addSourceInputsFromJvmFlag("input_srcs").</code> in your test class.
   */
  public DesugarRuleBuilder addJarInputsFromJvmFlag(String jvmFlagKey) {
    return addInputs(DesugarTestHelpers.getRuntimePathsFromJvmFlag(jvmFlagKey));
  }

  /**
   * Add javac options used for compilation, with the same support of `javacopts` attribute in
   * java_binary rule.
   */
  @CanIgnoreReturnValue
  public DesugarRuleBuilder addJavacOptions(String... javacOptions) {
    for (String javacOption : javacOptions) {
      if (!javacOption.startsWith("-")) {
        errorMessenger.addError(
            "Expected javac options, e.g. `-source 11`, `-target 11`, `-parameters`, Run `javac"
                + " -help` from terminal for all supported options.");
      }
      this.javacOptions.add(javacOption);
    }
    return this;
  }

  @CanIgnoreReturnValue
  public DesugarRuleBuilder addClasspathEntries(Path... inputJars) {
    Collections.addAll(classPathEntries, inputJars);
    return this;
  }

  @CanIgnoreReturnValue
  public DesugarRuleBuilder addBootClassPathEntries(Path... inputJars) {
    Collections.addAll(bootClassPathEntries, inputJars);
    return this;
  }

  /** Format: --<key>=<value> */
  @CanIgnoreReturnValue
  public DesugarRuleBuilder addCommandOptions(String key, String value) {
    customCommandOptions.put(key, value);
    return this;
  }

  private void checkJVMOptions() {
    if (Strings.isNullOrEmpty(getExplicitJvmFlagValue(DUMP_PROXY_CLASSES_JVM_FLAG_KEY))) {
      errorMessenger.addError(
          "Expected JVM flag: \"-D%s=$$(mktemp -d)\", but it is absent.",
          DUMP_PROXY_CLASSES_JVM_FLAG_KEY);
    }

    if (Strings.isNullOrEmpty(androidRuntimeJarJvmFlagValue)
        || !androidRuntimeJarJvmFlagValue.endsWith(".jar")
        || !Files.exists(Paths.get(androidRuntimeJarJvmFlagValue))) {
      errorMessenger.addError(
          "Android Runtime Jar does not exist: Please check JVM flag: -D%s='%s'",
          ANDROID_RUNTIME_JAR_JVM_FLAG_KEY, androidRuntimeJarJvmFlagValue);
    }

    if (Strings.isNullOrEmpty(jacocoAgentJarJvmFlagValue)
        || !jacocoAgentJarJvmFlagValue.endsWith(".jar")
        || !Files.exists(Paths.get(jacocoAgentJarJvmFlagValue))) {
      errorMessenger.addError(
          "Jacoco Agent Jar does not exist: Please check JVM flag: -D%s='%s'",
          JACOCO_AGENT_JAR_JVM_FLAG_KEY, jacocoAgentJarJvmFlagValue);
    }
  }

  public DesugarRule build() {
    checkInjectableClassLiterals();
    checkInjectableAsmNodes();
    checkInjectableMethodHandles();
    checkInjectableJarEntries();

    if (maxNumOfTransformations > 0) {
      checkJVMOptions();
      if (bootClassPathEntries.isEmpty()
          && !customCommandOptions.containsKey("allow_empty_bootclasspath")) {
        addBootClassPathEntries(Paths.get(androidRuntimeJarJvmFlagValue));
      }
      addClasspathEntries(Paths.get(jacocoAgentJarJvmFlagValue));
    }

    ImmutableList.Builder<SourceCompilationUnit> sourceCompilationUnits = ImmutableList.builder();
    if (!sourceInputs.isEmpty()) {
      try {
        Path runtimeCompiledJar = Files.createTempFile("runtime_compiled_", ".jar");
        sourceCompilationUnits.add(
            new SourceCompilationUnit(
                ToolProvider.getSystemJavaCompiler(),
                ImmutableList.copyOf(javacOptions),
                ImmutableList.copyOf(sourceInputs),
                runtimeCompiledJar));
        addInputs(runtimeCompiledJar);
      } catch (IOException e) {
        errorMessenger.addError(
            "Failed to access the output jar location for compilation: %s\n%s", sourceInputs, e);
      }
    }

    RuntimeEntityResolver runtimeEntityResolver =
        new RuntimeEntityResolver(
            testInstanceLookup,
            workingJavaPackage,
            maxNumOfTransformations,
            ImmutableList.copyOf(inputs),
            ImmutableList.copyOf(classPathEntries),
            ImmutableList.copyOf(bootClassPathEntries),
            ImmutableListMultimap.copyOf(customCommandOptions));

    if (errorMessenger.containsAnyError()) {
      throw new IllegalStateException(
          String.format(
              "Invalid Desugar configurations:\n%s\n",
              String.join("\n", errorMessenger.getAllMessages())));
    }

    return new DesugarRule(
        testInstance,
        testInstanceLookup,
        ImmutableList.<Field>builder()
            .addAll(injectableClassLiterals)
            .addAll(injectableAsmNodes)
            .addAll(injectableMethodHandles)
            .addAll(injectableJarEntries)
            .build(),
        sourceCompilationUnits.build(),
        runtimeEntityResolver);
  }

  private void checkInjectableClassLiterals() {
    for (Field field : injectableClassLiterals) {
      if (Modifier.isStatic(field.getModifiers())) {
        errorMessenger.addError("Expected to be non-static for field (%s)", field);
      }

      if (field.getType() != Class.class) {
        errorMessenger.addError("Expected a class literal type (Class<?>) for field (%s)", field);
      }

      DynamicClassLiteral dynamicClassLiteralAnnotation =
          field.getDeclaredAnnotation(DynamicClassLiteral.class);
      int round = dynamicClassLiteralAnnotation.round();
      if (round < 0 || round > maxNumOfTransformations) {
        errorMessenger.addError(
            "Expected the round (Actual:%d) of desugar transformation within [0, %d], where 0"
                + " indicates no transformation is applied.",
            round, maxNumOfTransformations);
      }
    }
  }

  private void checkInjectableAsmNodes() {
    for (Field field : injectableAsmNodes) {
      if (Modifier.isStatic(field.getModifiers())) {
        errorMessenger.addError("Expected to be non-static for field (%s)", field);
      }

      if (!SUPPORTED_ASM_NODE_TYPES.contains(field.getType())) {
        errorMessenger.addError(
            "Expected @inject @AsmNode on a field in one of: (%s) but gets (%s)",
            SUPPORTED_ASM_NODE_TYPES, field);
      }

      AsmNode astAsmNodeInfo = field.getDeclaredAnnotation(AsmNode.class);
      int round = astAsmNodeInfo.round();
      if (round < 0 || round > maxNumOfTransformations) {
        errorMessenger.addError(
            "Expected the round (actual:%d) of desugar transformation within [0, %d], where 0"
                + " indicates no transformation is used.",
            round, maxNumOfTransformations);
      }
    }
  }

  private void checkInjectableMethodHandles() {
    for (Field field : injectableMethodHandles) {
      if (Modifier.isStatic(field.getModifiers())) {
        errorMessenger.addError("Expected to be non-static for field (%s)", field);
      }

      if (field.getType() != MethodHandle.class) {
        errorMessenger.addError(
            "Expected @Inject @RuntimeMethodHandle annotated on a field with type (%s), but gets"
                + " (%s)",
            MethodHandle.class, field);
      }

      RuntimeMethodHandle methodHandleRequest =
          field.getDeclaredAnnotation(RuntimeMethodHandle.class);
      int round = methodHandleRequest.round();
      if (round < 0 || round > maxNumOfTransformations) {
        errorMessenger.addError(
            "Expected the round (actual:%d) of desugar transformation within [0, %d], where 0"
                + " indicates no transformation is used.",
            round, maxNumOfTransformations);
      }
    }
  }

  private void checkInjectableJarEntries() {
    for (Field field : injectableJarEntries) {
      if (Modifier.isStatic(field.getModifiers())) {
        errorMessenger.addError("Expected to be non-static for field (%s)", field);
      }

      if (field.getType() != JarEntry.class) {
        errorMessenger.addError(
            "Expected a field with Type: (%s) but gets (%s)", JarEntry.class.getName(), field);
      }

      RuntimeJarEntry jarEntryInfo = field.getDeclaredAnnotation(RuntimeJarEntry.class);
      if (jarEntryInfo.round() < 0 || jarEntryInfo.round() > maxNumOfTransformations) {
        errorMessenger.addError(
            "Expected the round of desugar transformation within [0, %d], where 0 indicates no"
                + " transformation is used.",
            maxNumOfTransformations);
      }
    }
  }

  private static String getExplicitJvmFlagValue(String jvmFlagKey) {
    String jvmFlagValue = System.getProperty(jvmFlagKey);
    return Strings.isNullOrEmpty(jvmFlagValue) ? "" : jvmFlagValue;
  }
}
