// Copyright 2021 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.rules.java;

import static com.google.common.truth.Truth.assertThat;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.devtools.build.lib.analysis.ConfiguredTarget;
import com.google.devtools.build.lib.analysis.util.BuildViewTestCase;
import com.google.devtools.build.lib.packages.StructProvider;
import com.google.devtools.build.lib.testutil.TestConstants;
import com.google.devtools.build.lib.vfs.ModifiedFileSet;
import com.google.devtools.build.lib.vfs.PathFragment;
import com.google.devtools.build.lib.vfs.Root;
import java.util.Map;
import java.util.Map.Entry;
import java.util.function.Predicate;
import net.starlark.java.eval.Dict;
import net.starlark.java.eval.EvalException;
import net.starlark.java.eval.Starlark;
import net.starlark.java.eval.StarlarkList;
import net.starlark.java.eval.Structure;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Tests if JavaInfo identical to one returned by Java rules can be constructed. */
@RunWith(JUnit4.class)
public class JavaInfoRoundtripTest extends BuildViewTestCase {
  /** A rule to convert JavaInfo to a structure having only string values. */
  @Before
  public void javaInfoToDict() throws Exception {
    mockToolsConfig.create("tools/build_defs/inspect/BUILD");
    mockToolsConfig.copyTool(
        TestConstants.BAZEL_REPO_SCRATCH + "tools/build_defs/inspect/struct_to_dict.bzl",
        "tools/build_defs/inspect/struct_to_dict.bzl");

    scratch.file(
        "javainfo/javainfo_to_dict.bzl",
        "load('//tools/build_defs/inspect:struct_to_dict.bzl', 'struct_to_dict')",
        "def _impl(ctx):",
        "  return struct(result = struct_to_dict(ctx.attr.dep[JavaInfo], 10))",
        "javainfo_to_dict = rule(_impl, attrs = {'dep' : attr.label()})");
  }

  /** A simple rule that calls JavaInfo constructor using identical attribute as java_library. */
  @Before
  public void constructJavaInfo() throws Exception {
    if (!getAnalysisMock().isThisBazel()) {
      setBuildLanguageOptions("--experimental_google_legacy_api");
    }
    scratch.file(
        "foo/construct_javainfo.bzl",
        "def _impl(ctx):",
        "  OUTS = {",
        "    'lib':'lib%s.jar',",
        "    'hjar': 'lib%s-hjar.jar',",
        "    'src': 'lib%s-src.jar',",
        "    'compile_jdeps': 'lib%s-hjar.jdeps',",
        "    'genclass': 'lib%s-gen.jar',",
        "    'gensource': 'lib%s-gensrc.jar',",
        "    'jdeps': 'lib%s.jdeps',",
        "    'manifest': 'lib%s.jar_manifest_proto',",
        "    'headers': 'lib%s-native-header.jar',",
        "  }",
        "  for file, name in OUTS.items():",
        "     OUTS[file] = ctx.actions.declare_file(name % ctx.label.name)",
        "     ctx.actions.write(OUTS[file], '')",
        "  ",
        "  java_info = JavaInfo(",
        "     output_jar = OUTS['lib'],",
        "     compile_jar = OUTS['hjar'],",
        "     source_jar = OUTS['src'],",
        "     compile_jdeps = OUTS['compile_jdeps'],",
        "     generated_class_jar = ctx.attr.plugins and OUTS['genclass'] or None,",
        "     generated_source_jar = ctx.attr.plugins and OUTS['gensource'] or None,",
        "     manifest_proto = OUTS['manifest'],",
        "     native_headers_jar = OUTS['headers'],",
        "     deps = [d[JavaInfo] for d in ctx.attr.deps],",
        "     runtime_deps = [d[JavaInfo] for d in ctx.attr.runtime_deps],",
        "     exports = [d[JavaInfo] for d in ctx.attr.exports],",
        "     jdeps = OUTS['jdeps'],",
        "  )",
        "  return [java_info]",
        "",
        "construct_javainfo = rule(",
        "  implementation = _impl,",
        "  attrs = {",
        "    'srcs': attr.label_list(allow_files = True),",
        "    'deps': attr.label_list(),",
        "    'runtime_deps': attr.label_list(),",
        "    'exports': attr.label_list(),",
        "    'plugins': attr.bool(default = False),",
        "  },",
        "  fragments = ['java'],",
        ")");
  }

  /** For a given target providing JavaInfo returns a Starlark Dict with String values */
  private Dict<Object, Object> getDictFromJavaInfo(String packageName, String javaInfoTarget)
      throws Exception {
    // Because we're overwriting files to have identical names, we need to invalidate them.
    skyframeExecutor.invalidateFilesUnderPathForTesting(
        reporter,
        new ModifiedFileSet.Builder().modify(PathFragment.create(packageName + "/BUILD")).build(),
        Root.fromPath(rootDirectory));

    scratch.deleteFile("javainfo/BUILD");
    ConfiguredTarget dictTarget =
        scratchConfiguredTarget(
            "javainfo",
            "javainfo",
            "load(':javainfo_to_dict.bzl', 'javainfo_to_dict')",
            "javainfo_to_dict(",
            "  name = 'javainfo',",
            "  dep = '//" + packageName + ':' + javaInfoTarget + "',",
            ")");
    @SuppressWarnings("unchecked") // deserialization
    Dict<Object, Object> javaInfo = (Dict<Object, Object>) dictTarget.get("result");
    return deepStripAttributes(
        javaInfo,
        attr -> attr.startsWith("_") || ImmutableSet.of("to_proto", "to_json").contains(attr));
  }

  @SuppressWarnings("unchecked")
  private static <T> T deepStripAttributes(T obj, Predicate<String> shouldRemove)
      throws EvalException {
    if (obj == null) {
      return null;
    } else if (obj instanceof StarlarkList) {
      ImmutableList.Builder<Object> builder = ImmutableList.builder();
      for (Object item : (StarlarkList<Object>) obj) {
        builder.add(deepStripAttributes(item, shouldRemove));
      }
      return (T) StarlarkList.immutableCopyOf(builder.build());
    } else if (obj instanceof Structure) {
      for (String fieldName : ((Structure) obj).getFieldNames()) {
        Dict.Builder<String, Object> builder = Dict.builder();
        if (!shouldRemove.test(fieldName)) {
          builder.put(
              fieldName, deepStripAttributes(((Structure) obj).getValue(fieldName), shouldRemove));
        }
        return (T) StructProvider.STRUCT.create(builder.buildImmutable(), "");
      }
    } else if (obj instanceof Dict) {
      Dict.Builder<Object, Object> builder = Dict.builder();
      for (Entry<Object, Object> e :
          Dict.cast(obj, Object.class, Object.class, "dict").entrySet()) {
        if (!(e.getKey() instanceof String && shouldRemove.test((String) e.getKey()))) {
          builder.put(e.getKey(), deepStripAttributes(e.getValue(), shouldRemove));
        }
      }
      return (T) builder.buildImmutable();
    }
    return obj;
  }

  private Dict<Object, Object> removeCompilationInfo(Dict<Object, Object> javaInfo) {
    return Dict.builder().putAll(javaInfo).put("compilation_info", Starlark.NONE).buildImmutable();
  }

  private Dict<Object, Object> removeAnnotationClasses(Dict<Object, Object> javaInfo) {
    @SuppressWarnings("unchecked") // safe by specification
    Dict<Object, Object> annotationProcessing =
        (Dict<Object, Object>) javaInfo.get("annotation_processing");

    annotationProcessing =
        Dict.builder()
            .putAll(annotationProcessing)
            .put("enabled", false)
            .put("processor_classnames", StarlarkList.immutableOf())
            .put("processor_classpath", StarlarkList.immutableOf())
            .buildImmutable();
    return Dict.builder()
        .putAll(javaInfo)
        .put("annotation_processing", annotationProcessing)
        .buildImmutable();
  }

  @Test
  public void dictFromJavaInfo_nonEmpty() throws Exception {
    scratch.overwriteFile("foo/BUILD", "java_library(name = 'java_lib', srcs = ['A.java'])");

    Dict<Object, Object> javaInfo = getDictFromJavaInfo("foo", "java_lib");

    assertThat((Map<?, ?>) javaInfo).isNotEmpty();
  }

  @Test
  public void dictFromJavaInfo_detectsDifference() throws Exception {

    scratch.overwriteFile("foo/BUILD", "java_library(name = 'java_lib', srcs = ['A.java'])");
    Dict<Object, Object> javaInfoA = getDictFromJavaInfo("foo", "java_lib");

    scratch.overwriteFile("foo/BUILD", "java_library(name = 'java_lib2', srcs = ['A.java'])");
    Dict<Object, Object> javaInfoB = getDictFromJavaInfo("foo", "java_lib2");

    assertThat((Map<?, ?>) javaInfoA).isNotEqualTo(javaInfoB);
  }

  @Test
  public void roundtripJavainfo_srcs() throws Exception {

    scratch.overwriteFile("foo/BUILD", "java_library(name = 'java_lib', srcs = ['A.java'])");
    Dict<Object, Object> javaInfoA = getDictFromJavaInfo("foo", "java_lib");
    scratch.overwriteFile(
        "foo/BUILD",
        "load('//foo:construct_javainfo.bzl', 'construct_javainfo')",
        "construct_javainfo(name = 'java_lib', srcs = ['A.java'])");
    Dict<Object, Object> javaInfoB = getDictFromJavaInfo("foo", "java_lib");

    javaInfoA = removeCompilationInfo(javaInfoA);
    assertThat((Map<?, ?>) javaInfoB).isEqualTo(javaInfoA);
  }

  @Test
  public void roundtripJavaInfo_deps() throws Exception {
    scratch.file("bar/BUILD", "java_library(name = 'javalib', srcs = ['A.java'])");

    scratch.overwriteFile(
        "foo/BUILD",
        "java_library(",
        "  name = 'java_lib',",
        "  srcs = ['A.java'],",
        "  deps = ['//bar:javalib']",
        ")");
    Dict<Object, Object> javaInfoA = getDictFromJavaInfo("foo", "java_lib");
    scratch.overwriteFile(
        "foo/BUILD",
        "load('//foo:construct_javainfo.bzl', 'construct_javainfo')",
        "construct_javainfo(",
        "  name = 'java_lib', ",
        "  srcs = ['A.java'], ",
        "  deps = ['//bar:javalib'],",
        ")");
    Dict<Object, Object> javaInfoB = getDictFromJavaInfo("foo", "java_lib");

    javaInfoA = removeCompilationInfo(javaInfoA);
    assertThat((Map<?, ?>) javaInfoB).isEqualTo(javaInfoA);
  }

  @Test
  public void roundtipJavaInfo_runtimeDeps() throws Exception {
    scratch.file("bar/BUILD", "java_library(name = 'deplib', srcs = ['A.java'])");

    scratch.overwriteFile(
        "foo/BUILD",
        "java_library(",
        "  name = 'java_lib',",
        "  srcs = ['A.java'],",
        "  runtime_deps = ['//bar:deplib']",
        ")");
    Dict<Object, Object> javaInfoA = getDictFromJavaInfo("foo", "java_lib");
    scratch.overwriteFile(
        "foo/BUILD",
        "load('//foo:construct_javainfo.bzl', 'construct_javainfo')",
        "construct_javainfo(",
        "  name = 'java_lib', ",
        "  srcs = ['A.java'], ",
        "  runtime_deps = ['//bar:deplib'],",
        ")");
    Dict<Object, Object> javaInfoB = getDictFromJavaInfo("foo", "java_lib");

    javaInfoA = removeCompilationInfo(javaInfoA);
    assertThat((Map<?, ?>) javaInfoB).isEqualTo(javaInfoA);
  }

  @Test
  public void roundtipJavaInfo_exports() throws Exception {
    scratch.file("bar/BUILD", "java_library(name = 'exportlib', srcs = ['A.java'])");

    scratch.overwriteFile(
        "foo/BUILD",
        "java_library(",
        "  name = 'java_lib',",
        "  srcs = ['A.java'],",
        "  exports = ['//bar:exportlib']",
        ")");
    Dict<Object, Object> javaInfoA = getDictFromJavaInfo("foo", "java_lib");
    scratch.overwriteFile(
        "foo/BUILD",
        "load('//foo:construct_javainfo.bzl', 'construct_javainfo')",
        "construct_javainfo(",
        "  name = 'java_lib', ",
        "  srcs = ['A.java'], ",
        "  exports = ['//bar:exportlib'],",
        ")");
    Dict<Object, Object> javaInfoB = getDictFromJavaInfo("foo", "java_lib");

    javaInfoA = removeCompilationInfo(javaInfoA);
    assertThat((Map<?, ?>) javaInfoB).isEqualTo(javaInfoA);
  }

  @Test
  public void roundtipJavaInfo_plugin() throws Exception {
    scratch.file(
        "bar/BUILD",
        "java_plugin(name = 'plugin', srcs = ['A.java'], processor_class = 'bar.Main')");

    scratch.overwriteFile(
        "foo/BUILD",
        "java_library(",
        "  name = 'java_lib',",
        "  srcs = ['A.java'],",
        "  plugins = ['//bar:plugin']",
        ")");
    Dict<Object, Object> javaInfoA = getDictFromJavaInfo("foo", "java_lib");
    scratch.overwriteFile(
        "foo/BUILD",
        "load('//foo:construct_javainfo.bzl', 'construct_javainfo')",
        "construct_javainfo(",
        "  name = 'java_lib', ",
        "  srcs = ['A.java'], ",
        "  plugins = True,",
        ")");
    Dict<Object, Object> javaInfoB = getDictFromJavaInfo("foo", "java_lib");

    javaInfoA = removeCompilationInfo(javaInfoA);
    javaInfoA = removeAnnotationClasses(javaInfoA);
    assertThat((Map<?, ?>) javaInfoB).isEqualTo(javaInfoA);
  }
}
