// Copyright 2022 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.skyframe;

import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.common.truth.TruthJUnit.assume;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.fail;

import com.google.common.collect.ImmutableList;
import com.google.common.eventbus.Subscribe;
import com.google.devtools.build.lib.actions.BuildFailedException;
import com.google.devtools.build.lib.actions.ChangedFilesMessage;
import com.google.devtools.build.lib.analysis.BlazeDirectories;
import com.google.devtools.build.lib.buildtool.util.SkyframeIntegrationTestBase;
import com.google.devtools.build.lib.runtime.BlazeModule;
import com.google.devtools.build.lib.runtime.BlazeRuntime;
import com.google.devtools.build.lib.runtime.Command;
import com.google.devtools.build.lib.runtime.WorkspaceBuilder;
import com.google.devtools.build.lib.util.AbruptExitException;
import com.google.devtools.build.lib.util.OS;
import com.google.devtools.build.lib.vfs.DelegateFileSystem;
import com.google.devtools.build.lib.vfs.FileStatus;
import com.google.devtools.build.lib.vfs.FileSystem;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.build.lib.vfs.PathFragment;
import com.google.devtools.build.skyframe.NotifyingHelper;
import com.google.devtools.common.options.OptionsBase;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/**
 * Tests for local diff awareness. A good place for general tests of Bazel's interactions with
 * "smart" filesystems, so that open-source changes don't break Google-internal features around
 * smart filesystems.
 */
@RunWith(JUnit4.class)
public class LocalDiffAwarenessIntegrationTest extends SkyframeIntegrationTestBase {
  private final Map<PathFragment, IOException> throwOnNextStatIfFound = new HashMap<>();

  @Override
  protected BlazeRuntime.Builder getRuntimeBuilder() throws Exception {
    return super.getRuntimeBuilder()
        .addBlazeModule(
            new BlazeModule() {
              @Override
              public void workspaceInit(
                  BlazeRuntime runtime, BlazeDirectories directories, WorkspaceBuilder builder) {
                builder.addDiffAwarenessFactory(new LocalDiffAwareness.Factory(ImmutableList.of()));
              }

              @Override
              public Iterable<Class<? extends OptionsBase>> getCommandOptions(Command command) {
                return ImmutableList.of(LocalDiffAwareness.Options.class);
              }
            });
  }

  @Override
  public FileSystem createFileSystem() throws Exception {
    return new DelegateFileSystem(super.createFileSystem()) {
      @Override
      protected FileStatus statIfFound(PathFragment path, boolean followSymlinks)
          throws IOException {
        IOException e = throwOnNextStatIfFound.remove(path);
        if (e != null) {
          throw e;
        }
        return super.statIfFound(path, followSymlinks);
      }
    };
  }

  @Before
  public void addOptions() {
    addOptions("--watchfs", "--experimental_windows_watchfs");
  }

  @After
  public void checkExceptionsThrown() {
    assertWithMessage("Injected exception(s) not thrown").that(throwOnNextStatIfFound).isEmpty();
  }

  @Test
  public void changedFile_detectsChange() throws Exception {
    // TODO(b/238606809): Understand why these tests are flaky on Mac. Probably real watchfs bug?
    assume().that(OS.getCurrent()).isNotEqualTo(OS.DARWIN);
    write("foo/BUILD", "genrule(name='foo', outs=['out'], cmd='echo hello > $@')");
    buildTarget("//foo");
    assertContents("hello", "//foo");
    write("foo/BUILD", "genrule(name='foo', outs=['out'], cmd='echo there > $@')");

    buildTargetWithRetryUntilSeesChange("//foo", "foo/BUILD");

    assertContents("there", "//foo");
  }

  @Test
  public void changedIgnoredFile_ignoresChange() throws Exception {
    // MacOSXFsEventsDiffAwareness doesn't currently support not registering
    // watches for ignored paths.
    assume().that(OS.getCurrent()).isNotEqualTo(OS.DARWIN);

    String notIgnoredFilePath = "foo/BUILD";
    String ignoredFilePath = "foo/ignored-dir/BUILD";

    write(".bazelignore", "foo/ignored-dir");

    write(ignoredFilePath, "");
    write(notIgnoredFilePath, "genrule(name='foo', outs=['out'], cmd='echo hello > $@')");
    buildTarget("//foo");
    assertContents("hello", "//foo");

    write(notIgnoredFilePath, "genrule(name='foo', outs=['out'], cmd='echo there > $@')");
    write(ignoredFilePath, "A = 1");

    AtomicBoolean ignoredFileChanged = new AtomicBoolean();
    AtomicBoolean notIgnoredFileChanged = new AtomicBoolean();
    runtimeWrapper.registerSubscriber(
        new Object() {
          @Subscribe
          private void onChangedFiles(ChangedFilesMessage changedFiles) {
            ignoredFileChanged.compareAndSet(
                false, changedFiles.changedFiles().contains(PathFragment.create(ignoredFilePath)));
            notIgnoredFileChanged.compareAndSet(
                false,
                changedFiles.changedFiles().contains(PathFragment.create(notIgnoredFilePath)));
          }
        });

    // Work around the inherent raciness of LocalDiffAwareness where the FS events are
    // delivered asynchronously and fast running test can trigger an incremental build
    // before the change is observed.
    for (int attempt = 0; attempt < 10; ++attempt) {
      buildTarget("//foo");
      if (notIgnoredFileChanged.get() && !ignoredFileChanged.get()) {
        assertContents("there", "//foo");
        return;
      }
    }

    if (!notIgnoredFileChanged.get()) {
      fail("Didn't observe file change within allowed number of retries");
    }
    if (ignoredFileChanged.get()) {
      fail("Observed ignored file change");
    }
  }

  @Test
  public void changedFile_statFails_throwsError() throws Exception {
    // TODO(b/238606809): Understand why these tests are flaky on Mac. Probably real watchfs bug?
    assume().that(OS.getCurrent()).isNotEqualTo(OS.DARWIN);
    write("foo/BUILD", "genrule(name='foo', outs=['out'], cmd='echo hello > $@')");
    buildTarget("//foo");
    assertContents("hello", "//foo");
    Path buildFile = write("foo/BUILD", "genrule(name='foo', outs=['out'], cmd='echo there > $@')");
    IOException injectedException = new IOException("oh no!");
    throwOnNextStatIfFound.put(buildFile.asFragment(), injectedException);

    AbruptExitException e =
        assertThrows(
            AbruptExitException.class,
            () -> buildTargetWithRetryUntilSeesChange("//foo", "foo/BUILD"));

    assertThat(e).hasCauseThat().hasCauseThat().hasCauseThat().isInstanceOf(IOException.class);
  }

  @Test
  public void renameDirectory_thenRenameBackWithRemovedFile_glob() throws Exception {
    write(
        "foo/BUILD",
        """
        genrule(
            name = "foo",
            srcs = glob(["*dir*/**"]),
            outs = ["out"],
            cmd = "echo $(SRCS) > $@",
        )
        """);
    Path dir = write("foo/dir/file1.txt").getParentDirectory();
    write("foo/dir/file2.txt");
    buildTarget("//foo");
    assertContents("foo/dir/file1.txt foo/dir/file2.txt", "//foo");

    Path newDir = dir.getParentDirectory().getChild("new_dir");
    dir.renameTo(newDir);
    buildTargetWithRetryUntilSeesChange("//foo", "foo/dir/file1.txt");
    assertContents("foo/new_dir/file1.txt foo/new_dir/file2.txt", "//foo");

    newDir.getChild("file2.txt").delete();
    newDir.renameTo(dir);
    buildTargetWithRetryUntilSeesChange("//foo", "foo/new_dir/file1.txt");
    assertContents("foo/dir/file1.txt", "//foo");
  }

  @Test
  public void renameDirectory_thenRenameBackWithRemovedFile_inputs() throws Exception {
    write(
        "foo/BUILD",
        """
        genrule(
            name = "foo",
            srcs = ["dir/file1.txt", "dir/file2.txt"],
            outs = ["out"],
            cmd = "echo $(SRCS) > $@",
        )
        """);
    Path dir = write("foo/dir/file1.txt").getParentDirectory();
    write("foo/dir/file2.txt");
    buildTarget("//foo");
    assertContents("foo/dir/file1.txt foo/dir/file2.txt", "//foo");

    Path newDir = dir.getParentDirectory().getChild("new_dir");
    dir.renameTo(newDir);
    write(
        "foo/BUILD",
        """
        genrule(
            name = "foo",
            srcs = ["new_dir/file1.txt", "new_dir/file2.txt"],
            outs = ["out"],
            cmd = "echo $(SRCS) > $@",
        )
        """);
    buildTargetWithRetryUntilSeesChange("//foo", "foo/dir/file1.txt");
    assertContents("foo/new_dir/file1.txt foo/new_dir/file2.txt", "//foo");

    newDir.getChild("file2.txt").delete();
    newDir.renameTo(dir);
    write(
        "foo/BUILD",
        """
        genrule(
            name = "foo",
            srcs = ["dir/file1.txt"],
            outs = ["out"],
            cmd = "echo $(SRCS) > $@",
        )
        """);
    buildTargetWithRetryUntilSeesChange("//foo", "foo/new_dir/file1.txt");
    assertContents("foo/dir/file1.txt", "//foo");

    write(
        "foo/BUILD",
        """
        genrule(
            name = "foo",
            srcs = ["dir/file2.txt"],
            outs = ["out"],
            cmd = "echo $(SRCS) > $@",
        )
        """);
    var e = assertThrows(BuildFailedException.class, () -> buildTarget("//foo"));
    // It is important that the error message says "do not exist", not "are in error". The latter
    // would indicate that the corresponding FileStateValue still considers the file to exist.
    assertThat(e).hasMessageThat().contains("1 input file(s) do not exist");
  }

  /**
   * Runs {@link #buildTarget(String...)} repeatedly until we observe a change for the given path.
   *
   * <p>This allows to work around the inherent raciness of {@code LocalDiffAwareness} where the FS
   * events are delivered asynchronously and fast running test can trigger an incremental build
   * before the change is observed.
   */
  private void buildTargetWithRetryUntilSeesChange(String target, String path) throws Exception {
    AtomicBoolean changed = new AtomicBoolean();
    runtimeWrapper.registerSubscriber(
        new Object() {
          @Subscribe
          private void onChangedFiles(ChangedFilesMessage changedFiles) {
            changed.compareAndSet(
                false, changedFiles.changedFiles().contains(PathFragment.create(path)));
          }
        });
    for (int attempt = 0; attempt < 10; ++attempt) {
      buildTarget(target);
      if (changed.get()) {
        return;
      }
    }
    fail("Didn't observe file change within allowed number of retries");
  }

  // This test doesn't use --watchfs functionality, but if the source filesystem doesn't offer diffs
  // Bazel must scan the full Skyframe graph anyway, so a bug in checking output files wouldn't be
  // detected without --watchfs.
  @Test
  public void ignoreOutputFilesThenCheckAgainDoesCheck() throws Exception {
    if ("bazel".equals(this.getRuntime().getProductName())) {
      // Repository options only in Bazel.
      addOptions("--noexperimental_check_external_repository_files");
    }
    Path buildFile =
        write(
            "foo/BUILD",
            "genrule(name = 'foo', outs = ['out'], cmd = 'cp $< $@', srcs = ['link'])");
    Path outputFile = directories.getOutputBase().getChild("linkTarget");
    FileSystemUtils.writeContentAsLatin1(outputFile, "one");
    buildFile.getParentDirectory().getChild("link").createSymbolicLink(outputFile.asFragment());

    buildTarget("//foo:foo");

    assertContents("one", "//foo:foo");

    addOptions("--noexperimental_check_output_files");
    FileSystemUtils.writeContentAsLatin1(outputFile, "two");

    buildTarget("//foo:foo");

    assertContents("one", "//foo:foo");

    addOptions("--experimental_check_output_files");

    buildTarget("//foo:foo");

    assertContents("two", "//foo:foo");
  }

  @Test
  public void externalSymlink_doesNotTriggerFullGraphTraversal() throws Exception {
    addOptions("--symlink_prefix=/");
    if ("bazel".equals(this.getRuntime().getProductName())) {
      // Repository options only in Bazel.
      addOptions("--noexperimental_check_external_repository_files");
    }
    AtomicInteger calledGetValues = new AtomicInteger(0);
    skyframeExecutor()
        .getEvaluator()
        .injectGraphTransformerForTesting(
            NotifyingHelper.makeNotifyingTransformer(
                (key, type, order, context) -> {
                  if (type == NotifyingHelper.EventType.GET_VALUES) {
                    calledGetValues.incrementAndGet();
                  }
                }));
    write(
        "hello/BUILD",
        "genrule(name='target', srcs = ['external'], outs=['out'], cmd='/bin/cat $(SRCS) > $@')");
    String externalLink = System.getenv("TEST_TMPDIR") + "/target";
    write(externalLink, "one");
    createSymlink(externalLink, "hello/external");

    // Trivial build: external symlink is not seen, so normal diff awareness is in play.
    buildTarget("//hello:BUILD");
    // New package path on first build triggers full-graph work.
    calledGetValues.set(0);
    // getValuesAndExceptions() called during output file checking (although if an output service is
    // able to report modified files in practice there is no iteration).

    buildTarget("//hello:BUILD");
    assertThat(calledGetValues.getAndSet(0)).isEqualTo(1);

    // Now bring the external symlink into Bazel's awareness.
    buildTarget("//hello:target");
    assertContents("one", "//hello:target");
    assertThat(calledGetValues.getAndSet(0)).isEqualTo(1);

    // Builds that follow a build containing an external file don't trigger a traversal.
    buildTarget("//hello:target");
    assertContents("one", "//hello:target");
    assertThat(calledGetValues.getAndSet(0)).isEqualTo(1);

    write(externalLink, "two");

    buildTarget("//hello:target");
    // External file changes are tracked.
    assertContents("two", "//hello:target");
    assertThat(calledGetValues.getAndSet(0)).isEqualTo(1);
  }
}
