"""Functions for wrapping virtualenv wrapper.
"""
import os
import re
import subprocess
import sys
from contextlib import contextmanager
from distutils.util import strtobool
from os.path import expandvars
from pathlib import Path
import logging
from shutil import which
from typing import List

logger = logging.getLogger(__file__)


def _interactive_sub_shell_command(command):
    """Build a command to run a given command in an interactive subshell.

    Args:
        command: The command for the subshell.

    Returns:
        A string which can be used with the subprocess module.

    Raises:
        ValueError: If the subshell command could not be determined.
    """
    preferred_shell_name = os.environ.get("SHELL", "bash")
    logger.debug("preferred_shell_name from $SHELL = %r", preferred_shell_name)
    shell_name = expandvars(os.environ.get("VENV_MANAGEMENT_SHELL", preferred_shell_name))
    logger.debug("shell_name = %r", shell_name)
    shell_filepath = which(shell_name)
    logger.debug("shell_filepath = %r", shell_filepath)
    if shell_filepath is None:
        raise RuntimeError(f"Could not determine the path to {shell_name}")
    shell_filepath = Path(shell_filepath)
    shell_filename = shell_filepath.name
    logger.debug("shell_filename = %r", shell_filename)
    rc_filename = f".{shell_filename}rc"
    logger.debug("rc_filename = %r", rc_filename)
    rc_filepath = Path.home() / rc_filename
    logger.debug("rc_filepath = %r", rc_filepath)
    interactive = strtobool(expandvars(os.environ.get("VENV_MANAGEMENT_INTERACTIVE_SHELL", "no")))
    logger.debug("interactive = %s", interactive)
    setup_filepath = Path(expandvars(os.environ.get("VENV_MANAGEMENT_SETUP_FILEPATH", str(rc_filepath))))
    logger.debug("setup_filepath = %s", setup_filepath)
    if not setup_filepath.is_file():
        raise RuntimeError(f"Could not find setup file {setup_filepath}")
    args = [
        str(shell_filepath),
        "-c",  # Run command
        *(["-i"] if interactive else []),
        f". {setup_filepath!s} && {command}",
    ]
    return args


def has_virtualenvwrapper():
    """Determine whether virtualenvwrapper available and working.

    Returns:
        True if virtualenvwrapper is available and working,
        otherwise False.
    """
    try:
        lsvirtualenv()
    except RuntimeError:
        return False
    return True


lsvirtualenv_commands = [
    "lsvirtualenv -b",
    "lsvirtualenvs -b",
]

def list_virtual_envs() -> List[str]:
    """A list of virtualenv names.

    Returns:
        A list of string names in case-sensitive alphanumeric order.

    Raises:
        FileNotFoundError: If virtualenvwrapper.sh could not be located.
    """
    failed_commands = []
    for lsvirtualenv_command in list(lsvirtualenv_commands):
        command = _interactive_sub_shell_command(lsvirtualenv_command)
        logger.debug(command)
        status, output = _getstatusoutput(command)
        logger.debug("status = %d", status)
        logger.debug("output = %s", output)
        if status == 0:
            break
        failed_commands.append(lsvirtualenv_command)
    else:  # no-break
        for failed_command in failed_commands:
            lsvirtualenv_commands.remove(failed_command)
        failure_message = "Could not list virtual environments with failed commands: {}".format(
            " ; ".join(failed_commands))
        logger.error(failure_message)
        raise RuntimeError(failure_message)
    return output.splitlines(keepends=False)


def _getstatusoutput(cmd):
    """    Return (status, output) of executing cmd in a shell.

    Execute the string 'cmd' in a shell with 'check_output' and
    return a 2-tuple (status, output). Universal newlines mode is used,
    meaning that the result with be decoded to a string.

    A trailing newline is stripped from the output.
    The exit status for the command can be interpreted
    according to the rules for the function 'wait'.
    """
    logger.debug("command = %r", cmd)
    process = subprocess.run(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        encoding=sys.getdefaultencoding()
    )
    status = process.returncode
    if status == 0:
        data = process.stdout
        if data[-1:] == '\n':
            data = data[:-1]
    else:
        data = (f"STATUS: {status} ; \n"
                f"STDOUT: {process.stdout} ; \n"
                f"STDERR: {process.stderr}"
        )
    return status, data


DESTINATION_PATTERN = r"dest=([^,]+)"
DESTINATION_REGEX = re.compile(DESTINATION_PATTERN)


def make_virtual_env(
    name,
    *,
    python=None,
    project_path=None,
    packages=None,
    requirements_file=None,
    system_site_packages=False,
    pip=True,
    setuptools=True,
    wheel=True,
):
    """Make a virtual env.

    Args:
        name: The name of the virtual environment.

        project_path: An optional path to a project which will be associated with the
            new virtual environment.

        packages: An optional sequence of package names for packages to be installed.

        requirements_file: An optional path to a requirements file to be installed.

        python: The target interpreter for which to create a virtual environment, either
            the name of the executable, or full path.

        system_site_packages: If True, give access to the system site packages.

        pip: If True, or 'latest' the latest pip will be installed. If False, pip will not
            be installed. If 'bundled', the bundled version will be installed. If a specific
            version string is given, that version will be installed.

        setuptools: If True, or 'latest' the latest pip will be installed. If False, pip will not
            be installed. If 'bundled', the bundled version will be installed. If a specific
            version string is given, that version will be installed.

        wheel: If True, or 'latest' the latest pip will be installed. If False, pip will not
            be installed. If 'bundled', the bundled version will be installed. If a specific
            version string is given, that version will be installed.

    Returns:
        The Path to the root of the virtualenv, or None if the path could not be determined.

    Raises:
        RuntimeError: If the virtualenv could not be created.
    """
    project_path_arg = f"-a {project_path}" if project_path else ""
    packages_args = [f"-i {package}" for package in packages] if packages else []
    requirements_arg = f"-r{requirements_file}" if requirements_file else ""
    python_arg = f"--python={python}" if python else ""
    system_site_packages_arg = "--system-site-packages" if system_site_packages else ""
    pip_arg = _parse_package_arg("pip", pip)
    setuptools_arg = _parse_package_arg("setuptools", setuptools)
    wheel_arg = _parse_package_arg("wheel", wheel)

    args = " ".join(
        (
            project_path_arg,
            *packages_args,
            requirements_arg,
            python_arg,
            system_site_packages_arg,
            pip_arg,
            setuptools_arg,
            wheel_arg,
        )
    )

    command = _interactive_sub_shell_command(f"mkvirtualenv {name} {args}")
    logger.info(command)
    status, output = subprocess.getstatusoutput(command)
    if status != 0:
        raise RuntimeError(f"Could not run {command}")
    lines = output.splitlines(keepends=False)
    for line in lines:
        m = DESTINATION_REGEX.search(line)
        if m is not None:
            return Path(m.group(1))
    return None


def resolve_virtual_env(name):
    """Given the name of a virtual environment, get its path.

    Args:
        The name of a virtual environment.

    Returns:
        The path to the virtual environment directory.

    Raises:
        ValueError: If the virtual environment name is not known.
        RuntimeError: If the path could not be determined.
    """
    if name not in list_virtual_envs():
        raise ValueError(f"Unknown virtual environment {name!r}")
    return virtual_envs_dirpath() / name


def virtual_envs_dirpath():
    """The directory in which new virtual environments are created.

    Returns:
        A path object.
    """
    return Path(os.path.expanduser(os.environ.get("WORKON_HOME", "~/.virtualenvs")))


@contextmanager
def virtual_env(name, expected_version=None, *, force=False, **kwargs):
    """A context manager that ensures a virtualenv with the given name and version exists.

    Irrespective of whether the virtual environment already exists, it will be removed when the context manager exits.

    Args:
        name: The name of the environment to check for.

        expected_version: An optional required version as a string. "3.8" will match "3.8.2"

        force: Force replacement of an existing virtual environment which has the wrong version.

        **kwargs: Arguments which will be forwarded to mkvirtualenv if the environment
            needs to be created.

    Returns:
        A context manager that manages the lifecycle of the virtual environment.

    Raises:
        RuntimeError: If the virtual environment couldn't be created or replaced.
    """
    venv_path = ensure_virtual_env(name, expected_version, force=force, **kwargs)
    try:
        yield venv_path
    finally:
        remove_virtual_env(name)


def ensure_virtual_env(name, expected_version=None, *, force=False, **kwargs):
    """Ensure a virtualenv with the given name and version exists.

    Args:
        name: The name of the environment to check for.

        expected_version: An optional required version as a string. "3.8" will match "3.8.2"

        force: Force replacement of an existing virtual environment which has the wrong version.

        **kwargs: Arguments which will be forwarded to mkvirtualenv if the environment
            needs to be created.

    Returns:
        The path to the virtual environment.

    Raises:
        RuntimeError: If the virtual environment couldn't be created or replaced.
    """
    python_arg = f"python{expected_version}" if (expected_version is not None) else None
    try:
        env_dirpath = resolve_virtual_env(name)
    except ValueError:
        # No such virtual environment, so make it
        env_dirpath = make_virtual_env(name, python=python_arg, **kwargs)
    else:
        # An environment with the right name exists. Does it have the right version?
        actual_version = python_version(env_dirpath)
        if (expected_version is not None) and (
            not _compatible_versions(actual_version, expected_version)
        ):
            message = (
                f"Virtual environment at {env_dirpath} has actual version {actual_version}, "
                f"not expected version {expected_version}"
            )
            logger.warning(message)
            if force:
                remove_virtual_env(name)
                env_dirpath = make_virtual_env(name, python=python_arg, **kwargs)
            else:
                raise RuntimeError(message)
    return env_dirpath


def _compatible_versions(actual_version, expected_version):
    return all(
        actual == expected
        for actual, expected in zip(actual_version.split("."), expected_version.split("."))
    )


def remove_virtual_env(name):
    """Remove a virtual environment.

    Args:
        name: The name of the virtual environment to remove.

    Raises:
        ValueError: If there is no environment with the given name.
        RuntimeError: If the virtualenv could not be removed.
    """
    command = _interactive_sub_shell_command(f"rmvirtualenv {name}")
    logger.info(command)
    status, output = subprocess.getstatusoutput(command)
    if status != 0:
        raise RuntimeError(f"Could not run {command}")
    if "Did not find environment" in output:
        raise ValueError(output.splitlines(keepends=False)[1])


def discard_virtual_env(name):
    """Discard a virtual environment.

    Args:
        name: The name of the virtual environment to remove.

    Raises:
        RuntimeError: If the virtualenv could not be removed.
    """
    try:
        remove_virtual_env(name)
    except ValueError:
        pass


def python_executable_path(env_dirpath):
    """Find the Python executable for a virtual environment.

    Args:
        env_dirpath: The path to the root of a virtual environment (Path or str).

    Returns:
        A Path object to the executable.

    Raises:
        ValueError: If the env_dirpath is not a virtual environment.
    """
    dirpath = Path(env_dirpath)
    exe_filepath = dirpath / "bin" / "python"
    if not exe_filepath.exists():
        raise ValueError(
            f"Could not locate Python executable for supposed virtual environment {env_dirpath}"
        )
    return exe_filepath


def python_name(env_dirpath):
    """Find the name of the Python in a virtual environment.

    Args:
        env_dirpath: The path to the root of a virtual environment (Path or str).

    Returns:
        A descriptive string.

    Raises:
        ValueError: If the env_dirpath is not a virtual environment.
    """
    exe = python_executable_path(env_dirpath)
    command = f"{exe} --version"
    status, output = subprocess.getstatusoutput(command)
    if status != 0:
        raise RuntimeError(f"Could not run {command}")
    return output.splitlines(keepends=False)[0]


def python_version(env_dirpath):
    """Find the version of the Python in virtual environment.

    Args:
        env_dirpath: The path to the root of a virtual environment (Path or str).

    Returns:
        A version string, such as "3.8.1"

    Raises:
        ValueError: If the env_dirpath is not a virtual environment.
    """
    name = python_name(env_dirpath)
    version = name.split()[-1]
    return version


def _parse_package_arg(name, arg):
    if arg == True:
        option = ""
    elif arg == False:
        option = f"--no-{name}"
    else:
        option = f"--{name}={arg}"
    return option


# Aliases for the main functions with the same name as the
# virtualenvwrapper shell commands
lsvirtualenv = list_virtual_envs
mkvirtualenv = make_virtual_env
rmvirtualenv = remove_virtual_env
