// 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.r8;

import static com.google.common.base.Verify.verify;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toList;

import com.android.tools.r8.ByteDataView;
import com.android.tools.r8.CompilationFailedException;
import com.android.tools.r8.CompilationMode;
import com.android.tools.r8.D8;
import com.android.tools.r8.D8Command;
import com.android.tools.r8.DexIndexedConsumer;
import com.android.tools.r8.DiagnosticsHandler;
import com.android.tools.r8.ProgramConsumer;
import com.android.tools.r8.Version;
import com.android.tools.r8.origin.Origin;
import com.android.tools.r8.origin.PathOrigin;
import com.android.tools.r8.utils.ArchiveResourceProvider;
import com.android.tools.r8.utils.ExceptionDiagnostic;
import com.google.common.collect.ImmutableList;
import com.google.common.io.ByteStreams;
import com.google.devtools.build.android.r8.CompatDx.DxCompatOptions.DxUsageMessage;
import com.google.devtools.build.android.r8.CompatDx.DxCompatOptions.PositionInfo;
import com.google.devtools.common.options.Converters.StringConverter;
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 java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

/**
 * Dx compatibility interface for d8.
 *
 * <p>This should become a mostly drop-in replacement for uses of the DX dexer (eg, dx --dex ...).
 */
public class CompatDx {

  private static final String USAGE_HEADER = "Usage: compatdx [options] <input files>";

  /** Commandline options. */
  public static class Options extends OptionsBase {
    @Option(
        name = "dex",
        defaultValue = "true",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Generate dex output.")
    public boolean dex;

    @Option(
        name = "debug",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Print debug information.")
    public boolean debug;

    @Option(
        name = "verbose",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Print verbose information.")
    public boolean verbose;

    @Option(
        name = "positions",
        defaultValue = "lines",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        converter = StringConverter.class,
        help = "What source-position information to keep. One of: none, lines, important.")
    public String positions;

    @Option(
        name = "no-locals",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Don't keep local variable information.")
    public boolean noLocals;

    @Option(
        name = "statistics",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Print statistics information.")
    public boolean statistics;

    @Option(
        name = "no-optimize",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Don't optimize.")
    public boolean noOptimize;

    @Option(
        name = "optimize-list",
        defaultValue = "null",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        converter = StringConverter.class,
        help = "File listing methods to optimize.")
    public String optimizeList;

    @Option(
        name = "no-optimize-list",
        defaultValue = "null",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        converter = StringConverter.class,
        help = "File listing methods not to optimize.")
    public String noOptimizeList;

    @Option(
        name = "no-strict",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Disable strict file/class name checks.")
    public boolean noStrict;

    @Option(
        name = "keep-classes",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Keep input class files in in output jar.")
    public boolean keepClasses;

    @Option(
        name = "output",
        defaultValue = "null",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        converter = StringConverter.class,
        help = "Output file or directory.")
    public String output;

    @Option(
        name = "dump-to",
        defaultValue = "null",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        converter = StringConverter.class,
        help = "File to dump information to.")
    public String dumpTo;

    @Option(
        name = "dump-width",
        defaultValue = "8",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        help = "Max width for columns in dump output.")
    public int dumpWidth;

    @Option(
        name = "dump-method",
        defaultValue = "null",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        converter = StringConverter.class,
        help = "Method to dump information for.")
    public String methodToDump;

    @Option(
        name = "dump",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Dump information.")
    public boolean dump;

    @Option(
        name = "verbose-dump",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Dump verbose information.")
    public boolean verboseDump;

    @Option(
        name = "no-files",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Don't fail if given no files.")
    public boolean noFiles;

    @Option(
        name = "core-library",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Construct a core library.")
    public boolean coreLibrary;

    @Option(
        name = "num-threads",
        defaultValue = "1",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        help = "Number of threads to run with.")
    public int numThreads;

    @Option(
        name = "incremental",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Merge result with the output if it exists.")
    public boolean incremental;

    @Option(
        name = "force-jumbo",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Force use of string-jumbo instructions.")
    public boolean forceJumbo;

    @Option(
        name = "no-warning",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Suppress warnings.")
    public boolean noWarning;

    @Option(
        name = "set-max-idx-number",
        defaultValue = "0",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        help = "Undocumented: Set maximal index number to use in a dex file.")
    public int maxIndexNumber;

    @Option(
        name = "minimal-main-dex",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Produce smallest possible main dex.")
    public boolean minimalMainDex;

    @Option(
        name = "main-dex-list",
        defaultValue = "null",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        converter = StringConverter.class,
        help = "File listing classes that must be in the main dex file.")
    public String mainDexList;

    @Option(
        name = "multi-dex",
        defaultValue = "false",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Allow generation of multi-dex.")
    public boolean multiDex;

    @Option(
        name = "min-sdk-version",
        defaultValue = "1",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Minimum Android API level compatibility.")
    public int minApiLevel;

    @Option(
        name = "input-list",
        defaultValue = "null",
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        converter = StringConverter.class,
        help = "File listing input files.")
    public String inputList;

    @Option(
        name = "version",
        defaultValue = "false", // dx's default
        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
        effectTags = {OptionEffectTag.UNKNOWN},
        allowMultiple = false,
        help = "Print the version of this tool.")
    public boolean version;
  }

  /** Compatibility options parsing for the DX --dex sub-command. */
  public static class DxCompatOptions {

    // Final values after parsing.
    // Note: These are ordered by their occurrence in "dx --help"
    public final boolean help;
    public final boolean version;
    public final boolean debug;
    public final boolean verbose;
    public final PositionInfo positions;
    public final boolean noLocals;
    public final boolean noOptimize;
    public final boolean statistics;
    public final String optimizeList;
    public final String noOptimizeList;
    public final boolean noStrict;
    public final boolean keepClasses;
    public final String output;
    public final String dumpTo;
    public final int dumpWidth;
    public final String methodToDump;
    public final boolean verboseDump;
    public final boolean dump;
    public final boolean noFiles;
    public final boolean coreLibrary;
    public final int numThreads;
    public final boolean incremental;
    public final boolean forceJumbo;
    public final boolean noWarning;
    public final boolean multiDex;
    public final String mainDexList;
    public final boolean minimalMainDex;
    public final int minApiLevel;
    public final String inputList;
    public final List<String> inputs;
    // Undocumented option
    public final int maxIndexNumber;

    /**
     * Values for dx --positions flag. Corresponding to "none", "important", "lines", "throwing".
     */
    public enum PositionInfo {
      NONE,
      IMPORTANT,
      LINES,
      THROWING
    }

    /** Exception thrown on invalid dx compat usage. */
    public static class DxUsageMessage extends Exception {

      public final String message;

      DxUsageMessage(String message) {
        this.message = message;
      }

      void printHelpOn(PrintStream sink) throws IOException {
        sink.println(message);
      }
    }

    private DxCompatOptions(Options options, List<String> remaining) {
      help = false;
      version = options.version;
      debug = options.debug;
      verbose = options.debug;
      switch (options.positions) {
        case "none":
          positions = PositionInfo.NONE;
          break;
        case "lines":
          positions = PositionInfo.LINES;
          break;
        case "throwing":
          positions = PositionInfo.THROWING;
          break;
        case "important":
          positions = PositionInfo.IMPORTANT;
          break;
        default:
          throw new AssertionError("Unreachable");
      }
      noLocals = options.noLocals;
      noOptimize = options.noOptimize;
      statistics = options.statistics;
      optimizeList = options.optimizeList;
      noOptimizeList = options.noOptimizeList;
      noStrict = options.noStrict;
      keepClasses = options.keepClasses;
      output = options.output;
      dumpTo = options.dumpTo;
      dumpWidth = options.dumpWidth;
      methodToDump = options.methodToDump;
      dump = options.dump;
      verboseDump = options.verboseDump;
      noFiles = options.noFiles;
      coreLibrary = options.coreLibrary;
      numThreads = options.numThreads;
      incremental = options.incremental;
      forceJumbo = options.forceJumbo;
      noWarning = options.noWarning;
      multiDex = options.multiDex;
      mainDexList = options.mainDexList;
      minimalMainDex = options.minimalMainDex;
      minApiLevel = options.minApiLevel;
      inputList = options.inputList;
      inputs = remaining;
      maxIndexNumber = options.maxIndexNumber;
    }

    public static DxCompatOptions parse(String[] args) {
      OptionsParser optionsParser = OptionsParser.builder().optionsClasses(Options.class).build();
      optionsParser.parseAndExitUponError(args);
      Options options = optionsParser.getOptions(Options.class);
      return new DxCompatOptions(options, optionsParser.getResidue());
    }
  }

  public static void main(String[] args) throws IOException {
    try {
      run(args);
    } catch (DxUsageMessage e) {
      System.err.println(USAGE_HEADER);
      e.printHelpOn(System.err);
      System.exit(1);
    } catch (CompilationFailedException e) {
      throw new AssertionError("Failure", e);
    }
  }

  private static void run(String[] args)
      throws DxUsageMessage, IOException, CompilationFailedException {
    DxCompatOptions dexArgs = DxCompatOptions.parse(args);
    if (dexArgs.version) {
      System.out.println("CompatDx " + Version.getVersionString());
      return;
    }
    CompilationMode mode = CompilationMode.RELEASE;
    Path output = null;
    List<Path> inputs = new ArrayList<>();
    boolean singleDexFile = !dexArgs.multiDex;
    Path mainDexList = null;
    int numberOfThreads = 1;

    for (String path : dexArgs.inputs) {
      processPath(Paths.get(path), inputs);
    }
    if (inputs.isEmpty()) {
      if (dexArgs.noFiles) {
        return;
      }
      throw new DxUsageMessage("No input files specified");
    }

    if (dexArgs.dump && dexArgs.verbose) {
      System.out.println("Warning: dump is not supported");
    }

    if (dexArgs.verboseDump) {
      throw new CompatDxUnimplemented("verbose dump file not yet supported");
    }

    if (dexArgs.methodToDump != null) {
      throw new CompatDxUnimplemented("method-dump not yet supported");
    }

    if (dexArgs.output != null) {
      output = Paths.get(dexArgs.output);
      if (FileUtils.isDexFile(output)) {
        if (!singleDexFile) {
          throw new DxUsageMessage("Cannot output to a single dex-file when running with multidex");
        }
      } else if (!FileUtils.isArchive(output)
          && (!output.toFile().exists() || !output.toFile().isDirectory())) {
        throw new DxUsageMessage(
            "Unsupported output file or output directory does not exist. "
                + "Output must be a directory or a file of type dex, apk, jar or zip.");
      }
    }

    if (dexArgs.dumpTo != null && dexArgs.verbose) {
      System.out.println("dump-to file not yet supported");
    }

    if (dexArgs.positions == PositionInfo.NONE && dexArgs.verbose) {
      System.out.println("Warning: no support for positions none.");
    }

    if (dexArgs.positions == PositionInfo.LINES && !dexArgs.noLocals) {
      mode = CompilationMode.DEBUG;
    }

    if (dexArgs.incremental) {
      throw new CompatDxUnimplemented("incremental merge not supported yet");
    }

    if (dexArgs.forceJumbo && dexArgs.verbose) {
      System.out.println(
          "Warning: no support for forcing jumbo-strings.\n"
              + "Strings will only use jumbo-string indexing if necessary.\n"
              + "Make sure that any dex merger subsequently used "
              + "supports correct handling of jumbo-strings (eg, D8/R8 does).");
    }

    if (dexArgs.noOptimize && dexArgs.verbose) {
      System.out.println("Warning: no support for not optimizing");
    }

    if (dexArgs.optimizeList != null) {
      throw new CompatDxUnimplemented("no support for optimize-method list");
    }

    if (dexArgs.noOptimizeList != null) {
      throw new CompatDxUnimplemented("no support for dont-optimize-method list");
    }

    if (dexArgs.statistics && dexArgs.verbose) {
      System.out.println("Warning: no support for printing statistics");
    }

    if (dexArgs.numThreads > 1) {
      numberOfThreads = dexArgs.numThreads;
    }

    if (dexArgs.mainDexList != null) {
      mainDexList = Paths.get(dexArgs.mainDexList);
    }

    if (dexArgs.noStrict) {
      if (dexArgs.verbose) {
        System.out.println("Warning: conservative main-dex list not yet supported");
      }
    } else {
      if (dexArgs.verbose) {
        System.out.println("Warning: strict name checking not yet supported");
      }
    }

    if (dexArgs.minimalMainDex && dexArgs.verbose) {
      if (dexArgs.debug) {
        System.out.println(
            "Info: minimal main-dex generation is always done for D8 debug builds."
                + " Please remove option --minimal-main-dex");
      } else {
        throw new DxUsageMessage("Error: minimal main-dex is not supported for D8 release builds");
      }
    }

    if (dexArgs.maxIndexNumber != 0 && dexArgs.verbose) {
      System.out.println("Warning: internal maximum-index setting is not supported");
    }

    if (numberOfThreads < 1) {
      throw new DxUsageMessage("Invalid numThreads value of " + numberOfThreads);
    }
    ExecutorService executor = Executors.newWorkStealingPool(numberOfThreads);

    try {
      D8Command.Builder builder = D8Command.builder();
      inputs.forEach(
          input ->
              builder.addProgramResourceProvider(ArchiveResourceProvider.fromArchive(input, true)));

      builder
          // .addProgramFiles(inputs)
          .setProgramConsumer(createConsumer(inputs, output, singleDexFile, dexArgs.keepClasses))
          .setMode(mode)
          .setDisableDesugaring(true) // DX does not desugar.
          .setMinApiLevel(dexArgs.minApiLevel);
      if (mainDexList != null) {
        builder.addMainDexListFiles(mainDexList);
      }
      D8.run(builder.build());
    } finally {
      executor.shutdown();
    }
  }

  private static ProgramConsumer createConsumer(
      List<Path> inputs, Path output, boolean singleDexFile, boolean keepClasses)
      throws DxUsageMessage {
    if (output == null) {
      return DexIndexedConsumer.emptyConsumer();
    }
    if (singleDexFile) {
      return new SingleDexFileConsumer(
          FileUtils.isDexFile(output)
              ? new NamedDexFileConsumer(output)
              : createDexConsumer(output, inputs, keepClasses));
    }
    return createDexConsumer(output, inputs, keepClasses);
  }

  private static DexIndexedConsumer createDexConsumer(
      Path output, List<Path> inputs, boolean keepClasses) throws DxUsageMessage {
    if (keepClasses) {
      if (!FileUtils.isArchive(output)) {
        throw new DxCompatOptions.DxUsageMessage(
            "Output must be an archive when --keep-classes is set.");
      }
      return new ArchiveConsumer(output, inputs);
    }
    return FileUtils.isArchive(output)
        ? new ArchiveConsumer(output)
        : new DexIndexedConsumer.DirectoryConsumer(output);
  }

  private static class SingleDexFileConsumer extends DexIndexedConsumer.ForwardingConsumer {

    private byte[] bytes = null;

    public SingleDexFileConsumer(DexIndexedConsumer consumer) {
      super(consumer);
    }

    @Override
    public void accept(
        int fileIndex, ByteDataView data, Set<String> descriptors, DiagnosticsHandler handler) {
      if (fileIndex > 0) {
        throw new CompatDxCompilationError(
            "Compilation result could not fit into a single dex file. "
                + "Reduce the input-program size or run with --multi-dex enabled");
      }
      verify(bytes == null, "Should not have been populated until now");
      // Store a copy of the bytes as we may not assume the backing is valid after accept returns.
      bytes = data.copyByteData();
    }

    @Override
    public void finished(DiagnosticsHandler handler) {
      if (bytes != null) {
        super.accept(0, ByteDataView.of(bytes), null, handler);
      }
      super.finished(handler);
    }
  }

  private static class NamedDexFileConsumer extends DexIndexedConsumer.ForwardingConsumer {

    private final Path output;

    public NamedDexFileConsumer(Path output) {
      super(null);
      this.output = output;
    }

    @Override
    public void accept(
        int fileIndex, ByteDataView data, Set<String> descriptors, DiagnosticsHandler handler) {
      StandardOpenOption[] options = {
        StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING
      };
      try (OutputStream stream = new BufferedOutputStream(Files.newOutputStream(output, options))) {
        stream.write(data.getBuffer(), data.getOffset(), data.getLength());
      } catch (IOException e) {
        handler.error(new ExceptionDiagnostic(e, new PathOrigin(output)));
      }
    }
  }

  /**
   * Consumer for writing the generated classes.dex files to an archive. Supports writing the input
   * class files to the same archive as well.
   */
  private static class ArchiveConsumer implements DexIndexedConsumer {
    private final Path path;
    private final List<Path> inputs;
    private final Origin origin;
    private ZipOutputStream stream;
    private int nextClassesDexIndex;
    private final Map<Integer, ClassesDexFileData> pendingClassesDexFiles = new HashMap<>();

    /** Content of a classes.dex file */
    private static class ClassesDexFileData {
      private final int index;
      private final ByteDataView content;

      private ClassesDexFileData(int index, ByteDataView content) {
        this.index = index;
        this.content = content;
      }
    }

    ArchiveConsumer(Path path) {
      this(path, ImmutableList.of());
    }

    ArchiveConsumer(Path path, List<Path> inputs) {
      this.path = path;
      this.inputs = inputs;
      this.origin = new PathOrigin(path);
    }

    @Override
    public void accept(
        int fileIndex, ByteDataView data, Set<String> descriptors, DiagnosticsHandler handler) {
      ensureOpenArchive(handler);
      addIndexedClassesDexFile(fileIndex, data, handler);
    }

    @Override
    public void finished(DiagnosticsHandler handler) {
      verify(pendingClassesDexFiles.isEmpty(), "All DEX files should have been written");
      if (stream == null) {
        return;
      }
      try {
        writeInputClassesToArchive(handler);
        stream.close();
        stream = null;
      } catch (IOException e) {
        handler.error(new ExceptionDiagnostic(e, origin));
      }
    }

    private synchronized void addIndexedClassesDexFile(
        int fileIndex, ByteDataView data, DiagnosticsHandler handler) {
      // Always add the classes.dex files in <code>fileIndex</code> order to have stable output.
      // Store the ones which arrive out-of-order and write as soon as possible.
      pendingClassesDexFiles.put(
          fileIndex, new ClassesDexFileData(fileIndex, ByteDataView.of(data.copyByteData())));
      while (pendingClassesDexFiles.containsKey(nextClassesDexIndex)) {
        ClassesDexFileData classesDexFileData = pendingClassesDexFiles.get(nextClassesDexIndex);
        writeClassesDexFile(classesDexFileData, handler);
        pendingClassesDexFiles.remove(nextClassesDexIndex);
        nextClassesDexIndex++;
      }
    }

    /** Get or open the zip output stream. */
    private synchronized void ensureOpenArchive(DiagnosticsHandler handler) {
      if (stream != null) {
        return;
      }
      try {
        stream =
            new ZipOutputStream(
                Files.newOutputStream(
                    path, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING));
      } catch (IOException e) {
        handler.error(new ExceptionDiagnostic(e, origin));
      }
    }

    private void writeClassesDexFile(
        ClassesDexFileData classesDexFileData, DiagnosticsHandler handler) {
      try {
        ZipUtils.writeToZipStream(
            getDexFileName(classesDexFileData.index),
            classesDexFileData.content,
            ZipEntry.DEFLATED,
            stream);
      } catch (IOException e) {
        handler.error(new ExceptionDiagnostic(e, origin));
      }
    }

    protected String getDexFileName(int fileIndex) {
      return "classes" + (fileIndex == 0 ? "" : (fileIndex + 1)) + FileUtils.DEX_EXTENSION;
    }

    private void writeClassFile(String name, ByteDataView content, DiagnosticsHandler handler) {
      try {
        ZipUtils.writeToZipStream(name, content, ZipEntry.DEFLATED, stream);
      } catch (IOException e) {
        handler.error(new ExceptionDiagnostic(e, origin));
      }
    }

    @SuppressWarnings("JdkObsolete") // Uses Enumeration by design.
    private void writeInputClassesToArchive(DiagnosticsHandler handler) throws IOException {
      // For each input archive file, add all class files within.
      for (Path input : inputs) {
        if (FileUtils.isArchive(input)) {
          try (ZipFile zipFile = new ZipFile(input.toFile(), UTF_8)) {
            final Enumeration<? extends ZipEntry> entries = zipFile.entries();
            while (entries.hasMoreElements()) {
              ZipEntry entry = entries.nextElement();
              if (FileUtils.isClassFile(entry.getName())) {
                try (InputStream entryStream = zipFile.getInputStream(entry)) {
                  byte[] bytes = ByteStreams.toByteArray(entryStream);
                  writeClassFile(entry.getName(), ByteDataView.of(bytes), handler);
                }
              }
            }
          }
        }
      }
    }
  }

  private static void processPath(Path path, List<Path> files) throws IOException {
    if (!Files.exists(path)) {
      throw new CompatDxCompilationError("File does not exist: " + path);
    }
    if (Files.isDirectory(path)) {
      processDirectory(path, files);
      return;
    }
    if (FileUtils.isZipFile(path) || FileUtils.isJarFile(path) || FileUtils.isClassFile(path)) {
      files.add(path);
      return;
    }
    if (FileUtils.isApkFile(path)) {
      throw new CompatDxUnimplemented("apk files not yet supported: " + path);
    }
  }

  private static void processDirectory(Path directory, List<Path> files) throws IOException {
    verify(Files.exists(directory), "Directory must exist");

    try (Stream<Path> pathStream = Files.list(directory)) {
      for (Path file : pathStream.collect(toList())) {
        processPath(file, files);
      }
    }
  }

  private CompatDx() {}
}
