// Copyright 2019 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.bazel.rules.python;

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

import com.google.devtools.build.lib.actions.Action;
import com.google.devtools.build.lib.actions.Artifact;
import com.google.devtools.build.lib.analysis.ConfiguredTarget;
import com.google.devtools.build.lib.analysis.FilesToRunProvider;
import com.google.devtools.build.lib.analysis.actions.Substitution;
import com.google.devtools.build.lib.analysis.actions.TemplateExpansionAction;
import com.google.devtools.build.lib.analysis.util.BuildViewTestCase;
import com.google.devtools.build.lib.testutil.TestConstants;
import com.google.devtools.build.lib.util.OS;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Bazel-specific tests for {@code py_binary}. */
@RunWith(JUnit4.class)
public class BazelPyBinaryConfiguredTargetTest extends BuildViewTestCase {

  private static final String TOOLCHAIN_BZL =
      TestConstants.TOOLS_REPOSITORY + "//tools/python:toolchain.bzl";

  private static final String TOOLCHAIN_TYPE =
      TestConstants.TOOLS_REPOSITORY + "//tools/python:toolchain_type";

  private static String join(String... lines) {
    return String.join("\n", lines);
  }

  /**
   * Given a {@code py_binary} or {@code py_test} target, returns the path of the Python interpreter
   * used by the generated stub script.
   *
   * <p>This works by casting the stub script's generating action to a template expansion action and
   * looking for the expansion key for the Python interpreter. It's therefore linked to the
   * implementation of the rule, but that's the cost we pay for avoiding an execution-time test.
   */
  private String getInterpreterPathFromStub(ConfiguredTarget pyExecutableTarget) {
    // First find the stub script. Normally this is just the executable associated with the target.
    // But for Windows the executable is a separate launcher with an ".exe" extension, and the stub
    // script artifact has the same base name with the extension ".temp" instead. (At least, when
    // --build_python_zip is enabled, which is the default on Windows.)
    Artifact executable = pyExecutableTarget.getProvider(FilesToRunProvider.class).getExecutable();
    Artifact stub;
    if (OS.getCurrent() == OS.WINDOWS) {
      stub =
          getDerivedArtifact(
              FileSystemUtils.replaceExtension(executable.getRootRelativePath(), ".temp"),
              executable.getRoot(),
              executable.getArtifactOwner());
    } else {
      stub = executable;
    }
    assertThat(stub).isNotNull();
    // Now grab its generating action, which should be a template action, and get the key for the
    // binary path.
    Action generatingAction = getGeneratingAction(stub);
    assertThat(generatingAction).isInstanceOf(TemplateExpansionAction.class);
    TemplateExpansionAction templateAction = (TemplateExpansionAction) generatingAction;
    for (Substitution sub : templateAction.getSubstitutions()) {
      if (sub.getKey().equals("%python_binary%")) {
        return sub.getValue();
      }
    }
    throw new AssertionError(
        "Failed to find the '%python_binary%' key in the stub script's template expansion action");
  }

  // TODO(#8169): Delete tests of the legacy --python_top / --python_path behavior.

  @Test
  public void runtimeSetByPythonTop() throws Exception {
    scratch.file(
        "pkg/BUILD",
        "py_runtime(",
        "    name = 'my_py_runtime',",
        "    interpreter_path = '/system/python2',",
        "    python_version = 'PY2',",
        ")",
        "py_binary(",
        "    name = 'pybin',",
        "    srcs = ['pybin.py'],",
        ")");
    String pythonTop =
        analysisMock.pySupport().createPythonTopEntryPoint(mockToolsConfig, "//pkg:my_py_runtime");
    useConfiguration("--incompatible_use_python_toolchains=false", "--python_top=" + pythonTop);
    String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:pybin"));
    assertThat(path).isEqualTo("/system/python2");
  }

  @Test
  public void runtimeSetByPythonPath() throws Exception {
    scratch.file(
        "pkg/BUILD", //
        "py_binary(",
        "    name = 'pybin',",
        "    srcs = ['pybin.py'],",
        ")");
    useConfiguration("--incompatible_use_python_toolchains=false", "--python_path=/system/python2");
    String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:pybin"));
    assertThat(path).isEqualTo("/system/python2");
  }

  @Test
  public void runtimeDefaultsToPythonSystemCommand() throws Exception {
    scratch.file(
        "pkg/BUILD", //
        "py_binary(",
        "    name = 'pybin',",
        "    srcs = ['pybin.py'],",
        ")");
    useConfiguration("--incompatible_use_python_toolchains=false");
    String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:pybin"));
    assertThat(path).isEqualTo("python");
  }

  @Test
  public void pythonTopTakesPrecedenceOverPythonPath() throws Exception {
    scratch.file(
        "pkg/BUILD",
        "py_runtime(",
        "    name = 'my_py_runtime',",
        "    interpreter_path = '/system/python2',",
        "    python_version = 'PY2',",
        ")",
        "py_binary(",
        "    name = 'pybin',",
        "    srcs = ['pybin.py'],",
        ")");
    String pythonTop =
        analysisMock.pySupport().createPythonTopEntryPoint(mockToolsConfig, "//pkg:my_py_runtime");
    useConfiguration(
        "--incompatible_use_python_toolchains=false",
        "--python_top=" + pythonTop,
        "--python_path=/better/not/be/this/one");
    String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:pybin"));
    assertThat(path).isEqualTo("/system/python2");
  }

  // TODO(brandjon): Move generic toolchain tests that don't access legacy behavior to
  // PyExecutableConfiguredtargetTestBase. Asserting on the chosen PyRuntimeInfo is problematic to
  // do at analysis time though. It's easier in this test because we know the PythonSemantics is
  // BazelPythonSemantics.

  /** Adds toolchain definitions to a //toolchains package, for user by the below tests. */
  private void defineToolchains() throws Exception {
    scratch.file(
        "toolchains/BUILD",
        "load('" + TOOLCHAIN_BZL + "', 'py_runtime_pair')",
        "py_runtime(",
        "    name = 'py2_runtime',",
        "    interpreter_path = '/system/python2',",
        "    python_version = 'PY2',",
        ")",
        "py_runtime(",
        "    name = 'py3_runtime',",
        "    interpreter_path = '/system/python3',",
        "    python_version = 'PY3',",
        ")",
        "py_runtime_pair(",
        "    name = 'py_runtime_pair',",
        "    py2_runtime = ':py2_runtime',",
        "    py3_runtime = ':py3_runtime',",
        ")",
        "toolchain(",
        "    name = 'py_toolchain',",
        "    toolchain = ':py_runtime_pair',",
        "    toolchain_type = '" + TOOLCHAIN_TYPE + "',",
        ")",
        "py_runtime_pair(",
        "    name = 'py_runtime_pair_for_py2_only',",
        "    py2_runtime = ':py2_runtime',",
        ")",
        "toolchain(",
        "    name = 'py_toolchain_for_py2_only',",
        "    toolchain = ':py_runtime_pair_for_py2_only',",
        "    toolchain_type = '" + TOOLCHAIN_TYPE + "',",
        ")");
  }

  @Test
  public void runtimeObtainedFromToolchain() throws Exception {
    defineToolchains();
    scratch.file(
        "pkg/BUILD",
        "py_binary(",
        "    name = 'py2_bin',",
        "    srcs = ['py2_bin.py'],",
        "    python_version = 'PY2',",
        ")",
        "py_binary(",
        "    name = 'py3_bin',",
        "    srcs = ['py3_bin.py'],",
        "    python_version = 'PY3',",
        ")");
    useConfiguration(
        "--incompatible_use_python_toolchains=true",
        "--extra_toolchains=//toolchains:py_toolchain");

    String py2Path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:py2_bin"));
    String py3Path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:py3_bin"));
    assertThat(py2Path).isEqualTo("/system/python2");
    assertThat(py3Path).isEqualTo("/system/python3");
  }

  @Test
  public void toolchainCanOmitUnusedRuntimeVersion() throws Exception {
    defineToolchains();
    scratch.file(
        "pkg/BUILD",
        "py_binary(",
        "    name = 'py2_bin',",
        "    srcs = ['py2_bin.py'],",
        "    python_version = 'PY2',",
        ")");
    useConfiguration(
        "--incompatible_use_python_toolchains=true",
        "--extra_toolchains=//toolchains:py_toolchain_for_py2_only");

    String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:py2_bin"));
    assertThat(path).isEqualTo("/system/python2");
  }

  @Test
  public void toolchainTakesPrecedenceOverLegacyFlags() throws Exception {
    defineToolchains();
    scratch.file(
        "pkg/BUILD",
        "py_binary(",
        "    name = 'py2_bin',",
        "    srcs = ['py2_bin.py'],",
        "    python_version = 'PY2',",
        ")");
    useConfiguration(
        "--incompatible_use_python_toolchains=true",
        "--extra_toolchains=//toolchains:py_toolchain",
        "--python_path=/better/not/be/this/one");

    String path = getInterpreterPathFromStub(getConfiguredTarget("//pkg:py2_bin"));
    assertThat(path).isEqualTo("/system/python2");
  }

  @Test
  public void toolchainIsMissingNeededRuntime() throws Exception {
    defineToolchains();
    reporter.removeHandler(failFastHandler);
    scratch.file(
        "pkg/BUILD",
        "py_binary(",
        "    name = 'py3_bin',",
        "    srcs = ['py3_bin.py'],",
        "    python_version = 'PY3',",
        ")");
    useConfiguration(
        "--incompatible_use_python_toolchains=true",
        "--extra_toolchains=//toolchains:py_toolchain_for_py2_only");

    getConfiguredTarget("//pkg:py3_bin");
    assertContainsEvent("The Python toolchain does not provide a runtime for Python version PY3");
  }

  /**
   * Creates a custom toolchain at //toolchains:custom that has the given lines in its rule
   * implementation function.
   */
  private void defineCustomToolchain(String... lines) throws Exception {
    String indentedBody;
    if (lines.length == 0) {
      indentedBody = "    pass";
    } else {
      indentedBody = "    " + join(lines).replace("\n", "\n    ");
    }
    scratch.file(
        "toolchains/rules.bzl",
        "def _custom_impl(ctx):",
        indentedBody,
        "custom = rule(",
        "    implementation = _custom_impl",
        ")");
    scratch.file(
        "toolchains/BUILD",
        "load(':rules.bzl', 'custom')",
        "custom(",
        "    name = 'custom',",
        ")",
        "toolchain(",
        "    name = 'custom_toolchain',",
        "    toolchain = ':custom',",
        "    toolchain_type = '" + TOOLCHAIN_TYPE + "',",
        ")");
  }

  /**
   * Defines a PY2 py_binary target at //pkg:pybin, configures it to use the custom toolchain
   * //toolchains:custom, and attempts to retrieve it with {@link #getConfiguredTarget}.
   */
  private void analyzePy2BinaryTargetUsingCustomToolchain() throws Exception {
    scratch.file(
        "pkg/BUILD",
        "py_binary(",
        "    name = 'pybin',",
        "    srcs = ['pybin.py'],",
        "    python_version = 'PY2',",
        ")");
    useConfiguration(
        "--incompatible_use_python_toolchains=true",
        "--extra_toolchains=//toolchains:custom_toolchain");
    getConfiguredTarget("//pkg:pybin");
  }

  @Test
  public void toolchainInfoFieldIsMissing() throws Exception {
    reporter.removeHandler(failFastHandler);
    defineCustomToolchain(
        "return platform_common.ToolchainInfo(",
        "    py2_runtime = PyRuntimeInfo(",
        "        interpreter_path = '/system/python2',",
        "        python_version = 'PY2')",
        ")");
    // Use PY2 binary to test that we still validate the PY3 field even when it's not needed.
    analyzePy2BinaryTargetUsingCustomToolchain();
    assertContainsEvent(
        "Error parsing the Python toolchain's ToolchainInfo: field 'py3_runtime' is missing");
  }

  @Test
  public void toolchainInfoFieldHasBadType() throws Exception {
    reporter.removeHandler(failFastHandler);
    defineCustomToolchain(
        "return platform_common.ToolchainInfo(",
        "    py2_runtime = PyRuntimeInfo(",
        "        interpreter_path = '/system/python2',",
        "        python_version = 'PY2'),",
        "    py3_runtime = 'abc',",
        ")");
    // Use PY2 binary to test that we still validate the PY3 field even when it's not needed.
    analyzePy2BinaryTargetUsingCustomToolchain();
    assertContainsEvent(
        "Error parsing the Python toolchain's ToolchainInfo: Expected a PyRuntimeInfo in field "
            + "'py3_runtime', but got 'string'");
  }

  @Test
  public void toolchainInfoFieldHasBadVersion() throws Exception {
    reporter.removeHandler(failFastHandler);
    defineCustomToolchain(
        "return platform_common.ToolchainInfo(",
        "    py2_runtime = PyRuntimeInfo(",
        "        interpreter_path = '/system/python2',",
        "        python_version = 'PY2'),",
        "    py3_runtime = PyRuntimeInfo(",
        "        interpreter_path = '/system/python3',",
        // python_version is erroneously set to PY2 for the PY3 field.
        "        python_version = 'PY2'),",
        ")");
    // Use PY2 binary to test that we still validate the PY3 field even when it's not needed.
    analyzePy2BinaryTargetUsingCustomToolchain();
    assertContainsEvent(
        "Error retrieving the Python runtime from the toolchain: Expected field 'py3_runtime' to "
            + "have a runtime with python_version = 'PY3', but got python_version = 'PY2'");
  }

  @Test
  public void explicitInitPy_CanBeGloballyEnabled() throws Exception {
    scratch.file(
        "pkg/BUILD",
        join(
            "py_binary(", //
            "    name = 'foo',",
            "    srcs = ['foo.py'],",
            ")"));
    useConfiguration("--incompatible_default_to_explicit_init_py=true");
    assertThat(getDefaultRunfiles(getConfiguredTarget("//pkg:foo")).getEmptyFilenames().toList())
        .isEmpty();
  }

  @Test
  public void explicitInitPy_CanBeSelectivelyDisabled() throws Exception {
    scratch.file(
        "pkg/BUILD",
        join(
            "py_binary(", //
            "    name = 'foo',",
            "    srcs = ['foo.py'],",
            "    legacy_create_init = True,",
            ")"));
    useConfiguration("--incompatible_default_to_explicit_init_py=true");
    assertThat(getDefaultRunfiles(getConfiguredTarget("//pkg:foo")).getEmptyFilenames().toList())
        .containsExactly("pkg/__init__.py");
  }

  @Test
  public void explicitInitPy_CanBeGloballyDisabled() throws Exception {
    scratch.file(
        "pkg/BUILD",
        join(
            "py_binary(", //
            "    name = 'foo',",
            "    srcs = ['foo.py'],",
            ")"));
    useConfiguration("--incompatible_default_to_explicit_init_py=false");
    assertThat(getDefaultRunfiles(getConfiguredTarget("//pkg:foo")).getEmptyFilenames().toList())
        .containsExactly("pkg/__init__.py");
  }

  @Test
  public void explicitInitPy_CanBeSelectivelyEnabled() throws Exception {
    scratch.file(
        "pkg/BUILD",
        join(
            "py_binary(", //
            "    name = 'foo',",
            "    srcs = ['foo.py'],",
            "    legacy_create_init = False,",
            ")"));
    useConfiguration("--incompatible_default_to_explicit_init_py=false");
    assertThat(getDefaultRunfiles(getConfiguredTarget("//pkg:foo")).getEmptyFilenames().toList())
        .isEmpty();
  }
}
