from __future__ import absolute_import, unicode_literals

import copy
import itertools
import json
import logging
import os
import sys
import sysconfig
from collections import namedtuple
from textwrap import dedent

import pytest

from virtualenv.discovery import cached_py_info
from virtualenv.discovery.py_info import PythonInfo, VersionInfo
from virtualenv.discovery.py_spec import PythonSpec
from virtualenv.info import IS_PYPY, fs_supports_symlink
from virtualenv.util.path import Path

CURRENT = PythonInfo.current_system()


def test_current_as_json():
    result = CURRENT._to_json()
    parsed = json.loads(result)
    a, b, c, d, e = sys.version_info
    assert parsed["version_info"] == {"major": a, "minor": b, "micro": c, "releaselevel": d, "serial": e}


def test_bad_exe_py_info_raise(tmp_path, session_app_data):
    exe = str(tmp_path)
    with pytest.raises(RuntimeError) as context:
        PythonInfo.from_exe(exe, session_app_data)
    msg = str(context.value)
    assert "code" in msg
    assert exe in msg


def test_bad_exe_py_info_no_raise(tmp_path, caplog, capsys, session_app_data):
    caplog.set_level(logging.NOTSET)
    exe = str(tmp_path)
    result = PythonInfo.from_exe(exe, session_app_data, raise_on_error=False)
    assert result is None
    out, _ = capsys.readouterr()
    assert not out
    messages = [r.message for r in caplog.records if r.name != "filelock"]
    assert len(messages) == 2
    msg = messages[0]
    assert "get interpreter info via cmd: " in msg
    msg = messages[1]
    assert str(exe) in msg
    assert "code" in msg


@pytest.mark.parametrize(
    "spec",
    itertools.chain(
        [sys.executable],
        list(
            "{}{}{}".format(impl, ".".join(str(i) for i in ver), arch)
            for impl, ver, arch in itertools.product(
                (
                    [CURRENT.implementation]
                    + (["python"] if CURRENT.implementation == "CPython" else [])
                    + (
                        [CURRENT.implementation.lower()]
                        if CURRENT.implementation != CURRENT.implementation.lower()
                        else []
                    )
                ),
                [sys.version_info[0 : i + 1] for i in range(3)],
                ["", "-{}".format(CURRENT.architecture)],
            )
        ),
    ),
)
def test_satisfy_py_info(spec):
    parsed_spec = PythonSpec.from_string_spec(spec)
    matches = CURRENT.satisfies(parsed_spec, True)
    assert matches is True


def test_satisfy_not_arch():
    parsed_spec = PythonSpec.from_string_spec(
        "{}-{}".format(CURRENT.implementation, 64 if CURRENT.architecture == 32 else 32),
    )
    matches = CURRENT.satisfies(parsed_spec, True)
    assert matches is False


def _generate_not_match_current_interpreter_version():
    result = []
    for i in range(3):
        ver = sys.version_info[0 : i + 1]
        for a in range(len(ver)):
            for o in [-1, 1]:
                temp = list(ver)
                temp[a] += o
                result.append(".".join(str(i) for i in temp))
    return result


_NON_MATCH_VER = _generate_not_match_current_interpreter_version()


@pytest.mark.parametrize("spec", _NON_MATCH_VER)
def test_satisfy_not_version(spec):
    parsed_spec = PythonSpec.from_string_spec("{}{}".format(CURRENT.implementation, spec))
    matches = CURRENT.satisfies(parsed_spec, True)
    assert matches is False


def test_py_info_cached_error(mocker, tmp_path, session_app_data):
    spy = mocker.spy(cached_py_info, "_run_subprocess")
    with pytest.raises(RuntimeError):
        PythonInfo.from_exe(str(tmp_path), session_app_data)
    with pytest.raises(RuntimeError):
        PythonInfo.from_exe(str(tmp_path), session_app_data)
    assert spy.call_count == 1


@pytest.mark.skipif(not fs_supports_symlink(), reason="symlink is not supported")
def test_py_info_cached_symlink_error(mocker, tmp_path, session_app_data):
    spy = mocker.spy(cached_py_info, "_run_subprocess")
    with pytest.raises(RuntimeError):
        PythonInfo.from_exe(str(tmp_path), session_app_data)
    symlinked = tmp_path / "a"
    symlinked.symlink_to(tmp_path)
    with pytest.raises(RuntimeError):
        PythonInfo.from_exe(str(symlinked), session_app_data)
    assert spy.call_count == 2


def test_py_info_cache_clear(mocker, tmp_path, session_app_data):
    spy = mocker.spy(cached_py_info, "_run_subprocess")
    result = PythonInfo.from_exe(sys.executable, session_app_data)
    assert result is not None
    count = 1 if result.executable == sys.executable else 2  # at least two, one for the venv, one more for the host
    assert spy.call_count >= count
    PythonInfo.clear_cache(session_app_data)
    assert PythonInfo.from_exe(sys.executable, session_app_data) is not None
    assert spy.call_count >= 2 * count


@pytest.mark.skipif(not fs_supports_symlink(), reason="symlink is not supported")
def test_py_info_cached_symlink(mocker, tmp_path, session_app_data):
    spy = mocker.spy(cached_py_info, "_run_subprocess")
    first_result = PythonInfo.from_exe(sys.executable, session_app_data)
    assert first_result is not None
    count = spy.call_count
    # at least two, one for the venv, one more for the host
    exp_count = 1 if first_result.executable == sys.executable else 2
    assert count >= exp_count  # at least two, one for the venv, one more for the host

    new_exe = tmp_path / "a"
    new_exe.symlink_to(sys.executable)
    pyvenv = Path(sys.executable).parents[1] / "pyvenv.cfg"
    if pyvenv.exists():
        (tmp_path / pyvenv.name).write_text(pyvenv.read_text())
    new_exe_str = str(new_exe)
    second_result = PythonInfo.from_exe(new_exe_str, session_app_data)
    assert second_result.executable == new_exe_str
    assert spy.call_count == count + 1  # no longer needed the host invocation, but the new symlink is must


PyInfoMock = namedtuple("PyInfoMock", ["implementation", "architecture", "version_info"])


@pytest.mark.parametrize(
    "target, position, discovered",
    [
        (
            PyInfoMock("CPython", 64, VersionInfo(3, 6, 8, "final", 0)),
            0,
            [
                PyInfoMock("CPython", 64, VersionInfo(3, 6, 9, "final", 0)),
                PyInfoMock("PyPy", 64, VersionInfo(3, 6, 8, "final", 0)),
            ],
        ),
        (
            PyInfoMock("CPython", 64, VersionInfo(3, 6, 8, "final", 0)),
            0,
            [
                PyInfoMock("CPython", 64, VersionInfo(3, 6, 9, "final", 0)),
                PyInfoMock("CPython", 32, VersionInfo(3, 6, 9, "final", 0)),
            ],
        ),
        (
            PyInfoMock("CPython", 64, VersionInfo(3, 8, 1, "final", 0)),
            0,
            [
                PyInfoMock("CPython", 32, VersionInfo(2, 7, 12, "rc", 2)),
                PyInfoMock("PyPy", 64, VersionInfo(3, 8, 1, "final", 0)),
            ],
        ),
    ],
)
def test_system_executable_no_exact_match(target, discovered, position, tmp_path, mocker, caplog, session_app_data):
    """Here we should fallback to other compatible"""
    caplog.set_level(logging.DEBUG)

    def _make_py_info(of):
        base = copy.deepcopy(CURRENT)
        base.implementation = of.implementation
        base.version_info = of.version_info
        base.architecture = of.architecture
        return base

    discovered_with_path = {}
    names = []
    selected = None
    for pos, i in enumerate(discovered):
        path = tmp_path / str(pos)
        path.write_text("")
        py_info = _make_py_info(i)
        py_info.system_executable = CURRENT.system_executable
        py_info.executable = CURRENT.system_executable
        py_info.base_executable = str(path)
        if pos == position:
            selected = py_info
        discovered_with_path[str(path)] = py_info
        names.append(path.name)

    target_py_info = _make_py_info(target)
    mocker.patch.object(target_py_info, "_find_possible_exe_names", return_value=names)
    mocker.patch.object(target_py_info, "_find_possible_folders", return_value=[str(tmp_path)])

    # noinspection PyUnusedLocal
    def func(k, app_data, resolve_to_host, raise_on_error, env):
        return discovered_with_path[k]

    mocker.patch.object(target_py_info, "from_exe", side_effect=func)
    target_py_info.real_prefix = str(tmp_path)

    target_py_info.system_executable = None
    target_py_info.executable = str(tmp_path)
    mapped = target_py_info._resolve_to_system(session_app_data, target_py_info)
    assert mapped.system_executable == CURRENT.system_executable
    found = discovered_with_path[mapped.base_executable]
    assert found is selected

    assert caplog.records[0].msg == "discover exe for %s in %s"
    for record in caplog.records[1:-1]:
        assert record.message.startswith("refused interpreter ")
        assert record.levelno == logging.DEBUG

    warn_similar = caplog.records[-1]
    assert warn_similar.levelno == logging.DEBUG
    assert warn_similar.msg.startswith("no exact match found, chosen most similar")


def test_py_info_ignores_distutils_config(monkeypatch, tmp_path):
    (tmp_path / "setup.cfg").write_text(
        dedent(
            """
            [install]
            prefix={0}{1}prefix
            install_purelib={0}{1}purelib
            install_platlib={0}{1}platlib
            install_headers={0}{1}headers
            install_scripts={0}{1}scripts
            install_data={0}{1}data
            """.format(
                tmp_path,
                os.sep,
            ),
        ),
    )
    monkeypatch.chdir(tmp_path)
    py_info = PythonInfo.from_exe(sys.executable)
    distutils = py_info.distutils_install
    for key, value in distutils.items():
        assert not value.startswith(str(tmp_path)), "{}={}".format(key, value)


def test_discover_exe_on_path_non_spec_name_match(mocker):
    suffixed_name = "python{}.{}m".format(CURRENT.version_info.major, CURRENT.version_info.minor)
    if sys.platform == "win32":
        suffixed_name += Path(CURRENT.original_executable).suffix
    spec = PythonSpec.from_string_spec(suffixed_name)
    mocker.patch.object(CURRENT, "original_executable", str(Path(CURRENT.executable).parent / suffixed_name))
    assert CURRENT.satisfies(spec, impl_must_match=True) is True


def test_discover_exe_on_path_non_spec_name_not_match(mocker):
    suffixed_name = "python{}.{}m".format(CURRENT.version_info.major, CURRENT.version_info.minor)
    if sys.platform == "win32":
        suffixed_name += Path(CURRENT.original_executable).suffix
    spec = PythonSpec.from_string_spec(suffixed_name)
    mocker.patch.object(
        CURRENT,
        "original_executable",
        str(Path(CURRENT.executable).parent / "e{}".format(suffixed_name)),
    )
    assert CURRENT.satisfies(spec, impl_must_match=True) is False


@pytest.mark.skipif(IS_PYPY, reason="setuptools distutil1s patching does not work")
def test_py_info_setuptools():
    from setuptools.dist import Distribution

    assert Distribution
    PythonInfo()


def test_py_info_to_system_raises(session_app_data, mocker, caplog, skip_if_test_in_system):
    caplog.set_level(logging.DEBUG)
    mocker.patch.object(PythonInfo, "_find_possible_folders", return_value=[])
    result = PythonInfo.from_exe(sys.executable, app_data=session_app_data, raise_on_error=False)
    assert result is None
    log = caplog.records[-1]
    assert log.levelno == logging.INFO
    expected = "ignore {} due cannot resolve system due to RuntimeError('failed to detect ".format(sys.executable)
    assert expected in log.message


def _stringify_schemes_dict(schemes_dict):
    """
    Since this file has from __future__ import unicode_literals, we manually cast all values of mocked install_schemes
    to str() as the original schemes are not unicode on Python 2.
    """
    return {str(n): {str(k): str(v) for k, v in s.items()} for n, s in schemes_dict.items()}


def test_custom_venv_install_scheme_is_prefered(mocker):
    # The paths in this test are Fedora paths, but we set them for nt as well, so the test also works on Windows,
    # despite the actual values are nonsense there.
    # Values were simplified to be compatible with all the supported Python versions.
    default_scheme = {
        "stdlib": "{base}/lib/python{py_version_short}",
        "platstdlib": "{platbase}/lib/python{py_version_short}",
        "purelib": "{base}/local/lib/python{py_version_short}/site-packages",
        "platlib": "{platbase}/local/lib/python{py_version_short}/site-packages",
        "include": "{base}/include/python{py_version_short}",
        "platinclude": "{platbase}/include/python{py_version_short}",
        "scripts": "{base}/local/bin",
        "data": "{base}/local",
    }
    venv_scheme = {key: path.replace("local", "") for key, path in default_scheme.items()}
    sysconfig_install_schemes = {
        "posix_prefix": default_scheme,
        "nt": default_scheme,
        "pypy": default_scheme,
        "pypy_nt": default_scheme,
        "venv": venv_scheme,
    }
    if getattr(sysconfig, "get_preferred_scheme", None):
        sysconfig_install_schemes[sysconfig.get_preferred_scheme("prefix")] = default_scheme

    if sys.version_info[0] == 2:
        sysconfig_install_schemes = _stringify_schemes_dict(sysconfig_install_schemes)

    # On Python < 3.10, the distutils schemes are not derived from sysconfig schemes
    # So we mock them as well to assert the custom "venv" install scheme has priority
    distutils_scheme = {
        "purelib": "$base/local/lib/python$py_version_short/site-packages",
        "platlib": "$platbase/local/lib/python$py_version_short/site-packages",
        "headers": "$base/include/python$py_version_short/$dist_name",
        "scripts": "$base/local/bin",
        "data": "$base/local",
    }
    distutils_schemes = {
        "unix_prefix": distutils_scheme,
        "nt": distutils_scheme,
    }

    if sys.version_info[0] == 2:
        distutils_schemes = _stringify_schemes_dict(distutils_schemes)

    # We need to mock distutils first, so they don't see the mocked sysconfig,
    # if imported for the first time.
    # That can happen if the actual interpreter has the "venv" INSTALL_SCHEME
    # and hence this is the first time we are touching distutils in this process.
    # If distutils saw our mocked sysconfig INSTALL_SCHEMES, we would need
    # to define all install schemes.
    mocker.patch("distutils.command.install.INSTALL_SCHEMES", distutils_schemes)
    mocker.patch("sysconfig._INSTALL_SCHEMES", sysconfig_install_schemes)

    pyinfo = PythonInfo()
    pyver = "{}.{}".format(pyinfo.version_info.major, pyinfo.version_info.minor)
    assert pyinfo.install_path("scripts") == "bin"
    assert pyinfo.install_path("purelib").replace(os.sep, "/") == "lib/python{}/site-packages".format(pyver)
