# Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

import os
import subprocess
from contextlib import contextmanager
from textwrap import dedent

from pants.backend.jvm.subsystems.junit import JUnit
from pants.backend.jvm.subsystems.jvm_platform import JvmPlatform
from pants.backend.jvm.targets.java_library import JavaLibrary
from pants.backend.jvm.targets.junit_tests import JUnitTests
from pants.backend.jvm.tasks.coursier.coursier_subsystem import CoursierSubsystem
from pants.backend.jvm.tasks.coverage.cobertura import Cobertura
from pants.backend.jvm.tasks.coverage.engine import NoCoverage
from pants.backend.jvm.tasks.coverage.jacoco import Jacoco
from pants.backend.jvm.tasks.coverage.manager import CodeCoverage
from pants.backend.jvm.tasks.junit_run import JUnitRun
from pants.backend.python.targets.python_tests import PythonTests
from pants.base.exceptions import TargetDefinitionException, TaskError
from pants.build_graph.build_file_aliases import BuildFileAliases
from pants.build_graph.files import Files
from pants.build_graph.resources import Resources
from pants.java.distribution.distribution import DistributionLocator
from pants.java.executor import SubprocessExecutor
from pants.testutil.jvm.jvm_tool_task_test_base import JvmToolTaskTestBase
from pants.testutil.subsystem.util import global_subsystem_instance, init_subsystem
from pants.testutil.task_test_base import ensure_cached
from pants.util.contextutil import environment_as, temporary_dir, temporary_file
from pants.util.dirutil import touch


class JUnitRunnerTest(JvmToolTaskTestBase):
    @classmethod
    def task_type(cls):
        return JUnitRun

    @classmethod
    def alias_groups(cls):
        return (
            super()
            .alias_groups()
            .merge(
                BuildFileAliases(
                    targets={
                        "files": Files,
                        "junit_tests": JUnitTests,
                        "python_tests": PythonTests,
                    },
                )
            )
        )

    def setUp(self):
        super().setUp()
        init_subsystem(JUnit)

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_junit_runner_success(self):
        self._execute_junit_runner(
            [
                (
                    "FooTest.java",
                    dedent(
                        """
                        import org.junit.Test;
                        import static org.junit.Assert.assertTrue;
                        public class FooTest {
                          @Test
                          public void testFoo() {
                            assertTrue(5 > 3);
                          }
                        }
                        """
                    ),
                )
            ]
        )

    @ensure_cached(JUnitRun, expected_num_artifacts=0)
    def test_junit_runner_failure(self):
        with self.assertRaises(TaskError) as cm:
            self._execute_junit_runner(
                [
                    (
                        "FooTest.java",
                        dedent(
                            """
                            import org.junit.Test;
                            import static org.junit.Assert.assertTrue;
                            public class FooTest {
                              @Test
                              public void testFoo() {
                                assertTrue(5 < 3);
                              }
                            }
                            """
                        ),
                    )
                ]
            )

        self.assertEqual([t.name for t in cm.exception.failed_targets], ["foo_test"])

    @ensure_cached(JUnitRun, expected_num_artifacts=0)
    def test_junit_runner_error(self):
        with self.assertRaises(TaskError) as cm:
            self._execute_junit_runner(
                [
                    (
                        "FooTest.java",
                        dedent(
                            """
                            import org.junit.Test;
                            public class FooTest {
                              @Test
                              public void testFoo() {
                                throw new RuntimeException("test error");
                              }
                            }
                            """
                        ),
                    )
                ]
            )

        self.assertEqual([t.name for t in cm.exception.failed_targets], ["foo_test"])

    def _execute_junit_runner(
        self, list_of_filename_content_tuples, create_some_resources=True, target_name=None
    ):
        # Create the temporary base test directory
        test_rel_path = "tests/java/org/pantsbuild/foo"

        # Create the temporary classes directory under work dir
        test_classes_abs_path = self.create_workdir_dir(test_rel_path)

        test_java_file_abs_paths = []
        # Generate the temporary java test source code.
        for filename, content in list_of_filename_content_tuples:
            test_java_file_rel_path = os.path.join(test_rel_path, filename)
            test_java_file_abs_path = self.create_file(test_java_file_rel_path, content)
            test_java_file_abs_paths.append(test_java_file_abs_path)

        distribution = DistributionLocator.cached(jdk=True)
        executor = SubprocessExecutor(distribution=distribution)
        with temporary_file() as f:
            res = executor.execute(
                classpath=[global_subsystem_instance(CoursierSubsystem).select(self.context())],
                main="coursier.cli.Coursier",
                args=[
                    "fetch",
                    "junit:junit:4.12",
                    "-r",
                    # This is needed to get around the maven blacklisting RBE.
                    "https://maven-central.storage-download.googleapis.com/maven2",
                ],
                stdout=f,
            )
            self.assertEqual(0, res, "Coursier resolve failed.")
            f.seek(0)
            classpath = ":".join(f.read().decode().split())

        # Now directly invoke javac to compile the test java code into classfiles that we can later
        # inject into a product mapping for JUnitRun to execute against.
        javac = distribution.binary("javac")
        subprocess.check_call(
            [javac, "-d", test_classes_abs_path, "-cp", classpath] + test_java_file_abs_paths
        )

        # If a target_name is specified create a target with it, otherwise create a junit_tests target.
        if target_name:
            target = self.target(target_name)
        else:
            target = self.create_library(
                path=test_rel_path,
                target_type="junit_tests",
                name="foo_test",
                sources=["FooTest.java"],
            )

        target_roots = []
        if create_some_resources:
            # Create a synthetic resource target.
            target_roots.append(self.make_target("some_resources", Resources))
        target_roots.append(target)

        # Set the context with the two targets, one junit_tests target and
        # one synthetic resources target.
        # The synthetic resources target is to make sure we won't regress
        # in the future with bug like https://github.com/pantsbuild/pants/issues/508. Note
        # in that bug, the resources target must be the first one in the list.
        context = self.context(target_roots=target_roots)

        # Before we run the task, we need to inject the "runtime_classpath" with
        # the compiled test java classes that JUnitRun will know which test
        # classes to execute. In a normal run, this "runtime_classpath" will be
        # populated by java compilation step.
        self.populate_runtime_classpath(context=context, classpath=[test_classes_abs_path])

        # Finally execute the task.
        self.execute(context)

    @ensure_cached(JUnitRun, expected_num_artifacts=0)
    def test_junit_runner_raises_no_error_on_non_junit_target(self):
        """Run pants against a `python_tests` target, but set an option for the `test.junit` task.

        This should execute without error.
        """
        self.add_to_build_file(
            "foo",
            dedent(
                """
                python_tests(
                  name='hello',
                  sources=[],
                )
                """
            ),
        )
        self.set_options(test="#abc")
        self.execute(self.context(target_roots=[self.target("foo:hello")]))

    @ensure_cached(JUnitRun, expected_num_artifacts=0)
    def test_empty_sources(self):
        self.add_to_build_file(
            "foo",
            dedent(
                """
                junit_tests(
                  name='empty',
                  sources=[],
                )
                """
            ),
        )
        task = self.prepare_execute(self.context(target_roots=[self.target("foo:empty")]))
        with self.assertRaisesRegex(
            TargetDefinitionException, r"must include a non-empty set of sources"
        ):
            task.execute()

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_allow_empty_sources(self):
        self.add_to_build_file(
            "foo",
            dedent(
                """
                junit_tests(
                  name='empty',
                  sources=[],
                )
                """
            ),
        )
        self.set_options(allow_empty_sources=True)
        context = self.context(target_roots=[self.target("foo:empty")])
        self.populate_runtime_classpath(context=context)
        self.execute(context)

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_junit_runner_extra_jvm_options(self):
        self.make_target(
            spec="tests/java/org/pantsbuild/foo:foo_test",
            target_type=JUnitTests,
            sources=["FooTest.java"],
            extra_jvm_options=["-Dexample.property=1"],
        )
        self._execute_junit_runner(
            [
                (
                    "FooTest.java",
                    dedent(
                        """
                        package org.pantsbuild.foo;
                        import org.junit.Test;
                        import static org.junit.Assert.assertTrue;
                        public class FooTest {
                          @Test
                          public void testFoo() {
                            String exampleProperty = System.getProperty("example.property");
                            assertTrue(exampleProperty != null && exampleProperty.equals("1"));
                          }
                        }
                        """
                    ),
                )
            ],
            target_name="tests/java/org/pantsbuild/foo:foo_test",
        )

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_junit_runner_runtime_platform_jvm_options(self):
        self.make_target(
            spec="tests/java/org/pantsbuild/foo:foo_test",
            target_type=JUnitTests,
            sources=["FooTest.java"],
            runtime_platform="java8-extra",
        )
        self.set_options_for_scope(
            JvmPlatform.options_scope,
            default_platform="java8",
            platforms={
                "java8": {"source": "8", "target": "8"},
                "java8-extra": {
                    "source": "8",
                    "target": "8",
                    "jvm_options": ["-Dexample.property=1"],
                },
            },
        )
        self._execute_junit_runner(
            [
                (
                    "FooTest.java",
                    dedent(
                        """
                        package org.pantsbuild.foo;
                        import org.junit.Test;
                        import static org.junit.Assert.assertTrue;
                        public class FooTest {
                          @Test
                          public void testFoo() {
                            String exampleProperty = System.getProperty("example.property");
                            assertTrue(exampleProperty != null && exampleProperty.equals("1"));
                          }
                        }
                        """
                    ),
                )
            ],
            target_name="tests/java/org/pantsbuild/foo:foo_test",
        )

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_junit_runner_multiple_extra_jvm_options(self):
        self.make_target(
            spec="tests/java/org/pantsbuild/foo:foo_test",
            target_type=JUnitTests,
            sources=["FooTest.java"],
            extra_jvm_options=["-Dexample.property1=1", "-Dexample.property2=2"],
        )
        self._execute_junit_runner(
            [
                (
                    "FooTest.java",
                    dedent(
                        """
                        package org.pantsbuild.foo;
                        import org.junit.Test;
                        import static org.junit.Assert.assertTrue;
                        public class FooTest {
                          @Test
                          public void testFoo() {
                            String exampleProperty1 = System.getProperty("example.property1");
                            assertTrue(exampleProperty1 != null && exampleProperty1.equals("1"));
                            String exampleProperty2 = System.getProperty("example.property2");
                            assertTrue(exampleProperty2 != null && exampleProperty2.equals("2"));
                            String exampleProperty3 = System.getProperty("example.property3");
                            assertTrue(exampleProperty3 == null);
                          }
                        }
                        """
                    ),
                )
            ],
            target_name="tests/java/org/pantsbuild/foo:foo_test",
        )

    # 2 runs with different targets (unique configurations), should cache twice.
    @ensure_cached(JUnitRun, expected_num_artifacts=2)
    def test_junit_runner_extra_env_vars(self):
        self.make_target(
            spec="tests/java/org/pantsbuild/foo:foo_test",
            target_type=JUnitTests,
            sources=["FooTest.java"],
            extra_env_vars={"HELLO": 27, "THERE": 32},
        )

        self.make_target(
            spec="tests/java/org/pantsbuild/foo:bar_test",
            target_type=JUnitTests,
            sources=["FooTest.java"],
            extra_env_vars={"THE_ANSWER": 42, "HELLO": 12},
        )

        self._execute_junit_runner(
            [
                (
                    "FooTest.java",
                    dedent(
                        """
                        package org.pantsbuild.foo;
                        import org.junit.Test;
                        import static org.junit.Assert.assertEquals;
                        public class FooTest {
                          @Test
                          public void testFoo() {
                            assertEquals("27", System.getenv().get("HELLO"));
                            assertEquals("32", System.getenv().get("THERE"));
                          }
                        }
                        """
                    ),
                )
            ],
            target_name="tests/java/org/pantsbuild/foo:foo_test",
        )

        # Execute twice in a row to make sure the environment changes aren't sticky.
        self._execute_junit_runner(
            [
                (
                    "FooTest.java",
                    dedent(
                        """
                        package org.pantsbuild.foo;
                        import org.junit.Test;
                        import static org.junit.Assert.assertEquals;
                        import static org.junit.Assert.assertFalse;
                        public class FooTest {
                          @Test
                          public void testFoo() {
                            assertEquals("12", System.getenv().get("HELLO"));
                            assertEquals("42", System.getenv().get("THE_ANSWER"));
                            assertFalse(System.getenv().containsKey("THERE"));
                          }
                        }
                        """
                    ),
                )
            ],
            target_name="tests/java/org/pantsbuild/foo:bar_test",
            create_some_resources=False,
        )

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_junit_runner_extra_env_vars_none(self):
        with environment_as(THIS_VARIABLE="12", THAT_VARIABLE="This is a variable."):
            self.make_target(
                spec="tests/java/org/pantsbuild/foo:foo_test",
                target_type=JUnitTests,
                sources=["FooTest.java"],
                extra_env_vars={"HELLO": None, "THERE": False, "THIS_VARIABLE": None},
            )

            self._execute_junit_runner(
                [
                    (
                        "FooTest.java",
                        dedent(
                            """
                            package org.pantsbuild.foo;
                            import org.junit.Test;
                            import static org.junit.Assert.assertEquals;
                            import static org.junit.Assert.assertFalse;
                            public class FooTest {
                              @Test
                              public void testFoo() {
                                assertEquals("False", System.getenv().get("THERE"));
                                assertEquals("This is a variable.", System.getenv().get("THAT_VARIABLE"));
                                assertFalse(System.getenv().containsKey("HELLO"));
                                assertFalse(System.getenv().containsKey("THIS_VARIABLE"));
                              }
                            }
                            """
                        ),
                    )
                ],
                target_name="tests/java/org/pantsbuild/foo:foo_test",
            )

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_junt_run_with_too_many_args(self):
        max_subprocess_args = 2
        num_of_classes = 5

        list_of_filename_content_tuples = []
        for n in range(num_of_classes):
            filename = f"FooTest{n}.java"

            content = dedent(
                """
                package org.pantsbuild.foo;
                import org.junit.Test;
                import static org.junit.Assert.assertTrue;
                public class FooTest{}{{
                @Test
                  public void testFoo() {{
                    int x = 5;
                  }}
                }}""".format(
                    n
                )
            )
            list_of_filename_content_tuples.append((filename, content))

        self.make_target(
            spec="tests/java/org/pantsbuild/foo:foo_test",
            target_type=JUnitTests,
            sources=[name for name, _ in list_of_filename_content_tuples],
        )
        self.set_options(max_subprocess_args=max_subprocess_args)

        self._execute_junit_runner(
            list_of_filename_content_tuples, target_name="tests/java/org/pantsbuild/foo:foo_test"
        )

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_junit_run_chroot(self):
        self.create_files("config/org/pantsbuild/foo", ["sentinel", "another"])
        files = self.make_target(
            spec="config/org/pantsbuild/foo:sentinel", target_type=Files, sources=["sentinel"]
        )
        self.make_target(
            spec="tests/java/org/pantsbuild/foo:foo_test",
            target_type=JUnitTests,
            sources=["FooTest.java"],
            dependencies=[files],
        )
        content = dedent(
            """
            package org.pantsbuild.foo;
            import java.io.File;
            import org.junit.Test;
            import static org.junit.Assert.assertFalse;
            import static org.junit.Assert.assertTrue;
            public class FooTest {
              @Test
              public void testFoo() {
                assertTrue(new File("config/org/pantsbuild/foo/sentinel").exists());
                assertFalse(new File("config/org/pantsbuild/foo/another").exists());
              }
            }
            """
        )
        self.set_options(chroot=True)
        self._execute_junit_runner(
            [("FooTest.java", content)], target_name="tests/java/org/pantsbuild/foo:foo_test"
        )

    @ensure_cached(JUnitRun, expected_num_artifacts=0)
    def test_junit_run_chroot_cwd_mutex(self):
        with temporary_dir() as chroot:
            self.set_options(chroot=True, cwd=chroot)
            with self.assertRaises(JUnitRun.OptionError):
                self.execute(self.context())

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_junit_run_target_cwd_trumps_chroot(self):
        with temporary_dir() as target_cwd:
            self.create_files("config/org/pantsbuild/foo", ["files_dep_sentinel"])
            files = self.make_target(
                spec="config/org/pantsbuild/foo:sentinel",
                target_type=Files,
                sources=["files_dep_sentinel"],
            )
            self.make_target(
                spec="tests/java/org/pantsbuild/foo:foo_test",
                target_type=JUnitTests,
                sources=["FooTest.java"],
                dependencies=[files],
                cwd=target_cwd,
            )
            content = dedent(
                """
                package org.pantsbuild.foo;
                import java.io.File;
                import org.junit.Test;
                import static org.junit.Assert.assertFalse;
                import static org.junit.Assert.assertTrue;
                public class FooTest {{
                  @Test
                  public void testFoo() {{
                    assertTrue(new File("target_cwd_sentinel").exists());

                    // We declare a Files dependency on this file, but since we run in a CWD not in a
                    // chroot and not in the build root, we can't find it at the expected relative path.
                    assertFalse(new File("config/org/pantsbuild/foo/files_dep_sentinel").exists());

                    // As a sanity check, it is at the expected absolute path though.
                    File buildRoot = new File("{}");
                    assertTrue(new File(buildRoot,
                                        "config/org/pantsbuild/foo/files_dep_sentinel").exists());
                  }}
                }}
                """.format(
                    self.build_root
                )
            )
            touch(os.path.join(target_cwd, "target_cwd_sentinel"))
            self.set_options(chroot=True)
            self._execute_junit_runner(
                [("FooTest.java", content)], target_name="tests/java/org/pantsbuild/foo:foo_test"
            )

    @ensure_cached(JUnitRun, expected_num_artifacts=1)
    def test_junit_run_target_cwd_trumps_cwd_option(self):
        with temporary_dir() as target_cwd:
            self.make_target(
                spec="tests/java/org/pantsbuild/foo:foo_test",
                target_type=JUnitTests,
                sources=["FooTest.java"],
                cwd=target_cwd,
            )
            content = dedent(
                """
                package org.pantsbuild.foo;
                import java.io.File;
                import org.junit.Test;
                import static org.junit.Assert.assertFalse;
                import static org.junit.Assert.assertTrue;
                public class FooTest {
                  @Test
                  public void testFoo() {
                    assertTrue(new File("target_cwd_sentinel").exists());
                    assertFalse(new File("option_cwd_sentinel").exists());
                  }
                }
              """
            )
            touch(os.path.join(target_cwd, "target_cwd_sentinel"))
            with temporary_dir() as option_cwd:
                touch(os.path.join(option_cwd, "option_cwd_sentinel"))
                # TODO(8989): Don't require chroot=False
                self.set_options(cwd=option_cwd, chroot=False)
                self._execute_junit_runner(
                    [("FooTest.java", content)],
                    target_name="tests/java/org/pantsbuild/foo:foo_test",
                )

    def test_junit_run_with_coverage_caching(self):
        source_under_test_content = dedent(
            """
            package org.pantsbuild.foo;
            class Foo {
              static String foo() {
                return "foo";
              }
              static String bar() {
                return "bar";
              }
            }
            """
        )
        source_under_test = self.make_target(
            spec="tests/java/org/pantsbuild/foo", target_type=JavaLibrary, sources=["Foo.java"]
        )

        test_content = dedent(
            """
            package org.pantsbuild.foo;
            import org.pantsbuild.foo.Foo;
            import org.junit.Test;
            import static org.junit.Assert.assertEquals;
            public class FooTest {
              @Test
              public void testFoo() {
                assertEquals("foo", Foo.foo());
              }
            }
            """
        )
        self.make_target(
            spec="tests/java/org/pantsbuild/foo:foo_test",
            target_type=JUnitTests,
            sources=["FooTest.java"],
            dependencies=[source_under_test],
        )

        self.set_options(coverage=True)

        with self.cache_check(expected_num_artifacts=1):
            self._execute_junit_runner(
                [("Foo.java", source_under_test_content), ("FooTest.java", test_content)],
                target_name="tests/java/org/pantsbuild/foo:foo_test",
            )

        # Now re-execute with a partial invalidation of the input targets. Since coverage is enabled,
        # that input set is {tests/java/org/pantsbuild/foo, tests/java/org/pantsbuild/foo:bar_test}
        # with only tests/java/org/pantsbuild/foo:bar_test invalidated. Even though the invalidation is
        # partial over all input targets, it is total over all the test targets in the input and so the
        # successful result run is eligible for caching.
        test_content_edited = dedent(
            """
            package org.pantsbuild.foo;
            import org.pantsbuild.foo.Foo;
            import org.junit.Test;
            import static org.junit.Assert.assertEquals;
            public class FooTest {
              @Test
              public void testFoo() {
                assertEquals("bar", Foo.bar());
              }
            }
            """
        )
        self.make_target(
            spec="tests/java/org/pantsbuild/foo:bar_test",
            target_type=JUnitTests,
            sources=["FooTest.java"],
            dependencies=[source_under_test],
        )

        with self.cache_check(expected_num_artifacts=1):
            self._execute_junit_runner(
                [("Foo.java", source_under_test_content), ("FooTest.java", test_content_edited)],
                target_name="tests/java/org/pantsbuild/foo:bar_test",
                create_some_resources=False,
            )

    @contextmanager
    def _coverage_engine(self):
        junit_run = self.prepare_execute(self.context())
        with temporary_dir() as output_dir:
            code_coverage = CodeCoverage.global_instance()
            source_under_test = self.make_target(
                spec="tests/java/org/pantsbuild/foo", target_type=JavaLibrary, sources=["Foo.java"]
            )
            yield code_coverage.get_coverage_engine(
                task=junit_run,
                output_dir=output_dir,
                all_targets=[source_under_test],
                execute_java=junit_run.execute_java_for_coverage,
            )

    def _assert_coverage_engine(self, expected_engine_type):
        with self._coverage_engine() as engine:
            self.assertIsInstance(engine, expected_engine_type)

    def test_coverage_default_off(self):
        self._assert_coverage_engine(NoCoverage)

    def test_coverage_explicit_on(self):
        self.set_options(coverage=True)
        self._assert_coverage_engine(Cobertura)

    def test_coverage_open_implicit_on(self):
        self.set_options(coverage_open=True)
        self._assert_coverage_engine(Cobertura)

    def test_coverage_processor_implicit_on(self):
        self.set_options(coverage_processor="jacoco")
        self._assert_coverage_engine(Jacoco)

    def test_coverage_processor_invalid(self):
        self.set_options(coverage_processor="bob")
        with self.assertRaises(CodeCoverage.InvalidCoverageEngine):
            with self._coverage_engine():
                self.fail("We should never get here.")
