// 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.lib.buildtool;

import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;

import com.google.common.collect.Iterables;
import com.google.common.collect.Sets;
import com.google.devtools.build.lib.actions.Action;
import com.google.devtools.build.lib.actions.ActionAnalysisMetadata;
import com.google.devtools.build.lib.actions.ActionGraph;
import com.google.devtools.build.lib.actions.ActionGraphVisitor;
import com.google.devtools.build.lib.actions.ActionKeyContext;
import com.google.devtools.build.lib.actions.Artifact;
import com.google.devtools.build.lib.actions.extra.ExtraActionInfo;
import com.google.devtools.build.lib.analysis.ConfiguredTarget;
import com.google.devtools.build.lib.analysis.ExtraActionArtifactsProvider;
import com.google.devtools.build.lib.analysis.ViewCreationFailedException;
import com.google.devtools.build.lib.analysis.extra.ExtraActionSpec;
import com.google.devtools.build.lib.buildtool.util.BuildIntegrationTestCase;
import com.google.devtools.build.lib.collect.nestedset.NestedSet;
import com.google.devtools.build.lib.includescanning.IncludeScanningModule;
import com.google.devtools.build.lib.rules.java.JavaInfo;
import com.google.devtools.build.lib.rules.java.JavaRuleOutputJarsProvider;
import com.google.devtools.build.lib.runtime.BlazeRuntime;
import com.google.devtools.build.lib.util.Fingerprint;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.common.options.OptionsParsingException;
import java.io.InputStream;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Tests the action_listener/extra_action feature. (--experimental_action_listener blaze option) */
@RunWith(JUnit4.class)
public final class ActionListenerIntegrationTest extends BuildIntegrationTestCase {

  private final ActionKeyContext actionKeyContext = new ActionKeyContext();

  @Override
  protected BlazeRuntime.Builder getRuntimeBuilder() throws Exception {
    return super.getRuntimeBuilder().addBlazeModule(new IncludeScanningModule());
  }

  private Map<ConfiguredTarget, Iterable<Artifact.DerivedArtifact>> getExtraArtifactMap() {
    Map<ConfiguredTarget, Iterable<Artifact.DerivedArtifact>> result = new LinkedHashMap<>();
    for (ConfiguredTarget configuredTarget : getAllConfiguredTargets()) {
      ExtraActionArtifactsProvider provider = configuredTarget.getProvider(
          ExtraActionArtifactsProvider.class);
      if (provider != null && !provider.getExtraActionArtifacts().isEmpty()) {
        result.put(configuredTarget, provider.getExtraActionArtifacts().toList());
      }
    }
    return result;
  }

  private NestedSet<Artifact.DerivedArtifact> getExtraActionArtifacts(ConfiguredTarget target) {
    return target.getProvider(ExtraActionArtifactsProvider.class).getExtraActionArtifacts();
  }

  private void assertExtraActionOutputForJavaLibraryRule(String rule, String extraAction,
      boolean shouldBePresent, boolean shouldDependOnOutput) throws Exception {
    final String extraActionPath = extraAction.substring(2).replace(':', '/');
    final String rulePackage = rule.substring(2, rule.indexOf(':'));
    final String extraActionOutputRoot =
        "extra_actions/" + extraActionPath + "/" + rulePackage + "/";

    final ConfiguredTarget javalib = getConfiguredTarget(rule);

    assertThat(javalib).isNotNull();

    NestedSet<Artifact.DerivedArtifact> extraArtifacts = getExtraActionArtifacts(javalib);
    assertThat(extraArtifacts).isNotNull();

    final Set<ActionAnalysisMetadata> actions = new HashSet<>();

    class JavacFinderVisitor extends ActionGraphVisitor {
      public JavacFinderVisitor(ActionGraph actionGraph) {
        super(actionGraph);
      }

      @Override
      protected boolean shouldVisit(ActionAnalysisMetadata action) {
        return action.getOwner().getLabel().equals(javalib.getLabel());
      }

      @Override
      protected void visitAction(ActionAnalysisMetadata action) {
        if (action.getMnemonic().equals("Javac")) {
          actions.add(action);
        }
      }
    }

    JavacFinderVisitor visitor = new JavacFinderVisitor(getActionGraph());

    Set<Artifact> outputs =
        Sets.newHashSet(
            Iterables.concat(
                JavaInfo.getProvider(JavaRuleOutputJarsProvider.class, javalib)
                    .getAllSrcOutputJars(),
                JavaInfo.getProvider(JavaRuleOutputJarsProvider.class, javalib)
                    .getAllClassOutputJars()));
    outputs.addAll(getFilesToBuild(javalib).toList());
    visitor.visitWhiteNodes(outputs);

    assertThat(actions).isNotEmpty();

    assertThat(extraArtifacts.toList()).hasSize(2 * actions.size());

    for (ActionAnalysisMetadata action : actions) {
      boolean hasProtoArtifact = false;
      boolean hasTestArtifact = false;

      String ownerDigest =
          new Fingerprint().addString(action.getOwner().getLabel().toString()).hexDigestAndReset();

      String actionId =
          ExtraActionSpec.getActionId(actionKeyContext, action.getOwner(), (Action) action);
      String testArtifactPath = extraActionOutputRoot + ownerDigest + "_" + actionId + ".tst";
      String protoArtifactPath = extraActionOutputRoot + actionId + ".xa";

      for (Artifact extraArtifact : extraArtifacts.toList()) {
        Path path = extraArtifact.getPath();
        if (path.toString().endsWith(protoArtifactPath)) {
          hasProtoArtifact = true;
          if (shouldBePresent) {
            ExtraActionInfo.Builder builder = ExtraActionInfo.newBuilder();
            InputStream inputStream = path.getInputStream();
            builder.mergeFrom(inputStream);
            ExtraActionInfo info = builder.build();
            assertThat(info.getOwner()).isEqualTo(rule);
          }
          continue;
        }
        ActionAnalysisMetadata artifactOwningExtraAction = getActionGraph()
            .getGeneratingAction(extraArtifact);

        assertThat(artifactOwningExtraAction).isNotNull();

        Set<Artifact> extraActionInputs = artifactOwningExtraAction.getInputs().toSet();
        Set<Artifact> actionOutputs = Sets.newHashSet(action.getOutputs());
        if (shouldDependOnOutput) {
          // If the extra_action has require_action_output set, all of the outputs of the
          // shadowed action should be part of the extra_action's inputs.
          assertThat(extraActionInputs).containsAtLeastElementsIn(actionOutputs);
        } else {
          // If the extra_action doesn't have requires_action_output set, none of the outputs of the
          // shadowed action should be part of the extra_action's inputs.
          assertThat(Sets.intersection(extraActionInputs, actionOutputs)).isEmpty();
        }

        assertThat(path.exists()).isEqualTo(shouldBePresent);

        if (path.toString().endsWith(testArtifactPath)) {
          hasTestArtifact = true;
          if (shouldBePresent) {
            String contents = readContentAsLatin1String(extraArtifact);
            String[] lines = contents.split("\n");
            assertThat(lines).isNotEmpty();
            String firstLine = lines[0];

            assertThat(firstLine).endsWith(protoArtifactPath);
          }
        }
      }
      assertThat(hasProtoArtifact).isTrue();
      assertThat(hasTestArtifact).isTrue();
    }
  }

  @Test
  public void testBasicActionListener() throws Exception {
    write("nobuild/BUILD",
        "java_library(name= 'javalib',",
        "             srcs=[])",
        "extra_action(name = 'baz',",
        "             out_templates = ['$(OWNER_LABEL_DIGEST)_$(ACTION_ID).tst'],",
        "             cmd = " +
        "                 'echo $(EXTRA_ACTION_FILE)>$(output $(OWNER_LABEL_DIGEST)" +
            "_$(ACTION_ID).tst)')",
        "action_listener(name = 'bar',",
        "                mnemonics = ['Javac'],",
        "                extra_actions = [':baz'])");

    addOptions("--experimental_action_listener=//nobuild:bar");

    buildTarget("//nobuild:javalib");

    Map<ConfiguredTarget, Iterable<Artifact.DerivedArtifact>> extraArtifactsMap =
        getExtraArtifactMap();
    assertThat(extraArtifactsMap).hasSize(1);

    assertExtraActionOutputForJavaLibraryRule("//nobuild:javalib", "//nobuild:baz", true, false);
  }

   @Test
   public void testActionListenerThatRequiresActionOutputs() throws Exception {
    write("nobuild/BUILD",
        "java_library(name= 'javalib',",
        "             srcs=[])",
        "extra_action(name = 'baz',",
        "             out_templates = ['$(OWNER_LABEL_DIGEST)_$(ACTION_ID).tst'],",
        "             requires_action_output = 1,",
        "             cmd = " +
        "                 'echo $(EXTRA_ACTION_FILE)>$(output $(OWNER_LABEL_DIGEST)" +
        "_$(ACTION_ID).tst)')",
        "action_listener(name = 'bar',",
        "                mnemonics = ['Javac'],",
        "                extra_actions = [':baz'])");

    addOptions("--experimental_action_listener=//nobuild:bar");

    buildTarget("//nobuild:javalib");

    Map<ConfiguredTarget, Iterable<Artifact.DerivedArtifact>> extraArtifactsMap =
        getExtraArtifactMap();
    assertThat(extraArtifactsMap).hasSize(1);

    assertExtraActionOutputForJavaLibraryRule("//nobuild:javalib", "//nobuild:baz", true, true);
  }

  @Test
  public void testFilteredActionListener() throws Exception {
    write("filtered/BUILD",
        "java_library(name= 'a',",
        "             srcs=[])",
        "java_library(name= 'b',",
        "             exports=[':a'])",
        "extra_action(name = 'baz',",
        "             out_templates = ['$(OWNER_LABEL_DIGEST)_$(ACTION_ID).tst'],",
        "             cmd = " +
        "                 'echo $(EXTRA_ACTION_FILE)>$(output $(OWNER_LABEL_DIGEST)" +
            "_$(ACTION_ID).tst)')",
        "action_listener(name = 'bar',",
        "                mnemonics = ['Javac'],",
        "                extra_actions = [':baz'])");

    addOptions("--experimental_action_listener=//filtered:bar",
        "--experimental_extra_action_filter=.*\\:a");

    buildTarget("//filtered:b");

    Map<ConfiguredTarget, Iterable<Artifact.DerivedArtifact>> extraArtifactsMap =
        getExtraArtifactMap();
    assertThat(extraArtifactsMap).hasSize(2);

    assertExtraActionOutputForJavaLibraryRule("//filtered:a", "//filtered:baz", true, false);
    assertExtraActionOutputForJavaLibraryRule("//filtered:b", "//filtered:baz", false, false);
  }

  @Test
  public void testTopLevelOnlyActionListener() throws Exception {
    write("filtered/BUILD",
        "java_library(name= 'a',",
        "             srcs=[])",
        "java_library(name= 'b',",
        "             exports=[':a'])",
        "extra_action(name = 'baz',",
        "             out_templates = ['$(OWNER_LABEL_DIGEST)_$(ACTION_ID).tst'],",
        "             cmd = " +
        "                 'echo $(EXTRA_ACTION_FILE)>$(output $(OWNER_LABEL_DIGEST)" +
            "_$(ACTION_ID).tst)')",
        "action_listener(name = 'bar',",
        "                mnemonics = ['Javac'],",
        "                extra_actions = [':baz'])");

    addOptions("--experimental_action_listener=//filtered:bar",
        "--experimental_extra_action_top_level_only");

    buildTarget("//filtered:b");

    Map<ConfiguredTarget, Iterable<Artifact.DerivedArtifact>> extraArtifactsMap =
        getExtraArtifactMap();
    assertThat(extraArtifactsMap).hasSize(2);

    assertExtraActionOutputForJavaLibraryRule("//filtered:a", "//filtered:baz", false, false);
    assertExtraActionOutputForJavaLibraryRule("//filtered:b", "//filtered:baz", true, false);
  }

  @Test
  public void testCcTestActionListener() throws Exception {
    write("nobuild/main.cc",
        "int  main() { return 0; }");
    write("nobuild/BUILD",
        "cc_test(name= 'cctest',",
        "             srcs=['main.cc'])",
        "extra_action(name = 'baz',",
        "             out_templates = ['$(ACTION_ID).tst'],",
        "             cmd = " +
        "                 'echo $(EXTRA_ACTION_FILE)>$(output $(ACTION_ID).tst)')",
        "action_listener(name = 'bar',",
        "                mnemonics = ['CppCompile'],",
        "                extra_actions = [':baz'])");

    addOptions("--experimental_action_listener=//nobuild:bar");

    buildTarget("//nobuild:cctest");
    final ConfiguredTarget cctest = getConfiguredTarget("//nobuild:cctest");

    assertThat(cctest).isNotNull();

    NestedSet<Artifact.DerivedArtifact> extraArtifacts = getExtraActionArtifacts(cctest);
    assertThat(extraArtifacts).isNotNull();

    final Set<ActionAnalysisMetadata> actions = new HashSet<>();
    class CppCompileActionFinder extends ActionGraphVisitor {

      public CppCompileActionFinder(ActionGraph actionGraph) {
        super(actionGraph);
      }

      @Override
      protected boolean shouldVisit(ActionAnalysisMetadata action) {
        return action.getOwner().getLabel().equals(cctest.getLabel());
      }

      @Override
      protected void visitAction(ActionAnalysisMetadata action) {
        if (action.getMnemonic().equals("CppCompile")) {
          actions.add(action);
        }
      }
    }

    CppCompileActionFinder visitor = new CppCompileActionFinder(getActionGraph());

    Set<Artifact> outputs = new HashSet<>();
    outputs.addAll(getFilesToBuild(cctest).toList());
    visitor.visitWhiteNodes(outputs);

    assertThat(actions).isNotEmpty();
    assertThat(extraArtifacts.toList()).hasSize(2 * actions.size());

    for (ActionAnalysisMetadata action : actions) {
      boolean hasProtoArtifact = false;
      boolean hasTestArtifact = false;
      String actionId =
          ExtraActionSpec.getActionId(actionKeyContext, action.getOwner(), (Action) action);

      String testArtifactPath = "extra_actions/nobuild/baz/nobuild/" + actionId + ".tst";
      String protoArtifactPath = "extra_actions/nobuild/baz/nobuild/" + actionId + ".xa";

      for (Artifact extraArtifact : extraArtifacts.toList()) {
        Path path = extraArtifact.getPath();
        assertThat(path.exists()).isTrue();

        if (path.toString().endsWith(testArtifactPath)) {
          hasTestArtifact = true;

          String contents = readContentAsLatin1String(extraArtifact);
          String[] lines = contents.split("\n");
          assertThat(lines).isNotEmpty();
          String firstLine = lines[0];

          assertThat(firstLine).endsWith(protoArtifactPath);
        }
        if (path.toString().endsWith(protoArtifactPath)) {
          hasProtoArtifact = true;

          ExtraActionInfo.Builder builder = ExtraActionInfo.newBuilder();
          InputStream inputStream = path.getInputStream();
          builder.mergeFrom(inputStream);
          ExtraActionInfo info = builder.build();
          assertThat(info.getOwner()).isEqualTo("//nobuild:cctest");
        }
      }
      assertThat(hasProtoArtifact).isTrue();
      assertThat(hasTestArtifact).isTrue();
    }
  }


  @Test
  public void testActionListenerNotEnabled() throws Exception {
    write("nobuild/BUILD",
        "java_library(name= 'javalib',",
        "             srcs=[])",
        "extra_action(name = 'baz',",
        "             out_templates = ['$(ACTION_ID).tst'],",
        "             cmd = " +
            "'echo $(EXTRA_ACTION_FILE)>$(output $(ACTION_ID).tst)')",
        "action_listener(name = 'bar',",
        "                mnemonics = ['Javac'],",
        "                extra_actions = [':baz'])");

    buildTarget("//nobuild:javalib");
    ConfiguredTarget javalib = getConfiguredTarget("//nobuild:javalib");

    assertThat(javalib).isNotNull();

    Map<ConfiguredTarget, Iterable<Artifact.DerivedArtifact>> extraArtifactsMap =
        getExtraArtifactMap();
    assertThat(extraArtifactsMap).isEmpty();
  }

  @Test
  public void testBuildActionListener() throws Exception {
    write("nobuild/BUILD",
      "extra_action(name = 'action',",
      "             cmd = '')",
      "action_listener(name = 'listener',",
      "                mnemonics = ['Foo'],",
      "                extra_actions = [':action'])");
    buildTarget("//nobuild:listener");
    // Confirm target exists.
    getExistingConfiguredTarget("//nobuild:listener");
  }

  @Test
  public void testNotActionListenerLabel() throws Exception {
    write("nobuild/BUILD",
        "java_library(name= 'javalib1',",
        "             srcs=[])",
        "java_library(name= 'javalib2',",
        "             srcs=[])");
    addOptions("--experimental_action_listener=//nobuild:javalib1");
    try {
      buildTarget("//nobuild:javalib2");
      Assert.fail("expected failure");
    } catch (ViewCreationFailedException expected) {
      assertThat(expected)
          .hasMessageThat()
          .contains(
              String.format("Analysis of target '%s' failed; build aborted", "//nobuild:javalib2"));
    }
  }

  @Test
  public void testInvalidActionListenerLabel() throws Exception {
    write("nobuild/BUILD",
        "java_library(name= 'javalib',",
        "             srcs=[])");
    addOptions("--experimental_action_listener='this is \\not\\ a valid label'");
    OptionsParsingException expected =
        assertThrows(OptionsParsingException.class, () -> buildTarget("//nobuild:javalib"));
    assertThat(expected)
        .hasMessageThat()
        .isEqualTo(
            String.format(
                "While parsing option %s='%s': invalid package name ''%s'': "
                    + "package names may contain "
                    + "A-Z, a-z, 0-9, or any of ' !\"#$%%&'()*+,-./;<=>?[]^_`{|}~' "
                    + "(most 7-bit ascii characters except 0-31, 127, ':', or '\\')",
                "--experimental_action_listener",
                "this is \\not\\ a valid label",
                "this is \\not\\ a valid label"));
  }

  /**
   * Ensure outputs checked for uniqueness.
   */
  @Test
  public void testNonUniqueOutputs() throws Exception {
    write("nobuild/BUILD",
        "java_library(name= 'javalib',",
        "             srcs=[])",
        "extra_action(name = 'baz',",
        "             out_templates = ['test.tst'],",
        "             cmd = 'echo $(output test.tst)')",
        "action_listener(name = 'bar',",
        "                mnemonics = ['Javac', 'JavaSourceJar'],",
        "                extra_actions = [':baz'])");

    addOptions("--experimental_action_listener=//nobuild:bar");

    try {
      buildTarget("//nobuild:javalib");
      Assert.fail("expected failure");
    } catch (ViewCreationFailedException vcfe) {
      assertThat(vcfe)
          .hasMessageThat()
          .contains(
              String.format("Analysis of target '%s' failed; build aborted", "//nobuild:javalib"));
    }
  }

  /**
   * Regression test for b/236308456.
   *
   * <p>Actions for {@code :shared1} and {@code :shared2} both produce {@code foo/shared.h}. {@code
   * :mid} propagates a dependency on the header via {@code :shared1_lib}, while {@code :top}
   * depends on the header via {@code :shared2_lib}. This leads to the extra action for {@code :top}
   * discovering two inputs with the same exec path but different owners.
   */
  @Test
  public void extraActionDiscoversBothSharedArtifacts() throws Exception {
    write(
        "foo/defs.bzl",
        "def _shared_header_impl(ctx):",
        "  header = ctx.actions.declare_file('shared.h')",
        "  ctx.actions.write(header, '')",
        "  return DefaultInfo(files = depset([header]))",
        "",
        "shared_header = rule(implementation = _shared_header_impl)");
    write(
        "foo/BUILD",
        "load(':defs.bzl', 'shared_header')",
        "shared_header(name = 'shared1')",
        "shared_header(name = 'shared2')",
        "cc_library(name = 'shared1_lib', hdrs = [':shared1'])",
        "cc_library(name = 'shared2_lib', hdrs = [':shared2'])",
        "cc_library(name = 'mid', hdrs = ['mid.h'], deps = [':shared1_lib'])",
        // Order of top's deps matters to reproduce the crash.
        "cc_library(name = 'top', hdrs = ['top.h'], deps = [':shared2_lib', ':mid'])",
        "extra_action(",
        "  name = 'extra',",
        "  cmd = 'touch $(output $(ACTION_ID).out)',",
        "  out_templates = ['$(ACTION_ID).out'],",
        ")",
        "action_listener(",
        "  name = 'listener',",
        "  extra_actions = [':extra'],",
        "  mnemonics = ['CppCompileHeader']",
        ")");
    write("foo/mid.h", "#include \"foo/shared.h\"");
    write(
        "foo/top.h",
        // A system include (<string>) is necessary to reproduce the crash, since otherwise the
        // shared header would be last in the ActionExecutionFunction#addDiscoveredInputs loop.
        "#include <string>",
        "#include \"foo/mid.h\"",
        "#include \"foo/shared.h\"");
    addOptions(
        "--cc_dotd_files",
        "--features=-use_header_modules",
        "--features=parse_headers",
        "--features=cc_include_scanning",
        "--incompatible_use_cpp_compile_header_mnemonic",
        "--experimental_action_listener=//foo:listener");
    buildTarget("//foo:top");
  }
}
