import os
import sys
import shutil
import signal
import tarfile
import importlib
import contextlib
from concurrent import futures
import re

import pytest
from jaraco import path

from .textwrap import DALS


TIMEOUT = int(os.getenv("TIMEOUT_BACKEND_TEST", "180"))  # in seconds
IS_PYPY = '__pypy__' in sys.builtin_module_names


pytestmark = pytest.mark.skipif(
    sys.platform == "win32" and IS_PYPY,
    reason="The combination of PyPy + Windows + pytest-xdist + ProcessPoolExecutor "
    "is flaky and problematic"
)


class BuildBackendBase:
    def __init__(self, cwd='.', env={}, backend_name='setuptools.build_meta'):
        self.cwd = cwd
        self.env = env
        self.backend_name = backend_name


class BuildBackend(BuildBackendBase):
    """PEP 517 Build Backend"""

    def __init__(self, *args, **kwargs):
        super(BuildBackend, self).__init__(*args, **kwargs)
        self.pool = futures.ProcessPoolExecutor(max_workers=1)

    def __getattr__(self, name):
        """Handles aribrary function invocations on the build backend."""

        def method(*args, **kw):
            root = os.path.abspath(self.cwd)
            caller = BuildBackendCaller(root, self.env, self.backend_name)
            pid = None
            try:
                pid = self.pool.submit(os.getpid).result(TIMEOUT)
                return self.pool.submit(caller, name, *args, **kw).result(TIMEOUT)
            except futures.TimeoutError:
                self.pool.shutdown(wait=False)  # doesn't stop already running processes
                self._kill(pid)
                pytest.xfail(f"Backend did not respond before timeout ({TIMEOUT} s)")
            except (futures.process.BrokenProcessPool, MemoryError, OSError):
                if IS_PYPY:
                    pytest.xfail("PyPy frequently fails tests with ProcessPoolExector")
                raise

        return method

    def _kill(self, pid):
        if pid is None:
            return
        with contextlib.suppress(ProcessLookupError, OSError):
            os.kill(pid, signal.SIGTERM if os.name == "nt" else signal.SIGKILL)


class BuildBackendCaller(BuildBackendBase):
    def __init__(self, *args, **kwargs):
        super(BuildBackendCaller, self).__init__(*args, **kwargs)

        (self.backend_name, _,
         self.backend_obj) = self.backend_name.partition(':')

    def __call__(self, name, *args, **kw):
        """Handles aribrary function invocations on the build backend."""
        os.chdir(self.cwd)
        os.environ.update(self.env)
        mod = importlib.import_module(self.backend_name)

        if self.backend_obj:
            backend = getattr(mod, self.backend_obj)
        else:
            backend = mod

        return getattr(backend, name)(*args, **kw)


defns = [
    {
        'setup.py': DALS("""
            __import__('setuptools').setup(
                name='foo',
                version='0.0.0',
                py_modules=['hello'],
                setup_requires=['six'],
            )
            """),
        'hello.py': DALS("""
            def run():
                print('hello')
            """),
    },
    {
        'setup.py': DALS("""
            assert __name__ == '__main__'
            __import__('setuptools').setup(
                name='foo',
                version='0.0.0',
                py_modules=['hello'],
                setup_requires=['six'],
            )
            """),
        'hello.py': DALS("""
            def run():
                print('hello')
            """),
    },
    {
        'setup.py': DALS("""
            variable = True
            def function():
                return variable
            assert variable
            __import__('setuptools').setup(
                name='foo',
                version='0.0.0',
                py_modules=['hello'],
                setup_requires=['six'],
            )
            """),
        'hello.py': DALS("""
            def run():
                print('hello')
            """),
    },
    {
        'setup.cfg': DALS("""
        [metadata]
        name = foo
        version = 0.0.0

        [options]
        py_modules=hello
        setup_requires=six
        """),
        'hello.py': DALS("""
        def run():
            print('hello')
        """)
    },
]


class TestBuildMetaBackend:
    backend_name = 'setuptools.build_meta'

    def get_build_backend(self):
        return BuildBackend(backend_name=self.backend_name)

    @pytest.fixture(params=defns)
    def build_backend(self, tmpdir, request):
        path.build(request.param, prefix=str(tmpdir))
        with tmpdir.as_cwd():
            yield self.get_build_backend()

    def test_get_requires_for_build_wheel(self, build_backend):
        actual = build_backend.get_requires_for_build_wheel()
        expected = ['six', 'wheel']
        assert sorted(actual) == sorted(expected)

    def test_get_requires_for_build_sdist(self, build_backend):
        actual = build_backend.get_requires_for_build_sdist()
        expected = ['six']
        assert sorted(actual) == sorted(expected)

    def test_build_wheel(self, build_backend):
        dist_dir = os.path.abspath('pip-wheel')
        os.makedirs(dist_dir)
        wheel_name = build_backend.build_wheel(dist_dir)

        assert os.path.isfile(os.path.join(dist_dir, wheel_name))

    @pytest.mark.parametrize('build_type', ('wheel', 'sdist'))
    def test_build_with_existing_file_present(self, build_type, tmpdir_cwd):
        # Building a sdist/wheel should still succeed if there's
        # already a sdist/wheel in the destination directory.
        files = {
            'setup.py': "from setuptools import setup\nsetup()",
            'VERSION': "0.0.1",
            'setup.cfg': DALS("""
                [metadata]
                name = foo
                version = file: VERSION
            """),
            'pyproject.toml': DALS("""
                [build-system]
                requires = ["setuptools", "wheel"]
                build-backend = "setuptools.build_meta"
            """),
        }

        path.build(files)

        dist_dir = os.path.abspath('preexisting-' + build_type)

        build_backend = self.get_build_backend()
        build_method = getattr(build_backend, 'build_' + build_type)

        # Build a first sdist/wheel.
        # Note: this also check the destination directory is
        # successfully created if it does not exist already.
        first_result = build_method(dist_dir)

        # Change version.
        with open("VERSION", "wt") as version_file:
            version_file.write("0.0.2")

        # Build a *second* sdist/wheel.
        second_result = build_method(dist_dir)

        assert os.path.isfile(os.path.join(dist_dir, first_result))
        assert first_result != second_result

        # And if rebuilding the exact same sdist/wheel?
        open(os.path.join(dist_dir, second_result), 'w').close()
        third_result = build_method(dist_dir)
        assert third_result == second_result
        assert os.path.getsize(os.path.join(dist_dir, third_result)) > 0

    def test_build_sdist(self, build_backend):
        dist_dir = os.path.abspath('pip-sdist')
        os.makedirs(dist_dir)
        sdist_name = build_backend.build_sdist(dist_dir)

        assert os.path.isfile(os.path.join(dist_dir, sdist_name))

    def test_prepare_metadata_for_build_wheel(self, build_backend):
        dist_dir = os.path.abspath('pip-dist-info')
        os.makedirs(dist_dir)

        dist_info = build_backend.prepare_metadata_for_build_wheel(dist_dir)

        assert os.path.isfile(os.path.join(dist_dir, dist_info, 'METADATA'))

    def test_build_sdist_explicit_dist(self, build_backend):
        # explicitly specifying the dist folder should work
        # the folder sdist_directory and the ``--dist-dir`` can be the same
        dist_dir = os.path.abspath('dist')
        sdist_name = build_backend.build_sdist(dist_dir)
        assert os.path.isfile(os.path.join(dist_dir, sdist_name))

    def test_build_sdist_version_change(self, build_backend):
        sdist_into_directory = os.path.abspath("out_sdist")
        os.makedirs(sdist_into_directory)

        sdist_name = build_backend.build_sdist(sdist_into_directory)
        assert os.path.isfile(os.path.join(sdist_into_directory, sdist_name))

        # if the setup.py changes subsequent call of the build meta
        # should still succeed, given the
        # sdist_directory the frontend specifies is empty
        setup_loc = os.path.abspath("setup.py")
        if not os.path.exists(setup_loc):
            setup_loc = os.path.abspath("setup.cfg")

        with open(setup_loc, 'rt') as file_handler:
            content = file_handler.read()
        with open(setup_loc, 'wt') as file_handler:
            file_handler.write(
                content.replace("version='0.0.0'", "version='0.0.1'"))

        shutil.rmtree(sdist_into_directory)
        os.makedirs(sdist_into_directory)

        sdist_name = build_backend.build_sdist("out_sdist")
        assert os.path.isfile(
            os.path.join(os.path.abspath("out_sdist"), sdist_name))

    def test_build_sdist_pyproject_toml_exists(self, tmpdir_cwd):
        files = {
            'setup.py': DALS("""
                __import__('setuptools').setup(
                    name='foo',
                    version='0.0.0',
                    py_modules=['hello']
                )"""),
            'hello.py': '',
            'pyproject.toml': DALS("""
                [build-system]
                requires = ["setuptools", "wheel"]
                build-backend = "setuptools.build_meta"
                """),
        }
        path.build(files)
        build_backend = self.get_build_backend()
        targz_path = build_backend.build_sdist("temp")
        with tarfile.open(os.path.join("temp", targz_path)) as tar:
            assert any('pyproject.toml' in name for name in tar.getnames())

    def test_build_sdist_setup_py_exists(self, tmpdir_cwd):
        # If build_sdist is called from a script other than setup.py,
        # ensure setup.py is included
        path.build(defns[0])

        build_backend = self.get_build_backend()
        targz_path = build_backend.build_sdist("temp")
        with tarfile.open(os.path.join("temp", targz_path)) as tar:
            assert any('setup.py' in name for name in tar.getnames())

    def test_build_sdist_setup_py_manifest_excluded(self, tmpdir_cwd):
        # Ensure that MANIFEST.in can exclude setup.py
        files = {
            'setup.py': DALS("""
        __import__('setuptools').setup(
            name='foo',
            version='0.0.0',
            py_modules=['hello']
        )"""),
            'hello.py': '',
            'MANIFEST.in': DALS("""
        exclude setup.py
        """)
        }

        path.build(files)

        build_backend = self.get_build_backend()
        targz_path = build_backend.build_sdist("temp")
        with tarfile.open(os.path.join("temp", targz_path)) as tar:
            assert not any('setup.py' in name for name in tar.getnames())

    def test_build_sdist_builds_targz_even_if_zip_indicated(self, tmpdir_cwd):
        files = {
            'setup.py': DALS("""
                __import__('setuptools').setup(
                    name='foo',
                    version='0.0.0',
                    py_modules=['hello']
                )"""),
            'hello.py': '',
            'setup.cfg': DALS("""
                [sdist]
                formats=zip
                """)
        }

        path.build(files)

        build_backend = self.get_build_backend()
        build_backend.build_sdist("temp")

    _relative_path_import_files = {
        'setup.py': DALS("""
            __import__('setuptools').setup(
                name='foo',
                version=__import__('hello').__version__,
                py_modules=['hello']
            )"""),
        'hello.py': '__version__ = "0.0.0"',
        'setup.cfg': DALS("""
            [sdist]
            formats=zip
            """)
    }

    def test_build_sdist_relative_path_import(self, tmpdir_cwd):
        path.build(self._relative_path_import_files)
        build_backend = self.get_build_backend()
        with pytest.raises(ImportError, match="^No module named 'hello'$"):
            build_backend.build_sdist("temp")

    @pytest.mark.parametrize('setup_literal, requirements', [
        ("'foo'", ['foo']),
        ("['foo']", ['foo']),
        (r"'foo\n'", ['foo']),
        (r"'foo\n\n'", ['foo']),
        ("['foo', 'bar']", ['foo', 'bar']),
        (r"'# Has a comment line\nfoo'", ['foo']),
        (r"'foo # Has an inline comment'", ['foo']),
        (r"'foo \\\n >=3.0'", ['foo>=3.0']),
        (r"'foo\nbar'", ['foo', 'bar']),
        (r"'foo\nbar\n'", ['foo', 'bar']),
        (r"['foo\n', 'bar\n']", ['foo', 'bar']),
    ])
    @pytest.mark.parametrize('use_wheel', [True, False])
    def test_setup_requires(self, setup_literal, requirements, use_wheel,
                            tmpdir_cwd):

        files = {
            'setup.py': DALS("""
                from setuptools import setup

                setup(
                    name="qux",
                    version="0.0.0",
                    py_modules=["hello"],
                    setup_requires={setup_literal},
                )
            """).format(setup_literal=setup_literal),
            'hello.py': DALS("""
            def run():
                print('hello')
            """),
        }

        path.build(files)

        build_backend = self.get_build_backend()

        if use_wheel:
            base_requirements = ['wheel']
            get_requires = build_backend.get_requires_for_build_wheel
        else:
            base_requirements = []
            get_requires = build_backend.get_requires_for_build_sdist

        # Ensure that the build requirements are properly parsed
        expected = sorted(base_requirements + requirements)
        actual = get_requires()

        assert expected == sorted(actual)

    def test_dont_install_setup_requires(self, tmpdir_cwd):
        files = {
            'setup.py': DALS("""
                        from setuptools import setup

                        setup(
                            name="qux",
                            version="0.0.0",
                            py_modules=["hello"],
                            setup_requires=["does-not-exist >99"],
                        )
                    """),
            'hello.py': DALS("""
                    def run():
                        print('hello')
                    """),
        }

        path.build(files)

        build_backend = self.get_build_backend()

        dist_dir = os.path.abspath('pip-dist-info')
        os.makedirs(dist_dir)

        # does-not-exist can't be satisfied, so if it attempts to install
        # setup_requires, it will fail.
        build_backend.prepare_metadata_for_build_wheel(dist_dir)

    _sys_argv_0_passthrough = {
        'setup.py': DALS("""
            import os
            import sys

            __import__('setuptools').setup(
                name='foo',
                version='0.0.0',
            )

            sys_argv = os.path.abspath(sys.argv[0])
            file_path = os.path.abspath('setup.py')
            assert sys_argv == file_path
            """)
    }

    def test_sys_argv_passthrough(self, tmpdir_cwd):
        path.build(self._sys_argv_0_passthrough)
        build_backend = self.get_build_backend()
        with pytest.raises(AssertionError):
            build_backend.build_sdist("temp")

    @pytest.mark.parametrize('build_hook', ('build_sdist', 'build_wheel'))
    def test_build_with_empty_setuppy(self, build_backend, build_hook):
        files = {'setup.py': ''}
        path.build(files)

        with pytest.raises(
                ValueError,
                match=re.escape('No distribution was found.')):
            getattr(build_backend, build_hook)("temp")


class TestBuildMetaLegacyBackend(TestBuildMetaBackend):
    backend_name = 'setuptools.build_meta:__legacy__'

    # build_meta_legacy-specific tests
    def test_build_sdist_relative_path_import(self, tmpdir_cwd):
        # This must fail in build_meta, but must pass in build_meta_legacy
        path.build(self._relative_path_import_files)

        build_backend = self.get_build_backend()
        build_backend.build_sdist("temp")

    def test_sys_argv_passthrough(self, tmpdir_cwd):
        path.build(self._sys_argv_0_passthrough)

        build_backend = self.get_build_backend()
        build_backend.build_sdist("temp")
