// Copyright 2017 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.android;

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

import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.PatternFilenameFilter;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Tests for {@link ManifestMergerAction}. */
@RunWith(JUnit4.class)
public class ManifestMergerActionTest {

  private Path working;

  /**
   * Returns a runfile's path.
   *
   * <p>The `path` must specify a valid runfile, meaning on Windows (where $RUNFILES_MANIFEST_ONLY
   * is 1) the `path` must exactly match a runfiles manifest entry, and on Linux/MacOS `path` must
   * point to a valid file in the runfiles directory.
   */
  @Nullable
  private static Path rlocation(String path) throws IOException {
    FileSystem fs = FileSystems.getDefault();
    if (fs.getPath(path).isAbsolute()) {
      return fs.getPath(path);
    }
    if ("1".equals(System.getenv("RUNFILES_MANIFEST_ONLY"))) {
      String manifest = System.getenv("RUNFILES_MANIFEST_FILE");
      assertThat(manifest).isNotNull();
      try (BufferedReader r =
          Files.newBufferedReader(Paths.get(manifest), Charset.defaultCharset())) {
        Splitter splitter = Splitter.on(' ').limit(2);
        String line = null;
        while ((line = r.readLine()) != null) {
          List<String> tokens = splitter.splitToList(line);
          if (tokens.size() == 2) {
            if (tokens.get(0).equals(path)) {
              return fs.getPath(tokens.get(1));
            }
          }
        }
      }
      return null;
    } else {
      String runfiles = System.getenv("RUNFILES_DIR");
      if (runfiles == null) {
        runfiles = System.getenv("JAVA_RUNFILES");
        assertThat(runfiles).isNotNull();
      }
      Path result = fs.getPath(runfiles).resolve(path);
      assertThat(result.toFile().exists()).isTrue(); // comply with function's contract
      return result;
    }
  }

  @Before public void setup() throws Exception {
    working = Files.createTempDirectory(toString());
    working.toFile().deleteOnExit();
  }

  @Test
  public void testMergeManifestWithBrokenManifestSyntax() throws Exception {
    String dataDir =
        Paths.get(System.getenv("TEST_WORKSPACE"), System.getenv("TEST_BINARY"))
            .resolveSibling("testing/manifestmerge")
            .toString()
            .replace('\\', '/');
    Files.createDirectories(working.resolve("output"));
    final Path mergedManifest = working.resolve("output/mergedManifest.xml");
    final Path brokenMergerManifest = rlocation(dataDir + "/brokenManifest/AndroidManifest.xml");
    assertThat(brokenMergerManifest.toFile().exists()).isTrue();

    AndroidManifestProcessor.ManifestProcessingException e =
        assertThrows(
            AndroidManifestProcessor.ManifestProcessingException.class,
            () -> {
              ManifestMergerAction.main(
                  generateArgs(
                          brokenMergerManifest,
                          ImmutableMap.of(),
                          false, /* isLibrary */
                          ImmutableMap.of("applicationId", "com.google.android.apps.testapp"),
                          "", /* custom_package */
                          mergedManifest,
                          false /* mergeManifestPermissions */)
                      .toArray(new String[0]));
            });
    assertThat(e)
        .hasMessageThat()
        .contains(
            "com.android.manifmerger.ManifestMerger2$MergeFailureException: "
                + "org.xml.sax.SAXParseException; lineNumber: 6; columnNumber: 6; "
                + "The markup in the document following the root element must be well-formed.");
    assertThat(mergedManifest.toFile().exists()).isFalse();
  }

  @Test
  public void testMerge_GenerateDummyManifest() throws Exception {
    Files.createDirectories(working.resolve("output"));
    Path mergedManifest = working.resolve("output/mergedManifest.xml");

    ManifestMergerAction.main(
        new String[] {
            "--customPackage",
            "foo.bar.baz",
            "--mergeType",
            "LIBRARY",
            "--manifestOutput",
            mergedManifest.toString()
        });

    assertThat(
            Joiner.on(" ")
                .join(Files.readAllLines(mergedManifest, UTF_8))
                .replaceAll("\\s+", " ")
                .trim())
        .isEqualTo(
            "<?xml version=\"1.0\" encoding=\"utf-8\"?> "
                + "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\" "
                + "package=\"foo.bar.baz\" > "
                + "<uses-sdk android:minSdkVersion=\"1\" /> "
                + "<application /> "
                + "</manifest>");
  }

  @Test public void testMerge() throws Exception {
    String dataDir =
        Paths.get(System.getenv("TEST_WORKSPACE"), System.getenv("TEST_BINARY"))
            .resolveSibling("testing/manifestmerge")
            .toString()
            .replace("\\", "/");

    final Path mergerManifest = rlocation(dataDir + "/merger/AndroidManifest.xml");
    final Path mergeeManifestOne = rlocation(dataDir + "/mergeeOne/AndroidManifest.xml");
    final Path mergeeManifestTwo = rlocation(dataDir + "/mergeeTwo/AndroidManifest.xml");
    assertThat(mergerManifest.toFile().exists()).isTrue();
    assertThat(mergeeManifestOne.toFile().exists()).isTrue();
    assertThat(mergeeManifestTwo.toFile().exists()).isTrue();

    // The following code retrieves the path of the only AndroidManifest.xml in the expected/
    // manifests directory. Unfortunately, this test runs internally and externally and the files
    // have different names.
    final File expectedManifestDirectory =
        mergerManifest.getParent().resolveSibling("expected").toFile();
    final String[] debug =
        expectedManifestDirectory.list(new PatternFilenameFilter(".*AndroidManifest\\.xml$"));
    assertThat(debug).isNotNull();
    final File[] expectedManifestDirectoryManifests =
        expectedManifestDirectory.listFiles((File dir, String name) -> true);
    assertThat(expectedManifestDirectoryManifests).isNotNull();
    assertThat(expectedManifestDirectoryManifests).hasLength(1);
    final Path expectedManifest = expectedManifestDirectoryManifests[0].toPath();

    Files.createDirectories(working.resolve("output"));
    final Path mergedManifest = working.resolve("output/mergedManifest.xml");

    List<String> args =
        generateArgs(
            mergerManifest,
            ImmutableMap.of(mergeeManifestOne, "mergeeOne", mergeeManifestTwo, "mergeeTwo"),
            false, /* isLibrary */
            ImmutableMap.of("applicationId", "com.google.android.apps.testapp"),
            "", /* custom_package */
            mergedManifest,
            /* mergeManifestPermissions */ false);
    ManifestMergerAction.main(args.toArray(new String[0]));

    assertThat(
        Joiner.on(" ")
            .join(Files.readAllLines(mergedManifest, UTF_8))
            .replaceAll("\\s+", " ")
            .trim())
        .isEqualTo(
            Joiner.on(" ")
                .join(Files.readAllLines(expectedManifest, UTF_8))
                .replaceAll("\\s+", " ")
                .trim());
  }

  @Test
  public void testMergeWithMergePermissionsEnabled() throws Exception {
    // Largely copied from testMerge() above. Perhaps worth combining the two test methods into one
    // method in the future?
    String dataDir =
        Paths.get(System.getenv("TEST_WORKSPACE"), System.getenv("TEST_BINARY"))
            .resolveSibling("testing/manifestmerge")
            .toString()
            .replace("\\", "/");

    final Path mergerManifest = rlocation(dataDir + "/merger/AndroidManifest.xml");
    final Path mergeeManifestOne = rlocation(dataDir + "/mergeeOne/AndroidManifest.xml");
    final Path mergeeManifestTwo = rlocation(dataDir + "/mergeeTwo/AndroidManifest.xml");
    assertThat(mergerManifest.toFile().exists()).isTrue();
    assertThat(mergeeManifestOne.toFile().exists()).isTrue();
    assertThat(mergeeManifestTwo.toFile().exists()).isTrue();

    // The following code retrieves the path of the only AndroidManifest.xml in the
    // expected-merged-permission/manifests directory. Unfortunately, this test runs
    // internally and externally and the files have different names.
    final File expectedManifestDirectory =
        mergerManifest.getParent().resolveSibling("expected-merged-permissions").toFile();
    assertThat(expectedManifestDirectory.exists()).isTrue();
    final String[] debug =
        expectedManifestDirectory.list(new PatternFilenameFilter(".*AndroidManifest\\.xml$"));
    assertThat(debug).isNotNull();
    final File[] expectedManifestDirectoryManifests =
        expectedManifestDirectory.listFiles((File dir, String name) -> true);
    assertThat(expectedManifestDirectoryManifests).isNotNull();
    assertThat(expectedManifestDirectoryManifests).hasLength(1);
    final Path expectedManifest = expectedManifestDirectoryManifests[0].toPath();

    Files.createDirectories(working.resolve("output"));
    final Path mergedManifest = working.resolve("output/mergedManifest.xml");

    List<String> args =
        generateArgs(
            mergerManifest,
            ImmutableMap.of(mergeeManifestOne, "mergeeOne", mergeeManifestTwo, "mergeeTwo"),
            false, /* isLibrary */
            ImmutableMap.of("applicationId", "com.google.android.apps.testapp"),
            "", /* custom_package */
            mergedManifest,
            /* mergeManifestPermissions */ true);
    ManifestMergerAction.main(args.toArray(new String[0]));

    assertThat(
            Joiner.on(" ")
                .join(Files.readAllLines(mergedManifest, UTF_8))
                .replaceAll("\\s+", " ")
                .trim())
        .isEqualTo(
            Joiner.on(" ")
                .join(Files.readAllLines(expectedManifest, UTF_8))
                .replaceAll("\\s+", " ")
                .trim());
  }

  @Test public void fullIntegration() throws Exception {
    Files.createDirectories(working.resolve("output"));
    final Path binaryOutput = working.resolve("output/binaryManifest.xml");
    final Path libFooOutput = working.resolve("output/libFooManifest.xml");
    final Path libBarOutput = working.resolve("output/libBarManifest.xml");

    final Path binaryManifest = AndroidDataBuilder.of(working.resolve("binary"))
        .createManifest("AndroidManifest.xml", "com.google.app", "")
        .buildUnvalidated()
        .getManifest();
    final Path libFooManifest = AndroidDataBuilder.of(working.resolve("libFoo"))
        .createManifest("AndroidManifest.xml", "com.google.foo",
            " <application android:name=\"${applicationId}\" />")
        .buildUnvalidated()
        .getManifest();
    final Path libBarManifest = AndroidDataBuilder.of(working.resolve("libBar"))
        .createManifest("AndroidManifest.xml", "com.google.bar",
            "<application android:name=\"${applicationId}\">",
            "<activity android:name=\".activityFoo\" />",
            "</application>")
        .buildUnvalidated()
        .getManifest();

    // libFoo manifest merging
    List<String> args =
        generateArgs(
            libFooManifest,
            ImmutableMap.<Path, String>of(),
            true,
            ImmutableMap.<String, String>of(),
            "",
            libFooOutput,
            false);
    ManifestMergerAction.main(args.toArray(new String[0]));
    assertThat(Joiner.on(" ")
        .join(Files.readAllLines(libFooOutput, UTF_8))
        .replaceAll("\\s+", " ").trim()).contains(
            "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
            + "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\""
            + " package=\"com.google.foo\">"
            + " <application android:name=\"${applicationId}\" />"
            + "</manifest>");

    // libBar manifest merging
    args =
        generateArgs(
            libBarManifest,
            ImmutableMap.<Path, String>of(),
            true,
            ImmutableMap.<String, String>of(),
            "com.google.libbar",
            libBarOutput,
            false);
    ManifestMergerAction.main(args.toArray(new String[0]));
    assertThat(Joiner.on(" ")
        .join(Files.readAllLines(libBarOutput, UTF_8))
        .replaceAll("\\s+", " ").trim()).contains(
            "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
            + " <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\""
            + " package=\"com.google.libbar\" >"
            + " <application android:name=\"${applicationId}\" >"
            + " <activity android:name=\"com.google.bar.activityFoo\" />"
            + " </application>"
            + " </manifest>");

    // binary manifest merging
    args =
        generateArgs(
            binaryManifest,
            ImmutableMap.of(libFooOutput, "libFoo", libBarOutput, "libBar"),
            /* library= */ false,
            ImmutableMap.of(
                "applicationId", "com.google.android.app",
                "foo", "this \\\\: is \"a, \"bad string"),
            /* customPackage= */ "",
            binaryOutput,
            /* mergeManifestPermissions */ false);
    ManifestMergerAction.main(args.toArray(new String[0]));
    assertThat(Joiner.on(" ")
        .join(Files.readAllLines(binaryOutput, UTF_8))
        .replaceAll("\\s+", " ").trim()).contains(
            "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
            + " <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\""
            + " package=\"com.google.android.app\" >"
            + " <application android:name=\"com.google.android.app\" >"
            + " <activity android:name=\"com.google.bar.activityFoo\" />"
            + " </application>"
            + " </manifest>");
  }

  private List<String> generateArgs(
      Path manifest,
      Map<Path, String> mergeeManifests,
      boolean library,
      Map<String, String> manifestValues,
      String customPackage,
      Path manifestOutput,
      boolean mergeManifestPermissions) {
    ImmutableList.Builder<String> builder = ImmutableList.builder();
    builder.add(
        "--manifest", manifest.toString(),
        "--mergeeManifests", mapToDictionaryString(mergeeManifests));
    if (mergeManifestPermissions) {
      builder.add("--mergeManifestPermissions");
    }

    builder.add(
        "--mergeType",
        library ? "LIBRARY" : "APPLICATION",
        "--manifestValues",
        mapToDictionaryString(manifestValues),
        "--customPackage",
        customPackage,
        "--manifestOutput",
        manifestOutput.toString());
    return builder.build();
  }

  private <K, V> String mapToDictionaryString(Map<K, V> map) {
    StringBuilder sb = new StringBuilder();
    Iterator<Map.Entry<K, V>> iter = map.entrySet().iterator();
    while (iter.hasNext()) {
      Map.Entry<K, V> entry = iter.next();
      sb.append(entry.getKey().toString().replace(":", "\\:").replace(",", "\\,"));
      sb.append(':');
      sb.append(entry.getValue().toString().replace(":", "\\:").replace(",", "\\,"));
      if (iter.hasNext()) {
        sb.append(',');
      }
    }
    return sb.toString();
  }
}
