// Copyright 2018 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.scan;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static java.nio.file.StandardOpenOption.CREATE;
import static java.util.Comparator.comparing;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.ByteStreams;
import com.google.common.io.Closer;
import com.google.devtools.build.android.Converters.ExistingPathConverter;
import com.google.devtools.build.android.Converters.PathConverter;
import com.google.devtools.build.android.desugar.io.CoreLibraryRewriter;
import com.google.devtools.build.android.desugar.io.HeaderClassLoader;
import com.google.devtools.build.android.desugar.io.IndexedInputs;
import com.google.devtools.build.android.desugar.io.InputFileProvider;
import com.google.devtools.build.android.desugar.io.ThrowingClassLoader;
import com.google.devtools.common.options.Option;
import com.google.devtools.common.options.OptionDocumentationCategory;
import com.google.devtools.common.options.OptionEffectTag;
import com.google.devtools.common.options.OptionsBase;
import com.google.devtools.common.options.OptionsParser;
import com.google.devtools.common.options.ShellQuotedParamsFilePreProcessor;
import java.io.IOError;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.lang.reflect.Method;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.Type;

class KeepScanner {

  public static class KeepScannerOptions extends OptionsBase {
    @Option(
      name = "input",
      defaultValue = "null",
      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
      effectTags = OptionEffectTag.UNKNOWN,
      converter = ExistingPathConverter.class,
      abbrev = 'i',
      help = "Input Jar with classes to scan."
    )
    public Path inputJars;

    @Option(
      name = "classpath_entry",
      allowMultiple = true,
      defaultValue = "",
      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
      effectTags = {OptionEffectTag.UNKNOWN},
      converter = ExistingPathConverter.class,
      help =
          "Ordered classpath (Jar or directory) to resolve symbols in the --input Jar, like "
              + "javac's -cp flag."
    )
    public List<Path> classpath;

    @Option(
      name = "bootclasspath_entry",
      allowMultiple = true,
      defaultValue = "",
      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
      effectTags = {OptionEffectTag.UNKNOWN},
      converter = ExistingPathConverter.class,
      help =
          "Bootclasspath that was used to compile the --input Jar with, like javac's "
              + "-bootclasspath flag (required)."
    )
    public List<Path> bootclasspath;

    @Option(
      name = "keep_file",
      defaultValue = "null",
      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
      effectTags = OptionEffectTag.UNKNOWN,
      converter = PathConverter.class,
      help = "Where to write keep rules to."
    )
    public Path keepDest;

    @Option(
      name = "prefix",
      defaultValue = "j$/",
      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
      effectTags = OptionEffectTag.UNKNOWN,
      help = "type to scan for."
    )
    public String prefix;
  }

  public static void main(String... args) throws Exception {
    OptionsParser parser = OptionsParser.newOptionsParser(KeepScannerOptions.class);
    parser.setAllowResidue(false);
    parser.enableParamsFileSupport(new ShellQuotedParamsFilePreProcessor(FileSystems.getDefault()));
    parser.parseAndExitUponError(args);
    KeepScannerOptions options = parser.getOptions(KeepScannerOptions.class);

    Map<String, ImmutableSet<KeepReference>> seeds;
    try (Closer closer = Closer.create()) {
      // TODO(kmb): Try to share more of this code with Desugar binary
      IndexedInputs classpath =
          new IndexedInputs(toRegisteredInputFileProvider(closer, options.classpath));
      IndexedInputs bootclasspath =
          new IndexedInputs(toRegisteredInputFileProvider(closer, options.bootclasspath));

      // Construct classloader from classpath.  Since we're assuming the prefix we're looking for
      // isn't part of the input itself we shouldn't need to include the input in the classloader.
      CoreLibraryRewriter noopRewriter = new CoreLibraryRewriter("");
      ClassLoader classloader =
          new HeaderClassLoader(classpath, noopRewriter,
              new HeaderClassLoader(bootclasspath, noopRewriter,
                  new ThrowingClassLoader()));
      seeds = scan(checkNotNull(options.inputJars), options.prefix, classloader);
    }

    try (PrintStream out =
        new PrintStream(
            Files.newOutputStream(options.keepDest, CREATE), /*autoFlush=*/ false, "UTF-8")) {
      writeKeepDirectives(out, seeds);
    }
  }

  /**
   * Writes a -keep rule for each class listing any members to keep.  We sort classes and members
   * so the output is deterministic.
   */
  private static void writeKeepDirectives(
      PrintStream out, Map<String, ImmutableSet<KeepReference>> seeds) {
    seeds
        .entrySet()
        .stream()
        .sorted(comparing(Map.Entry::getKey))
        .forEachOrdered(
            type -> {
              out.printf("-keep class %s {%n", type.getKey().replace('/', '.'));
              type.getValue()
                  .stream()
                  .filter(KeepReference::isMemberReference)
                  .sorted(comparing(KeepReference::name).thenComparing(KeepReference::desc))
                  .map(ref -> toKeepDescriptor(ref))
                  .distinct() // drop duplicates due to method descriptors with different returns
                  .forEachOrdered(line -> out.append("  ").append(line).append(";").println());
              out.printf("}%n");
            });
  }

  /** Scans for and returns references with owners matching the given prefix grouped by owner. */
  private static Map<String, ImmutableSet<KeepReference>> scan(
      Path jarFile, String prefix, ClassLoader classpath) throws IOException {
    // We read the Jar sequentially since ZipFile uses locks anyway but then allow scanning each
    // class in parallel.
    try (ZipFile zip = new ZipFile(jarFile.toFile())) {
      return zip.stream()
          .filter(entry -> entry.getName().endsWith(".class"))
          .map(entry -> readFully(zip, entry))
          .parallel()
          .flatMap(
              content -> PrefixReferenceScanner.scan(new ClassReader(content), prefix).stream())
          .distinct() // so we don't process the same reference multiple times next
          .map(ref -> nearestDeclaration(ref, classpath))
          .collect(
              Collectors.groupingByConcurrent(
                  KeepReference::internalName, ImmutableSet.toImmutableSet()));
    }
  }

  private static byte[] readFully(ZipFile zip, ZipEntry entry) {
    byte[] result = new byte[(int) entry.getSize()];
    try (InputStream content = zip.getInputStream(entry)) {
      ByteStreams.readFully(content, result);
      return result;
    } catch (IOException e) {
      throw new IOError(e);
    }
  }

  /**
   * Find the nearest definition of the given reference in the class hierarchy and return the
   * modified reference.  This is needed b/c bytecode sometimes refers to a method or field using
   * an owner type that inherits the method or field instead of defining the member itself.
   * In that case we need to find and keep the inherited definition.
   */
  private static KeepReference nearestDeclaration(KeepReference ref, ClassLoader classpath) {
    if (!ref.isMemberReference() || "<init>".equals(ref.name())) {
      return ref; // class and constructor references don't need any further work
    }

    Class<?> clazz;
    try {
      clazz = classpath.loadClass(ref.internalName().replace('/', '.'));
    } catch (ClassNotFoundException e) {
      throw (NoClassDefFoundError) new NoClassDefFoundError("Couldn't load " + ref).initCause(e);
    }

    Class<?> owner = findDeclaringClass(clazz, ref);
    if (owner == clazz) {
      return ref;
    }
    String parent = checkNotNull(owner, "Can't resolve: %s", ref).getName().replace('.', '/');
    return KeepReference.memberReference(parent, ref.name(), ref.desc());
  }

  private static Class<?> findDeclaringClass(Class<?> clazz, KeepReference ref) {
    if (ref.isFieldReference()) {
      try {
        return clazz.getField(ref.name()).getDeclaringClass();
      } catch (NoSuchFieldException e) {
        // field must be non-public, so search class hierarchy
        do {
          try {
            return clazz.getDeclaredField(ref.name()).getDeclaringClass();
          } catch (NoSuchFieldException ignored) {
            // fall through for clarity
          }
          clazz = clazz.getSuperclass();
        } while (clazz != null);
      }
    } else {
      checkState(ref.isMethodReference());
      Type descriptor = Type.getMethodType(ref.desc());
      for (Method m : clazz.getMethods()) {
        if (m.getName().equals(ref.name()) && Type.getType(m).equals(descriptor)) {
          return m.getDeclaringClass();
        }
      }
      do {
        // Method must be non-public, so search class hierarchy
        for (Method m : clazz.getDeclaredMethods()) {
          if (m.getName().equals(ref.name()) && Type.getType(m).equals(descriptor)) {
            return m.getDeclaringClass();
          }
        }
        clazz = clazz.getSuperclass();
      } while (clazz != null);
    }
    return null;
  }

  private static CharSequence toKeepDescriptor(KeepReference member) {
    StringBuilder result = new StringBuilder();
    if (member.isMethodReference()) {
      if (!"<init>".equals(member.name())) {
        result.append("*** ");
      }
      result.append(member.name()).append("(");
      // Ignore return type as it's unique in the source language
      boolean first = true;
      for (Type param : Type.getMethodType(member.desc()).getArgumentTypes()) {
        if (first) {
          first = false;
        } else {
          result.append(", ");
        }
        result.append(param.getClassName());
      }
      result.append(")");
    } else {
      checkArgument(member.isFieldReference());
      result.append("*** ").append(member.name()); // field names are unique so ignore descriptor
    }
    return result;
  }

  /**
   * Transform a list of Path to a list of InputFileProvider and register them with the given
   * closer.
   */
  @SuppressWarnings("MustBeClosedChecker")
  private static ImmutableList<InputFileProvider> toRegisteredInputFileProvider(
      Closer closer, List<Path> paths) throws IOException {
    ImmutableList.Builder<InputFileProvider> builder = new ImmutableList.Builder<>();
    for (Path path : paths) {
      builder.add(closer.register(InputFileProvider.open(path)));
    }
    return builder.build();
  }

  private KeepScanner() {}
}
