// Copyright 2024 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.remote;

import static com.google.common.truth.Truth.assertThat;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.assertThrows;
import static org.junit.Assume.assumeTrue;

import com.google.devtools.build.lib.util.OS;
import com.google.devtools.build.lib.vfs.DigestHashFunction;
import com.google.devtools.build.lib.vfs.FileSymlinkLoopException;
import com.google.devtools.build.lib.vfs.FileSystem;
import com.google.devtools.build.lib.vfs.FileSystem.NotASymlinkException;
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.lib.vfs.inmemoryfs.InMemoryFileSystem;
import java.io.FileNotFoundException;
import java.io.IOException;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Tests for {@link PathCanonicalizer}. */
@RunWith(JUnit4.class)
public final class PathCanonicalizerTest {

  // Test outline:
  // 1. Set up the filesystem state by calling createSymlink, createNonSymlink or deleteTree.
  // 2. Call assertSuccess or assertFailure to check for successful resolution or failure.

  // On Windows, absolute paths start with a drive letter, e.g. C:/, instead of / as in Unix.
  // To avoid test duplication, when the tests run on Windows, Unix-style absolute paths passed to
  // the above methods will have a C: automatically prepended to them.

  private final FileSystem fs = new InMemoryFileSystem(DigestHashFunction.SHA256);

  private final PathCanonicalizer canonicalizer = new PathCanonicalizer(this::resolve);

  private @Nullable PathFragment resolve(PathFragment pathFragment) throws IOException {
    Path path = fs.getPath(pathFragment);
    try {
      return path.readSymbolicLink();
    } catch (NotASymlinkException e) {
      return null;
    }
  }

  @Test
  public void testRoot() throws Exception {
    assertSuccess("/", "/");
  }

  @Test
  public void testAlreadyCanonical() throws Exception {
    createNonSymlink("/a/b");
    assertSuccess("/a/b", "/a/b");
  }

  @Test
  public void testAbsoluteSymlinkToFile() throws Exception {
    createSymlink("/a/b", "/c/d");
    createNonSymlink("/c/d");
    assertSuccess("/a/b", "/c/d");
  }

  @Test
  public void testAbsoluteSymlinkToDirectory() throws Exception {
    createSymlink("/a/b", "/d/e");
    createNonSymlink("/d/e/c");
    assertSuccess("/a/b/c", "/d/e/c");
  }

  @Test
  public void testAbsoluteSymlinkToDifferentDrive() throws Exception {
    assumeTrue(OS.getCurrent() == OS.WINDOWS);

    createSymlink("C:/a/b", "D:/e/f");
    createNonSymlink("D:/e/f");
    assertSuccess("C:/a/b/c/d", "D:/e/f/c/d");
  }

  @Test
  public void testRelativeSymlinkToFileInSameDirectory() throws Exception {
    createSymlink("/a/b", "c");
    createNonSymlink("/a/c");
    assertSuccess("/a/b", "/a/c");
  }

  @Test
  public void testRelativeSymlinkToFileInDirectoryBelow() throws Exception {
    createSymlink("/a/b", "c/d");
    createNonSymlink("/a/c/d");
    assertSuccess("/a/b", "/a/c/d");
  }

  @Test
  public void testRelativeSymlinkToFileInDirectoryAbove() throws Exception {
    createSymlink("/a/b/c", "../d/e");
    createNonSymlink("/a/d/e");
    assertSuccess("/a/b/c", "/a/d/e");
  }

  @Test
  public void testRelativeSymlinkToRoot() throws Exception {
    createSymlink("/a/b/c", "../../d");
    createNonSymlink("/d");
    assertSuccess("/a/b/c", "/d");
  }

  @Test
  public void testRelativeSymlinkWithTooManyUplevelReferences() throws Exception {
    createSymlink("/a/b", "../../d");
    createNonSymlink("/d/c");
    assertSuccess("/a/b/c", "/d/c");
  }

  @Test
  public void testMultipleSymlinks() throws Exception {
    createSymlink("/a", "/b");
    createSymlink("/b/c", "/d");
    createSymlink("/d/e", "/f");
    createNonSymlink("/f");
    assertSuccess("/a/c/e", "/f");
  }

  @Test
  public void testReplayCanonical() throws Exception {
    createNonSymlink("/a/b/c");
    assertSuccess("/a/b/c", "/a/b/c");
    assertSuccess("/a/b/c", "/a/b/c");
  }

  @Test
  public void testReplaySymlink() throws Exception {
    createSymlink("/a/b", "/d");
    createNonSymlink("/d/c");
    assertSuccess("/a/b/c", "/d/c");
    assertSuccess("/a/b/c", "/d/c");
  }

  @Test
  public void testDistinguishPathsWithCommonPrefix() throws Exception {
    createSymlink("/a/b", "/d");
    createNonSymlink("/d/c");
    createNonSymlink("/a/e");
    assertSuccess("/a/b/c", "/d/c");
    assertSuccess("/a/e", "/a/e");
  }

  @Test
  public void testDistinguishPathsWithDifferentDriveLetter() throws Exception {
    assumeTrue(OS.getCurrent() == OS.WINDOWS);
    createSymlink("C:/a/b", "D:/d");
    createNonSymlink("D:/d/c");
    createNonSymlink("C:/a/b/c");
    assertSuccess("C:/a/b/c", "D:/d/c");
    assertSuccess("D:/a/b/c", "D:/a/b/c");
  }

  @Test
  public void testClearAndReplaceWithSymlink() throws Exception {
    createNonSymlink("/a/b/c");
    assertSuccess("/a/b/c", "/a/b/c");
    deleteTree("/a/b");
    createSymlink("/a/b", "/d");
    createNonSymlink("/d/c");
    assertSuccess("/a/b/c", "/d/c");
  }

  @Test
  public void testClearAndReplaceWithNonSymlink() throws Exception {
    createSymlink("/a/b", "/d");
    createNonSymlink("/d/c");
    assertSuccess("/a/b/c", "/d/c");
    deleteTree("/a/b");
    createNonSymlink("/a/b/c");
    assertSuccess("/a/b/c", "/a/b/c");
  }

  @Test
  public void testClearSymlinkAndDoNotReplace() throws Exception {
    createSymlink("/a/b", "/d");
    createNonSymlink("/d/c");
    assertSuccess("/a/b/c", "/d/c");
    deleteTree("/a/b");
    assertFailure(FileNotFoundException.class, "/a/b/c");
  }

  @Test
  public void testClearNonSymlinkAndDoNotReplace() throws Exception {
    createNonSymlink("/a/b/c");
    assertSuccess("/a/b/c", "/a/b/c");
    deleteTree("/a/b");
    assertFailure(FileNotFoundException.class, "/a/b/c");
  }

  @Test
  public void testSymlinkSelfLoop() throws Exception {
    createSymlink("/a/b", "/a/b");
    assertFailure(FileSymlinkLoopException.class, "/a/b");
  }

  @Test
  public void testSymlinkMutualLoop() throws Exception {
    createSymlink("/a/b", "/c/d");
    createSymlink("/c/d", "/a/b");
    assertFailure(FileSymlinkLoopException.class, "/a/b");
  }

  @Test
  public void testSymlinkChainTooLong() throws Exception {
    for (int i = 0; i < FileSystem.MAX_SYMLINKS + 1; i++) {
      createSymlink(String.format("/%s", i), String.format("/%s", i + 1));
    }
    assertFailure(FileSymlinkLoopException.class, "/0");
  }

  @Test
  public void testFileNotFound() throws Exception {
    assertFailure(FileNotFoundException.class, "/a/b");
    createNonSymlink("/a/b");
    assertSuccess("/a/b", "/a/b");
  }

  @Test
  public void testEmpty() throws Exception {
    assertFailure(IllegalArgumentException.class, "");
  }

  @Test
  public void testNonAbsolute() throws Exception {
    assertFailure(IllegalArgumentException.class, "a/b");
  }

  private void createSymlink(String linkPathStr, String targetPathStr) throws Exception {
    Path linkPath = fs.getPath(pathFragment(linkPathStr));
    linkPath.getParentDirectory().createDirectoryAndParents();
    linkPath.createSymbolicLink(pathFragment(targetPathStr));
  }

  private void createNonSymlink(String pathStr) throws Exception {
    Path path = fs.getPath(pathFragment(pathStr));
    path.getParentDirectory().createDirectoryAndParents();
    FileSystemUtils.writeContent(path, UTF_8, "");
  }

  private void deleteTree(String pathStr) throws Exception {
    canonicalizer.clearPrefix(pathFragment(pathStr));
    fs.getPath(pathFragment(pathStr)).deleteTree();
  }

  private void assertSuccess(String input, String output) throws Exception {
    assertThat(canonicalizer.resolveSymbolicLinks(pathFragment(input)))
        .isEqualTo(pathFragment(output));
  }

  private <T extends Throwable> void assertFailure(Class<T> exceptionClass, String input)
      throws Exception {
    assertThrows(exceptionClass, () -> canonicalizer.resolveSymbolicLinks(pathFragment(input)));
  }

  private static PathFragment pathFragment(String pathStr) {
    if (pathStr.startsWith("/") && OS.getCurrent() == OS.WINDOWS) {
      pathStr = "C:" + pathStr;
    }
    return PathFragment.create(pathStr);
  }
}
