// Copyright 2014 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.buildjar.javac.plugins.dependency;

import static com.google.common.collect.ImmutableList.toImmutableList;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Ordering;
import com.google.devtools.build.buildjar.JarOwner;
import com.google.devtools.build.buildjar.javac.plugins.BlazeJavaCompilerPlugin;
import com.google.devtools.build.lib.view.proto.Deps;
import com.google.devtools.build.lib.view.proto.Deps.Dependency.Kind;
import com.sun.tools.javac.code.Symbol.PackageSymbol;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * Wrapper class for managing dependencies on top of {@link
 * com.google.devtools.build.buildjar.javac.BlazeJavaCompiler}. If strict_java_deps is enabled, it
 * keeps two maps between jar names (as they appear on the classpath) and their originating targets,
 * one for direct dependencies and the other for transitive (indirect) dependencies, and enables the
 * {@link StrictJavaDepsPlugin} to perform the actual checks. The plugin also collects dependency
 * information during compilation, and DependencyModule generates a .jdeps artifact summarizing the
 * discovered dependencies.
 */
public final class DependencyModule {

  public static enum StrictJavaDeps {
    /** Legacy behavior: Silently allow referencing transitive dependencies. */
    OFF(false),
    /** Warn about transitive dependencies being used directly. */
    WARN(true),
    /** Fail the build when transitive dependencies are used directly. */
    ERROR(true);

    private final boolean enabled;

    StrictJavaDeps(boolean enabled) {
      this.enabled = enabled;
    }

    /** Convenience method for just checking if it's not OFF */
    public boolean isEnabled() {
      return enabled;
    }
  }

  private final StrictJavaDeps strictJavaDeps;
  private final Map<String, JarOwner> directJarsToTargets;
  private final Map<String, JarOwner> indirectJarsToTargets;
  private final boolean strictClasspathMode;
  private final Set<String> depsArtifacts;
  private final String ruleKind;
  private final String targetLabel;
  private final String outputDepsProtoFile;
  private final Set<String> usedClasspath;
  private final Map<String, Deps.Dependency> explicitDependenciesMap;
  private final Map<String, Deps.Dependency> implicitDependenciesMap;
  private final ImmutableSet<String> platformJars;
  Set<String> requiredClasspath;
  private final FixMessage fixMessage;
  private final Set<String> exemptGenerators;
  private final Set<PackageSymbol> packages;

  DependencyModule(
      StrictJavaDeps strictJavaDeps,
      Map<String, JarOwner> directJarsToTargets,
      Map<String, JarOwner> indirectJarsToTargets,
      boolean strictClasspathMode,
      Set<String> depsArtifacts,
      ImmutableSet<String> platformJars,
      String ruleKind,
      String targetLabel,
      String outputDepsProtoFile,
      FixMessage fixMessage,
      Set<String> exemptGenerators) {
    this.strictJavaDeps = strictJavaDeps;
    this.directJarsToTargets = directJarsToTargets;
    this.indirectJarsToTargets = indirectJarsToTargets;
    this.strictClasspathMode = strictClasspathMode;
    this.depsArtifacts = depsArtifacts;
    this.ruleKind = ruleKind;
    this.targetLabel = targetLabel;
    this.outputDepsProtoFile = outputDepsProtoFile;
    this.explicitDependenciesMap = new HashMap<>();
    this.implicitDependenciesMap = new HashMap<>();
    this.platformJars = platformJars;
    this.usedClasspath = new HashSet<>();
    this.fixMessage = fixMessage;
    this.exemptGenerators = exemptGenerators;
    this.packages = new HashSet<>();
  }

  /** Returns a plugin to be enabled in the compiler. */
  public BlazeJavaCompilerPlugin getPlugin() {
    return new StrictJavaDepsPlugin(this);
  }

  /**
   * Writes dependency information to the deps file in proto format, if specified.
   *
   * <p>We collect precise dependency information to allow Blaze to analyze both strict and unused
   * dependencies, as well as packages contained by the output jar.
   */
  public void emitDependencyInformation(ImmutableList<String> classpath, boolean successful)
      throws IOException {
    if (outputDepsProtoFile == null) {
      return;
    }

    try (BufferedOutputStream out =
        new BufferedOutputStream(new FileOutputStream(outputDepsProtoFile))) {
      buildDependenciesProto(classpath, successful).writeTo(out);
    } catch (IOException ex) {
      throw new IOException("Cannot write dependencies to " + outputDepsProtoFile, ex);
    }
  }

  @VisibleForTesting
  Deps.Dependencies buildDependenciesProto(ImmutableList<String> classpath, boolean successful) {
    Deps.Dependencies.Builder deps = Deps.Dependencies.newBuilder();
    if (targetLabel != null) {
      deps.setRuleLabel(targetLabel);
    }
    deps.setSuccess(successful);

    deps.addAllContainedPackage(
        FluentIterable.from(packages)
            .transform(
                new Function<PackageSymbol, String>() {
                  @Override
                  public String apply(PackageSymbol pkg) {
                    return pkg.isUnnamed() ? "" : pkg.getQualifiedName().toString();
                  }
                })
            .toSortedList(Ordering.natural()));

    // Filter using the original classpath, to preserve ordering.
    for (String entry : classpath) {
      if (explicitDependenciesMap.containsKey(entry)) {
        deps.addDependency(explicitDependenciesMap.get(entry));
      } else if (implicitDependenciesMap.containsKey(entry)) {
        deps.addDependency(implicitDependenciesMap.get(entry));
      }
    }
    return deps.build();
  }

  /** Returns whether strict dependency checks (strictJavaDeps) are enabled. */
  public boolean isStrictDepsEnabled() {
    return strictJavaDeps.isEnabled();
  }

  /**
   * Returns the mapping for jars of direct dependencies. The keys are full paths (as seen on the
   * classpath), and the values are build target names.
   */
  public Map<String, JarOwner> getDirectMapping() {
    return directJarsToTargets;
  }

  /**
   * Returns the mapping for jars of indirect dependencies. The keys are full paths (as seen on the
   * classpath), and the values are build target names.
   */
  public Map<String, JarOwner> getIndirectMapping() {
    return indirectJarsToTargets;
  }

  /** Returns the strict dependency checking (strictJavaDeps) setting. */
  public StrictJavaDeps getStrictJavaDeps() {
    return strictJavaDeps;
  }

  /** Returns the map collecting precise explicit dependency information. */
  public Map<String, Deps.Dependency> getExplicitDependenciesMap() {
    return explicitDependenciesMap;
  }

  /** Returns the map collecting precise implicit dependency information. */
  public Map<String, Deps.Dependency> getImplicitDependenciesMap() {
    return implicitDependenciesMap;
  }

  /** Returns the jars in the platform classpath. */
  public ImmutableSet<String> getPlatformJars() {
    return platformJars;
  }

  /** Adds a package to the set of packages built by this target. */
  public boolean addPackage(PackageSymbol packge) {
    return packages.add(packge);
  }

  /** Returns the type (rule kind) of the originating target. */
  public String getRuleKind() {
    return ruleKind;
  }

  /** Returns the name (label) of the originating target. */
  public String getTargetLabel() {
    return targetLabel;
  }

  /** Returns the file name collecting dependency information. */
  public String getOutputDepsProtoFile() {
    return outputDepsProtoFile;
  }

  @VisibleForTesting
  Set<String> getUsedClasspath() {
    return usedClasspath;
  }

  /** Returns a message to suggest fix when a missing indirect dependency is found. */
  public FixMessage getFixMessage() {
    return fixMessage;
  }

  /** Return a set of generator values that are exempt from strict dependencies. */
  public Set<String> getExemptGenerators() {
    return exemptGenerators;
  }

  /** Returns whether classpath reduction is enabled for this invocation. */
  public boolean reduceClasspath() {
    return strictClasspathMode;
  }

  /**
   * Computes a reduced compile-time classpath from the union of direct dependencies and their
   * dependencies, as listed in the associated .deps artifacts.
   */
  public ImmutableList<String> computeStrictClasspath(ImmutableList<String> originalClasspath)
      throws IOException {
    if (!strictClasspathMode) {
      return originalClasspath;
    }

    // Classpath = direct deps + runtime direct deps + their .deps
    requiredClasspath = new HashSet<>(directJarsToTargets.keySet());

    for (String depsArtifact : depsArtifacts) {
      collectDependenciesFromArtifact(depsArtifact);
    }

    // Filter the initial classpath and keep the original order
    return originalClasspath
        .stream()
        .filter(requiredClasspath::contains)
        .collect(toImmutableList());
  }

  @VisibleForTesting
  // TODO(cushon): use Paths instead of strings, or inject a FileSystem
  void setStrictClasspath(Set<String> strictClasspath) {
    this.requiredClasspath = strictClasspath;
  }

  /** Updates {@link #requiredClasspath} to include dependencies from the given output artifact. */
  private void collectDependenciesFromArtifact(String path) throws IOException {
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(path))) {
      Deps.Dependencies deps = Deps.Dependencies.parseFrom(bis);
      // Sanity check to make sure we have a valid proto.
      if (!deps.hasRuleLabel()) {
        throw new IOException("Could not parse Deps.Dependencies message from proto.");
      }
      for (Deps.Dependency dep : deps.getDependencyList()) {
        if (dep.getKind() == Kind.EXPLICIT
            || dep.getKind() == Kind.IMPLICIT
            || dep.getKind() == Kind.INCOMPLETE) {
          requiredClasspath.add(dep.getPath());
        }
      }
    } catch (IOException e) {
      throw new IOException(String.format("error reading deps artifact: %s", path), e);
    }
  }

  /**
   * A functional that formats a message for the user about a missing dependency that they should
   * add to unbreak their build.
   */
  public interface FixMessage {
    String get(Iterable<JarOwner> missing, String recipient, boolean useColor);
  }

  /** Builder for {@link DependencyModule}. */
  public static class Builder {

    private StrictJavaDeps strictJavaDeps = StrictJavaDeps.OFF;
    private final Map<String, JarOwner> directJarsToTargets = new HashMap<>();
    private final Map<String, JarOwner> indirectJarsToTargets = new HashMap<>();
    private final Set<String> depsArtifacts = new HashSet<>();
    private ImmutableSet<String> platformJars = ImmutableSet.of();
    private String ruleKind;
    private String targetLabel;
    private String outputDepsProtoFile;
    private boolean strictClasspathMode = false;
    private FixMessage fixMessage = new DefaultFixMessage();
    private final Set<String> exemptGenerators = new HashSet<>();

    private static class DefaultFixMessage implements DependencyModule.FixMessage {
      @Override
      public String get(Iterable<JarOwner> missing, String recipient, boolean useColor) {
        StringBuilder missingTargetsStr = new StringBuilder();
        for (JarOwner owner : missing) {
          missingTargetsStr.append(owner.label());
          missingTargetsStr.append(" ");
        }

        return String.format(
            "%s** Please add the following dependencies:%s\n  %s to %s\n\n",
            useColor ? "\033[35m\033[1m" : "",
            useColor ? "\033[0m" : "",
            missingTargetsStr.toString(),
            recipient);
      }
    }

    /**
     * Constructs the DependencyModule, guaranteeing that the maps are never null (they may be
     * empty), and the default strictJavaDeps setting is OFF.
     *
     * @return an instance of DependencyModule
     */
    public DependencyModule build() {
      return new DependencyModule(
          strictJavaDeps,
          directJarsToTargets,
          indirectJarsToTargets,
          strictClasspathMode,
          depsArtifacts,
          platformJars,
          ruleKind,
          targetLabel,
          outputDepsProtoFile,
          fixMessage,
          exemptGenerators);
    }

    /**
     * Sets the strictness level for dependency checking.
     *
     * @param strictJavaDeps level, as specified by {@link StrictJavaDeps}
     * @return this Builder instance
     */
    public Builder setStrictJavaDeps(String strictJavaDeps) {
      this.strictJavaDeps = StrictJavaDeps.valueOf(strictJavaDeps);
      return this;
    }

    /**
     * Sets the type (rule kind) of the originating target.
     *
     * @param ruleKind kind, such as the rule kind of a RuleConfiguredTarget
     * @return this Builder instance
     */
    public Builder setRuleKind(String ruleKind) {
      this.ruleKind = ruleKind;
      return this;
    }

    /**
     * Sets the name (label) of the originating target.
     *
     * @param targetLabel label, such as the label of a RuleConfiguredTarget.
     * @return this Builder instance.
     */
    public Builder setTargetLabel(String targetLabel) {
      this.targetLabel = targetLabel;
      return this;
    }

    /**
     * Adds direct mappings to the existing map for direct dependencies.
     *
     * @param directMappings a map of paths of jar artifacts, as seen on classpath, to full names of
     *     build targets providing the jar.
     * @return this Builder instance
     */
    public Builder addDirectMappings(Map<String, JarOwner> directMappings) {
      directJarsToTargets.putAll(directMappings);
      return this;
    }

    /**
     * Adds an indirect mapping to the existing map for indirect dependencies.
     *
     * @param jar path of jar artifact, as seen on classpath.
     * @param target full name of build target providing the jar.
     * @return this Builder instance
     */
    public Builder addIndirectMapping(String jar, JarOwner target) {
      indirectJarsToTargets.put(jar, target);
      return this;
    }

    /**
     * Adds indirect mappings to the existing map for indirect dependencies.
     *
     * @param indirectMappings a map of paths of jar artifacts, as seen on classpath, to full names
     *     of build targets providing the jar.
     * @return this Builder instance
     */
    public Builder addIndirectMappings(Map<String, JarOwner> indirectMappings) {
      indirectJarsToTargets.putAll(indirectMappings);
      return this;
    }

    /**
     * Sets the name of the file that will contain dependency information in the protocol buffer
     * format.
     *
     * @param outputDepsProtoFile output file name for dependency information
     * @return this Builder instance
     */
    public Builder setOutputDepsProtoFile(String outputDepsProtoFile) {
      this.outputDepsProtoFile = outputDepsProtoFile;
      return this;
    }

    /**
     * Adds a collection of dependency artifacts to use when reducing the compile-time classpath.
     *
     * @param depsArtifacts dependency artifacts
     * @return this Builder instance
     */
    public Builder addDepsArtifacts(Collection<String> depsArtifacts) {
      this.depsArtifacts.addAll(depsArtifacts);
      return this;
    }

    /** Sets the platform classpath entries. */
    public Builder setPlatformJars(ImmutableSet<String> platformJars) {
      this.platformJars = platformJars;
      return this;
    }

    /**
     * Requests compile-time classpath reduction based on provided dependency artifacts.
     *
     * @return this Builder instance
     */
    public Builder setReduceClasspath() {
      this.strictClasspathMode = true;
      return this;
    }

    /**
     * Set the message to display when a missing indirect dependency is found.
     *
     * @param fixMessage the fix message
     * @return this Builder instance
     */
    public Builder setFixMessage(FixMessage fixMessage) {
      this.fixMessage = fixMessage;
      return this;
    }

    /**
     * Add a generator to the exempt set.
     *
     * @param exemptGenerator the generator class name
     * @return this Builder instance
     */
    public Builder addExemptGenerator(String exemptGenerator) {
      exemptGenerators.add(exemptGenerator);
      return this;
    }
  }
}
