// Copyright 2017 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;

import com.beust.jcommander.IStringConverter;
import com.beust.jcommander.IValueValidator;
import com.beust.jcommander.JCommander;
import com.beust.jcommander.Parameter;
import com.beust.jcommander.ParameterException;
import com.beust.jcommander.Parameters;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Stopwatch;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.collect.Multimap;
import com.google.devtools.build.singlejar.ZipCombiner;
import com.google.devtools.build.singlejar.ZipCombiner.OutputMode;
import com.google.devtools.build.singlejar.ZipEntryFilter;
import com.google.devtools.build.zip.ZipFileEntry;
import com.google.devtools.build.zip.ZipReader;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import java.util.regex.Pattern;

/**
 * Action to filter entries out of a Zip file.
 *
 * <p>The entries to remove are determined from the filterZips and filterTypes. All entries from the
 * filter Zip files that have an extension listed in filterTypes will be removed. If no filterZips
 * are specified, no entries will be removed. Specifying no filterTypes is treated as if an
 * extension of '.*' was specified.
 *
 * <p>Assuming each Zip as a set of entries, the result is:
 *
 * <pre> outputZip = inputZip - union[x intersect filterTypes for x in filterZips]</pre>
 *
 * <p>
 *
 * <pre>
 * Example Usage:
 *   java/com/google/build/android/ZipFilterAction\
 *      --inputZip path/to/inputZip
 *      --outputZip path/to/outputZip
 *      --filterZips [path/to/filterZip[,path/to/filterZip]...]
 *      --filterTypes [fileExtension[,fileExtension]...]
 *      --explicitFilters [fileRegex[,fileRegex]...]
 *      --outputMode [DONT_CARE|FORCE_DEFLATE|FORCE_STORED]
 *      --errorOnHashMismatch
 * </pre>
 */
public class ZipFilterAction {

  private static final Logger logger = Logger.getLogger(ZipFilterAction.class.getName());

  /** Modes of performing content hash checking during zip filtering. */
  public enum HashMismatchCheckMode {
    /** Filter file from input zip iff a file is found with the same filename in filter zips. */
    IGNORE,

    /**
     * Filter file from input zip iff a file is found with the same filename and content hash in
     * filter zips. Print warning if the filename is identical but content hash is not.
     */
    WARN,

    /**
     * Same behavior as WARN, but throw an error if a file is found with the same filename with
     * different content hash.
     */
    ERROR
  }

  @Parameters(optionPrefixes = "--")
  static class Options {
    @Parameter(
      names = "--inputZip",
      description = "Path of input zip.",
      converter = PathFlagConverter.class,
      validateValueWith = PathExistsValidator.class
    )
    Path inputZip;

    @Parameter(
      names = "--outputZip",
      description = "Path to write output zip.",
      converter = PathFlagConverter.class
    )
    Path outputZip;

    @Parameter(
      names = "--filterZips",
      description = "Filter zips.",
      converter = PathFlagConverter.class,
      validateValueWith = AllPathsExistValidator.class
    )
    List<Path> filterZips = ImmutableList.of();

    @Parameter(names = "--filterTypes", description = "Filter file types.")
    List<String> filterTypes = ImmutableList.of();

    @Parameter(names = "--explicitFilters", description = "Explicitly specified filters.")
    List<String> explicitFilters = ImmutableList.of();

    @Parameter(names = "--outputMode", description = "Output zip compression mode.")
    OutputMode outputMode = OutputMode.DONT_CARE;

    @Parameter(
      names = "--checkHashMismatch",
      description =
          "Ignore, warn or throw an error if the content hashes of two files with the "
              + "same name are different."
    )
    HashMismatchCheckMode hashMismatchCheckMode = HashMismatchCheckMode.WARN;

    /**
     * @deprecated please use --checkHashMismatch ERROR instead. Other options are IGNORE and WARN.
     */
    @Deprecated
    @Parameter(
      names = "--errorOnHashMismatch",
      description = "Error on entry filter with hash mismatch."
    )
    boolean errorOnHashMismatch = false;

    /**
     * @deprecated please use --checkHashMismatch WARN instead. Other options are IGNORE and WARN.
     *     <p>This is a hack to support existing users of --noerrorOnHashMismatch. JCommander does
     *     not support setting boolean flags with "--no", so instead we set the default to false and
     *     just ignore anyone who passes --noerrorOnHashMismatch.
     */
    @Deprecated
    @Parameter(names = "--noerrorOnHashMismatch")
    boolean ignored = false;
  }

  /** Converts string flags to paths. Public because JCommander invokes this by reflection. */
  public static class PathFlagConverter implements IStringConverter<Path> {

    @Override
    public Path convert(String text) {
      return FileSystems.getDefault().getPath(text);
    }
  }

  /** Validates that a path exists. Public because JCommander invokes this by reflection. */
  public static class PathExistsValidator implements IValueValidator<Path> {

    @Override
    public void validate(String s, Path path) {
      if (!Files.exists(path)) {
        throw new ParameterException(String.format("%s is not a valid path.", path.toString()));
      }
    }
  }

  /** Validates that a set of paths exist. Public because JCommander invokes this by reflection. */
  public static class AllPathsExistValidator implements IValueValidator<List<Path>> {

    @Override
    public void validate(String s, List<Path> paths) {
      for (Path path : paths) {
        if (!Files.exists(path)) {
          throw new ParameterException(String.format("%s is not a valid path.", path.toString()));
        }
      }
    }
  }

  @VisibleForTesting
  static Multimap<String, Long> getEntriesToOmit(
      Collection<Path> filterZips, Collection<String> filterTypes) throws IOException {
    // Escape filter types to prevent regex abuse
    Set<String> escapedFilterTypes = new HashSet<>();
    for (String filterType : filterTypes) {
      escapedFilterTypes.add(Pattern.quote(filterType));
    }
    // Match any string that ends with any of the filter file types
    String filterRegex = String.format(".*(%s)$", Joiner.on("|").join(escapedFilterTypes));

    ImmutableSetMultimap.Builder<String, Long> entriesToOmit = ImmutableSetMultimap.builder();
    for (Path filterZip : filterZips) {
      try (ZipReader zip = new ZipReader(filterZip.toFile())) {
        for (ZipFileEntry entry : zip.entries()) {
          if (filterTypes.isEmpty() || entry.getName().matches(filterRegex)) {
            entriesToOmit.put(entry.getName(), entry.getCrc());
          }
        }
      }
    }
    return entriesToOmit.build();
  }

  public static void main(String[] args) throws IOException {
    Options options = new Options();
    new JCommander(options).parse(args);
    logger.fine(
        String.format(
            "Creating filter from entries of type %s, in zip files %s.",
            options.filterTypes, options.filterZips));

    final Stopwatch timer = Stopwatch.createStarted();
    Multimap<String, Long> entriesToOmit =
        getEntriesToOmit(options.filterZips, options.filterTypes);
    final String explicitFilter =
        options.explicitFilters.isEmpty()
            ? ""
            : String.format(".*(%s).*", Joiner.on("|").join(options.explicitFilters));
    logger.fine(String.format("Filter created in %dms", timer.elapsed(TimeUnit.MILLISECONDS)));

    ImmutableMap.Builder<String, Long> inputEntries = ImmutableMap.builder();
    try (ZipReader input = new ZipReader(options.inputZip.toFile())) {
      for (ZipFileEntry entry : input.entries()) {
        inputEntries.put(entry.getName(), entry.getCrc());
      }
    }

    // TODO(jingwen): Remove --errorOnHashMismatch when Blaze release with --checkHashMismatch
    // is checked in.
    if (options.errorOnHashMismatch) {
      options.hashMismatchCheckMode = HashMismatchCheckMode.ERROR;
    }
    ZipEntryFilter entryFilter =
        new ZipFilterEntryFilter(
            explicitFilter, entriesToOmit, inputEntries.build(), options.hashMismatchCheckMode);

    try (OutputStream out = Files.newOutputStream(options.outputZip);
        ZipCombiner combiner = new ZipCombiner(options.outputMode, entryFilter, out)) {
      combiner.addZip(options.inputZip.toFile());
    }
    logger.fine(String.format("Filtering completed in %dms", timer.elapsed(TimeUnit.MILLISECONDS)));
  }
}
