// Copyright 2015 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.pkgcache;

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

import com.google.common.base.Joiner;
import com.google.common.base.Predicates;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.eventbus.EventBus;
import com.google.devtools.build.lib.analysis.BlazeDirectories;
import com.google.devtools.build.lib.clock.BlazeClock;
import com.google.devtools.build.lib.cmdline.Label;
import com.google.devtools.build.lib.events.Reporter;
import com.google.devtools.build.lib.packages.ConstantRuleVisibility;
import com.google.devtools.build.lib.packages.NoSuchPackageException;
import com.google.devtools.build.lib.packages.NoSuchTargetException;
import com.google.devtools.build.lib.packages.NoSuchThingException;
import com.google.devtools.build.lib.packages.Package;
import com.google.devtools.build.lib.packages.Rule;
import com.google.devtools.build.lib.packages.Target;
import com.google.devtools.build.lib.packages.util.LoadingMock;
import com.google.devtools.build.lib.skyframe.BazelSkyframeExecutorConstants;
import com.google.devtools.build.lib.skyframe.DiffAwareness;
import com.google.devtools.build.lib.skyframe.PrecomputedValue;
import com.google.devtools.build.lib.skyframe.SequencedSkyframeExecutor;
import com.google.devtools.build.lib.skyframe.SkyValueDirtinessChecker;
import com.google.devtools.build.lib.skyframe.SkyframeExecutor;
import com.google.devtools.build.lib.syntax.GlobList;
import com.google.devtools.build.lib.syntax.SkylarkSemanticsOptions;
import com.google.devtools.build.lib.testutil.ManualClock;
import com.google.devtools.build.lib.testutil.TestConstants;
import com.google.devtools.build.lib.util.Preconditions;
import com.google.devtools.build.lib.util.io.TimestampGranularityMonitor;
import com.google.devtools.build.lib.vfs.Dirent;
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.ModifiedFileSet;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.build.lib.vfs.PathFragment;
import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
import com.google.devtools.build.skyframe.SkyFunction;
import com.google.devtools.build.skyframe.SkyFunctionName;
import com.google.devtools.common.options.Options;
import com.google.devtools.common.options.OptionsClassProvider;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import javax.annotation.Nullable;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/**
 * Tests for incremental loading; these cover both normal operation and diff awareness, for which a
 * list of modified / added / removed files is available.
 */
@RunWith(JUnit4.class)
public class IncrementalLoadingTest {
  protected PackageCacheTester tester;

  private Path throwOnReaddir = null;
  private Path throwOnStat = null;

  @Before
  public final void createTester() throws Exception {
    ManualClock clock = new ManualClock();
    FileSystem fs =
        new InMemoryFileSystem(clock) {
          @Override
          public Collection<Dirent> readdir(Path path, boolean followSymlinks) throws IOException {
            if (path.equals(throwOnReaddir)) {
              throw new FileNotFoundException(path.getPathString());
            }
            return super.readdir(path, followSymlinks);
          }

          @Nullable
          @Override
          public FileStatus stat(Path path, boolean followSymlinks) throws IOException {
            if (path.equals(throwOnStat)) {
              throw new IOException("bork " + path.getPathString());
            }
            return super.stat(path, followSymlinks);
          }
        };
    tester = createTester(fs, clock);
  }

  protected PackageCacheTester createTester(FileSystem fs, ManualClock clock) throws Exception {
    return new PackageCacheTester(fs, clock);
  }

  @Test
  public void testNoChange() throws Exception {
    tester.addFile("base/BUILD",
        "filegroup(name = 'hello', srcs = ['foo.txt'])");
    tester.sync();
    Target oldTarget = tester.getTarget("//base:hello");
    assertThat(oldTarget).isNotNull();

    tester.sync();
    Target newTarget = tester.getTarget("//base:hello");
    assertThat(newTarget).isSameAs(oldTarget);
  }

  @Test
  public void testModifyBuildFile() throws Exception {
    tester.addFile("base/BUILD", "filegroup(name = 'hello', srcs = ['foo.txt'])");
    tester.sync();
    Target oldTarget = tester.getTarget("//base:hello");

    tester.modifyFile("base/BUILD", "filegroup(name = 'hello', srcs = ['bar.txt'])");
    tester.sync();
    Target newTarget = tester.getTarget("//base:hello");
    assertThat(newTarget).isNotSameAs(oldTarget);
  }

  @Test
  public void testModifyNonBuildFile() throws Exception {
    tester.addFile("base/BUILD", "filegroup(name = 'hello', srcs = ['foo.txt'])");
    tester.addFile("base/foo.txt", "nothing");
    tester.sync();
    Target oldTarget = tester.getTarget("//base:hello");

    tester.modifyFile("base/foo.txt", "other");
    tester.sync();
    Target newTarget = tester.getTarget("//base:hello");
    assertThat(newTarget).isSameAs(oldTarget);
  }

  @Test
  public void testRemoveNonBuildFile() throws Exception {
    tester.addFile("base/BUILD", "filegroup(name = 'hello', srcs = ['foo.txt'])");
    tester.addFile("base/foo.txt", "nothing");
    tester.sync();
    Target oldTarget = tester.getTarget("//base:hello");

    tester.removeFile("base/foo.txt");
    tester.sync();
    Target newTarget = tester.getTarget("//base:hello");
    assertThat(newTarget).isSameAs(oldTarget);
  }

  @Test
  public void testModifySymlinkedFileSamePackage() throws Exception {
    tester.addSymlink("base/BUILD", "mybuild");
    tester.addFile("base/mybuild", "filegroup(name = 'hello', srcs = ['foo.txt'])");
    tester.sync();
    Target oldTarget = tester.getTarget("//base:hello");
    tester.modifyFile("base/mybuild", "filegroup(name = 'hello', srcs = ['bar.txt'])");
    tester.sync();
    Target newTarget = tester.getTarget("//base:hello");
    assertThat(newTarget).isNotSameAs(oldTarget);
  }

  @Test
  public void testModifySymlinkedFileDifferentPackage() throws Exception {
    tester.addSymlink("base/BUILD", "../other/BUILD");
    tester.addFile("other/BUILD", "filegroup(name = 'hello', srcs = ['foo.txt'])");
    tester.sync();
    Target oldTarget = tester.getTarget("//base:hello");

    tester.modifyFile("other/BUILD", "filegroup(name = 'hello', srcs = ['bar.txt'])");
    tester.sync();
    Target newTarget = tester.getTarget("//base:hello");
    assertThat(newTarget).isNotSameAs(oldTarget);
  }

  @Test
  public void testBUILDSymlinkModifiedThenChanges() throws Exception {
    // We need to ensure that the timestamps of "one" and "two" are different, because Blaze
    // currently does not recognize changes to symlinks if the timestamps of the old and the new
    // file pointed to by the symlink are the same.
    tester.addFile("one", "filegroup(name='a', srcs=['1'])");
    tester.sync();

    tester.addFile("two", "filegroup(name='a', srcs=['2'])");
    tester.addSymlink("oldlink", "one");
    tester.addSymlink("newlink", "one");
    tester.addSymlink("a/BUILD", "../oldlink");
    tester.sync();
    Target a1 = tester.getTarget("//a:a");

    tester.modifySymlink("a/BUILD", "../newlink");
    tester.sync();

    tester.getTarget("//a:a");

    tester.modifySymlink("newlink", "two");
    tester.sync();

    Target a3 = tester.getTarget("//a:a");
    assertThat(a3).isNotSameAs(a1);
  }

  @Test
  public void testBUILDFileIsExternalSymlinkAndChanges() throws Exception {
    tester.addFile("/nonroot/file", "filegroup(name='a', srcs=['file'])");
    tester.addSymlink("a/BUILD", "/nonroot/file");
    tester.sync();

    Target a1 = tester.getTarget("//a:a");
    tester.modifyFile("/nonroot/file", "filegroup(name='a', srcs=['file2'])");
    tester.sync();

    Target a2 = tester.getTarget("//a:a");
    tester.sync();

    assertThat(a2).isNotSameAs(a1);
  }

  @Test
  public void testLabelWithTwoSegmentsAndTotalInvalidation() throws Exception {
    tester.addFile("a/BUILD", "filegroup(name='fg', srcs=['b/c'])");
    tester.addFile("a/b/BUILD");
    tester.sync();

    Target fg1 = tester.getTarget("//a:fg");
    tester.everythingModified();
    tester.sync();

    Target fg2 = tester.getTarget("//a:fg");
    assertThat(fg2).isSameAs(fg1);
  }

  @Test
  public void testAddGlobFile() throws Exception {
    tester.addFile("base/BUILD", "filegroup(name = 'hello', srcs = glob(['*.txt']))");
    tester.addFile("base/foo.txt", "nothing");
    tester.sync();
    Target oldTarget = tester.getTarget("//base:hello");

    tester.addFile("base/bar.txt", "also nothing");
    tester.sync();
    Target newTarget = tester.getTarget("//base:hello");
    assertThat(newTarget).isNotSameAs(oldTarget);
  }

  @Test
  public void testRemoveGlobFile() throws Exception {
    tester.addFile("base/BUILD", "filegroup(name = 'hello', srcs = glob(['*.txt']))");
    tester.addFile("base/foo.txt", "nothing");
    tester.addFile("base/bar.txt", "also nothing");
    tester.sync();
    Target oldTarget = tester.getTarget("//base:hello");

    tester.removeFile("base/bar.txt");
    tester.sync();
    Target newTarget = tester.getTarget("//base:hello");
    assertThat(newTarget).isNotSameAs(oldTarget);
  }

  @Test
  public void testPackageNotInLastBuildReplaced() throws Exception {
    tester.addFile("a/BUILD", "filegroup(name='a', srcs=['bad.sh'])");
    tester.sync();
    Target a1 = tester.getTarget("//a:a");

    tester.addFile("b/BUILD", "filegroup(name='b', srcs=['b.sh'])");
    tester.modifyFile("a/BUILD", "filegroup(name='a', srcs=['good.sh'])");
    tester.sync();
    tester.getTarget("//b:b");

    tester.sync();
    Target a2 = tester.getTarget("//a:a");
    assertThat(a2).isNotSameAs(a1);
  }

  @Test
  public void testBrokenSymlinkAddedThenFixed() throws Exception {
    tester.addFile("a/BUILD", "filegroup(name='a', srcs=glob(['**']))");
    tester.sync();
    Target a1 = tester.getTarget("//a:a");

    tester.addSymlink("a/b", "../c");
    tester.sync();
    tester.getTarget("//a:a");

    tester.addFile("c");
    tester.sync();
    Target a3 = tester.getTarget("//a:a");
    assertThat(a3).isNotSameAs(a1);
  }

  @Test
  public void testBuildFileWithSyntaxError() throws Exception {
    tester.addFile("a/BUILD", "sh_library(xyz='a')");
    tester.sync();
    try {
      tester.getTarget("//a:a");
      fail();
    } catch (NoSuchThingException e) {
      // Expected
    }

    tester.modifyFile("a/BUILD", "sh_library(name='a')");
    tester.sync();
    tester.getTarget("//a:a");
  }

  @Test
  public void testSymlinkedBuildFileWithSyntaxError() throws Exception {
    tester.addFile("a/BUILD.real", "sh_library(xyz='a')");
    tester.addSymlink("a/BUILD", "BUILD.real");
    tester.sync();
    try {
      tester.getTarget("//a:a");
      fail();
    } catch (NoSuchThingException e) {
      // Expected
    }
    tester.modifyFile("a/BUILD.real", "sh_library(name='a')");
    tester.sync();
    tester.getTarget("//a:a");
  }

  @Test
  public void testTransientErrorsInGlobbing() throws Exception {
    Path buildFile = tester.addFile("e/BUILD", "sh_library(name = 'e', data = glob(['*.txt']))");
    Path parentDir = buildFile.getParentDirectory();
    tester.addFile("e/data.txt");
    throwOnReaddir = parentDir;
    tester.sync();
    try {
      tester.getTarget("//e:e");
    } catch (NoSuchPackageException expected) {
    }
    throwOnReaddir = null;
    tester.sync();
    Target target = tester.getTarget("//e:e");
    assertThat(((Rule) target).containsErrors()).isFalse();
    GlobList<?> globList = (GlobList<?>) ((Rule) target).getAttributeContainer().getAttr("data");
    assertThat(globList).containsExactly(Label.parseAbsolute("//e:data.txt"));
  }

  @Test
  public void testIrrelevantFileInSubdirDoesntReloadPackage() throws Exception {
    tester.addFile("pkg/BUILD", "sh_library(name = 'pkg', srcs = glob(['**/*.sh']))");
    tester.addFile("pkg/pkg.sh", "#!/bin/bash");
    tester.addFile("pkg/bar/bar.sh", "#!/bin/bash");
    Package pkg = tester.getTarget("//pkg:pkg").getPackage();

    // Write file in directory to force reload of top-level glob.
    tester.addFile("pkg/irrelevant_file");
    tester.addFile("pkg/bar/irrelevant_file"); // Subglob is also reloaded.
    assertThat(tester.getTarget("//pkg:pkg").getPackage()).isSameAs(pkg);
  }

  @Test
  public void testMissingPackages() throws Exception {
    tester.sync();

    try {
      tester.getTarget("//a:a");
      fail();
    } catch (NoSuchThingException e) {
      // expected
    }

    tester.addFile("a/BUILD", "sh_library(name='a')");
    tester.sync();
    tester.getTarget("//a:a");
  }

  @Test
  public void testChangedExternalFile() throws Exception {
    tester.addFile("a/BUILD",
        "load('/a/b', 'b')",
        "b()");

    tester.addFile("/b.bzl",
        "def b():",
        "  pass");
    tester.addSymlink("a/b.bzl", "/b.bzl");
    tester.sync();
    tester.getTarget("//a:BUILD");
    tester.modifyFile("/b.bzl", "ERROR ERROR");
    tester.sync();

    try {
      tester.getTarget("//a:BUILD");
      fail();
    } catch (NoSuchThingException e) {
      // expected
    }
  }


  static class PackageCacheTester {
    private class ManualDiffAwareness implements DiffAwareness {
      private View lastView;
      private View currentView;

      @Override
      public View getCurrentView(OptionsClassProvider options) {
        lastView = currentView;
        currentView = new View() {};
        return currentView;
      }

      @Override
      public ModifiedFileSet getDiff(View oldView, View newView) {
        if (oldView == lastView && newView == currentView) {
          return Preconditions.checkNotNull(modifiedFileSet);
        } else {
          return ModifiedFileSet.EVERYTHING_MODIFIED;
        }
      }

      @Override
      public String name() {
        return "PackageCacheTester.DiffAwareness";
      }

      @Override
      public void close() {
      }
    }

    private class ManualDiffAwarenessFactory implements DiffAwareness.Factory {
      @Nullable
      @Override
      public DiffAwareness maybeCreate(Path pathEntry) {
        return pathEntry == workspace ? new ManualDiffAwareness() : null;
      }
    }

    private final ManualClock clock;
    private final Path workspace;
    private final Path outputBase;
    private final Reporter reporter = new Reporter(new EventBus());
    private final SkyframeExecutor skyframeExecutor;
    private final List<Path> changes = new ArrayList<>();
    private boolean everythingModified = false;
    private ModifiedFileSet modifiedFileSet;

    public PackageCacheTester(FileSystem fs, ManualClock clock) throws IOException {
      this.clock = clock;
      workspace = fs.getPath("/workspace");
      workspace.createDirectory();
      outputBase = fs.getPath("/output_base");
      outputBase.createDirectory();
      addFile("WORKSPACE");

      LoadingMock loadingMock = LoadingMock.get();
      skyframeExecutor =
          SequencedSkyframeExecutor.create(
              loadingMock
                  .getPackageFactoryBuilderForTesting()
                  .build(loadingMock.createRuleClassProvider(), fs),
              new BlazeDirectories(
                  fs.getPath("/install"),
                  fs.getPath("/output"),
                  workspace,
                  loadingMock.getProductName()),
              null, /* BinTools */
              null, /* workspaceStatusActionFactory */
              loadingMock.createRuleClassProvider().getBuildInfoFactories(),
              ImmutableList.of(new ManualDiffAwarenessFactory()),
              Predicates.<PathFragment>alwaysFalse(),
              ImmutableMap.<SkyFunctionName, SkyFunction>of(),
              ImmutableList.<PrecomputedValue.Injected>of(),
              ImmutableList.<SkyValueDirtinessChecker>of(),
              PathFragment.EMPTY_FRAGMENT,
              loadingMock.getProductName(),
              BazelSkyframeExecutorConstants.CROSS_REPOSITORY_LABEL_VIOLATION_STRATEGY,
              BazelSkyframeExecutorConstants.BUILD_FILES_BY_PRIORITY,
              BazelSkyframeExecutorConstants.ACTION_ON_IO_EXCEPTION_READING_BUILD_FILE);
      TestConstants.processSkyframeExecutorForTesting(skyframeExecutor);
      PackageCacheOptions packageCacheOptions = Options.getDefaults(PackageCacheOptions.class);
      packageCacheOptions.defaultVisibility = ConstantRuleVisibility.PUBLIC;
      packageCacheOptions.showLoadingProgress = true;
      packageCacheOptions.globbingThreads = 7;
      skyframeExecutor.preparePackageLoading(
          new PathPackageLocator(outputBase, ImmutableList.of(workspace)),
          packageCacheOptions,
          Options.getDefaults(SkylarkSemanticsOptions.class),
          "",
          UUID.randomUUID(),
          ImmutableMap.<String, String>of(),
          ImmutableMap.<String, String>of(),
          new TimestampGranularityMonitor(BlazeClock.instance()));
    }

    Path addFile(String fileName, String... content) throws IOException {
      Path buildFile = workspace.getRelative(fileName);
      Preconditions.checkState(!buildFile.exists());
      Path currentPath = buildFile;

      // Add the new file and all the directories that will be created by
      // createDirectoryAndParents()
      while (!currentPath.exists()) {
        changes.add(currentPath);
        currentPath = currentPath.getParentDirectory();
      }

      FileSystemUtils.createDirectoryAndParents(buildFile.getParentDirectory());
      FileSystemUtils.writeContentAsLatin1(buildFile, Joiner.on('\n').join(content));
      return buildFile;
    }

    void addSymlink(String fileName, String target) throws IOException {
      Path path = workspace.getRelative(fileName);
      Preconditions.checkState(!path.exists());
      FileSystemUtils.createDirectoryAndParents(path.getParentDirectory());
      path.createSymbolicLink(PathFragment.create(target));
      changes.add(path);
    }

    void removeFile(String fileName) throws IOException {
      Path path = workspace.getRelative(fileName);
      Preconditions.checkState(path.delete());
      changes.add(path);
    }

    void modifyFile(String fileName, String... content) throws IOException {
      Path path = workspace.getRelative(fileName);
      Preconditions.checkState(path.exists());
      Preconditions.checkState(path.delete());
      FileSystemUtils.writeContentAsLatin1(path, Joiner.on('\n').join(content));
      changes.add(path);
    }

    void modifySymlink(String fileName, String newTarget) throws IOException {
      Path symlink = workspace.getRelative(fileName);
      Preconditions.checkState(symlink.exists());
      symlink.delete();
      symlink.createSymbolicLink(PathFragment.create(newTarget));
      changes.add(symlink);
    }

    void everythingModified() {
      everythingModified = true;
    }

    private ModifiedFileSet getModifiedFileSet() {
      if (everythingModified) {
        everythingModified = false;
        return ModifiedFileSet.EVERYTHING_MODIFIED;
      }

      ModifiedFileSet.Builder builder = ModifiedFileSet.builder();
      for (Path path : changes) {
        if (!path.startsWith(workspace)) {
          continue;
        }

        PathFragment workspacePath = path.relativeTo(workspace);
        builder.modify(workspacePath);
      }
      return builder.build();
    }

    void sync() throws InterruptedException {
      clock.advanceMillis(1);

      modifiedFileSet = getModifiedFileSet();
      PackageCacheOptions packageCacheOptions = Options.getDefaults(PackageCacheOptions.class);
      packageCacheOptions.defaultVisibility = ConstantRuleVisibility.PUBLIC;
      packageCacheOptions.showLoadingProgress = true;
      packageCacheOptions.globbingThreads = 7;
      skyframeExecutor.preparePackageLoading(
          new PathPackageLocator(outputBase, ImmutableList.of(workspace)),
          packageCacheOptions,
          Options.getDefaults(SkylarkSemanticsOptions.class),
          "",
          UUID.randomUUID(),
          ImmutableMap.<String, String>of(),
          ImmutableMap.<String, String>of(),
          new TimestampGranularityMonitor(BlazeClock.instance()));
      skyframeExecutor.invalidateFilesUnderPathForTesting(
          new Reporter(new EventBus()), modifiedFileSet, workspace);
      ((SequencedSkyframeExecutor) skyframeExecutor).handleDiffs(new Reporter(new EventBus()));

      changes.clear();
    }

    Target getTarget(String targetName)
        throws NoSuchPackageException, NoSuchTargetException, InterruptedException {
      Label label = Label.parseAbsoluteUnchecked(targetName);
      return skyframeExecutor.getPackageManager().getTarget(reporter, label);
    }
  }
}
