import errno
import os
import posixpath
import re
import shlex
import shutil
import stat
import sys
import warnings
from contextlib import contextmanager
from functools import lru_cache
from pathlib import Path

from pipenv.utils import err
from pipenv.utils.fileutils import normalize_drive, normalize_path
from pipenv.vendor import click
from pipenv.vendor.pythonfinder.utils import ensure_path, parse_python_version

from .constants import FALSE_VALUES, SCHEME_LIST, TRUE_VALUES
from .processes import subprocess_run


@lru_cache
def make_posix(path: str) -> str:
    """
    Convert a path with possible windows-style separators to a posix-style path
    (with **/** separators instead of **\\** separators).

    :param Text path: A path to convert.
    :return: A converted posix-style path
    :rtype: Text

    >>> make_posix("c:/users/user/venvs/some_venv\\Lib\\site-packages")
    "c:/users/user/venvs/some_venv/Lib/site-packages"

    >>> make_posix("c:\\users\\user\\venvs\\some_venv")
    "c:/users/user/venvs/some_venv"
    """
    if not isinstance(path, str):
        raise TypeError(f"Expected a string for path, received {path!r}...")
    starts_with_sep = path.startswith(os.path.sep)
    separated = normalize_path(path).split(os.path.sep)
    if isinstance(separated, (list, tuple)):
        path = posixpath.join(*separated)
        if starts_with_sep:
            path = f"/{path}"
    return path


@contextmanager
def chdir(path):
    """Context manager to change working directories."""
    if not path:
        return
    prev_cwd = Path.cwd().as_posix()
    if isinstance(path, Path):
        path = path.as_posix()
    os.chdir(str(path))
    try:
        yield
    finally:
        os.chdir(prev_cwd)


def looks_like_dir(path):
    seps = (sep for sep in (os.path.sep, os.path.altsep) if sep is not None)
    return any(sep in path for sep in seps)


def load_path(python):
    import json
    from pathlib import Path

    python = Path(python).as_posix()
    c = subprocess_run([python, "-c", "import json, sys; print(json.dumps(sys.path))"])
    if c.returncode == 0:
        return json.loads(c.stdout.strip())
    else:
        return []


def path_to_url(path):
    return Path(normalize_drive(os.path.abspath(path))).as_uri()


def get_windows_path(*args):
    """Sanitize a path for windows environments

    Accepts an arbitrary list of arguments and makes a clean windows path"""
    return os.path.normpath(os.path.join(*args))


def find_windows_executable(bin_path, exe_name):
    """Given an executable name, search the given location for an executable"""
    requested_path = get_windows_path(bin_path, exe_name)
    if os.path.isfile(requested_path):
        return requested_path

    try:
        pathext = os.environ["PATHEXT"]
    except KeyError:
        pass
    else:
        for ext in pathext.split(os.pathsep):
            path = get_windows_path(bin_path, exe_name + ext.strip().lower())
            if os.path.isfile(path):
                return path

    return shutil.which(exe_name)


def walk_up(bottom):
    """Mimic os.walk, but walk 'up' instead of down the directory tree.
    From: https://gist.github.com/zdavkeos/1098474
    """
    bottom = os.path.realpath(bottom)
    # Get files in current dir.
    try:
        names = os.listdir(bottom)
    except Exception:
        return

    dirs, nondirs = [], []
    for name in names:
        if os.path.isdir(os.path.join(bottom, name)):
            dirs.append(name)
        else:
            nondirs.append(name)
    yield bottom, dirs, nondirs

    new_path = os.path.realpath(os.path.join(bottom, ".."))
    # See if we are at the top.
    if new_path == bottom:
        return

    yield from walk_up(new_path)


def find_requirements(max_depth=3):
    """Returns the path of a requirements.txt file in parent directories."""
    i = 0
    for c, _, _ in walk_up(os.getcwd()):
        i += 1
        if i < max_depth:
            r = os.path.join(c, "requirements.txt")
            if os.path.isfile(r):
                return r

    raise RuntimeError("No requirements.txt found!")


# Borrowed from Pew.
# See https://github.com/berdario/pew/blob/master/pew/_utils.py#L82
@contextmanager
def temp_environ():
    """Allow the ability to set os.environ temporarily"""
    environ = dict(os.environ)
    try:
        yield

    finally:
        os.environ.clear()
        os.environ.update(environ)


def escape_cmd(cmd):
    if any(special_char in cmd for special_char in ["<", ">", "&", ".", "^", "|", "?"]):
        cmd = f'"{cmd}"'
    return cmd


def safe_expandvars(value):
    """Call os.path.expandvars if value is a string, otherwise do nothing."""
    if isinstance(value, str):
        return os.path.expandvars(value)
    return value


def cmd_list_to_shell(args):
    """Convert a list of arguments to a quoted shell command."""
    return " ".join(shlex.quote(str(token)) for token in args)


def get_workon_home():
    workon_home = os.environ.get("WORKON_HOME")
    if not workon_home:
        if os.name == "nt":
            workon_home = "~/.virtualenvs"
        else:
            workon_home = os.path.join(
                os.environ.get("XDG_DATA_HOME", "~/.local/share"), "virtualenvs"
            )
    # Create directory if it does not already exist
    expanded_path = Path(os.path.expandvars(workon_home)).expanduser()
    expanded_path = ensure_path(expanded_path)
    os.makedirs(expanded_path, exist_ok=True)
    return expanded_path


def is_file(package):
    """Determine if a package name is for a File dependency."""
    if hasattr(package, "keys"):
        return any(key for key in package if key in ["file", "path"])

    if os.path.exists(str(package)):
        return True

    return any(str(package).startswith(start) for start in SCHEME_LIST)


def is_virtual_environment(path):
    """Check if a given path is a virtual environment's root.

    This is done by checking if the directory contains a Python executable in
    its bin/Scripts directory. Not technically correct, but good enough for
    general usage.
    """
    if not path.is_dir():
        return False
    for bindir_name in ("bin", "Scripts"):
        for python in path.joinpath(bindir_name).glob("python*"):
            try:
                exeness = python.is_file() and os.access(str(python), os.X_OK)
            except OSError:
                exeness = False
            if exeness:
                return True
    return False


def find_python(finder, line=None):
    """
    Given a `pythonfinder.Finder` instance and an optional line, find a corresponding python

    :param finder: A :class:`pythonfinder.Finder` instance to use for searching
    :type finder: :class:pythonfinder.Finder`
    :param str line: A version, path, name, or nothing, defaults to None
    :return: A path to python
    :rtype: str
    """
    if line and not isinstance(line, str):
        raise TypeError(f"Invalid python search type: expected string, received {line!r}")
    if line:
        modified_line = line
        if (
            os.name == "nt"
            and not os.path.exists(modified_line)
            and not modified_line.lower().endswith(".exe")
        ):
            modified_line += ".exe"
        if os.path.exists(modified_line) and shutil.which(modified_line):
            return modified_line

    if not finder:
        from pipenv.vendor.pythonfinder import Finder

        finder = Finder(global_search=True)
    if not line:
        result = next(iter(finder.find_all_python_versions()), None)
    elif line and line[0].isdigit() or re.match(r"^\d+(\.\d+)*$", line):
        version_info = parse_python_version(line)
        result = finder.find_python_version(
            major=version_info.get("major"),
            minor=version_info.get("minor"),
            patch=version_info.get("patch"),
            pre=version_info.get("is_prerelease"),
            dev=version_info.get("is_devrelease"),
            sort_by_path=True,
        )
    else:
        result = finder.find_python_version(name=line)
    if not result:
        result = finder.which(line)
    if not result and "python" not in line.lower():
        line = f"python{line}"
        result = find_python(finder, line)

    if result:
        if not isinstance(result, str):
            return result.path.as_posix()
        return result
    return


def is_python_command(line):
    """
    Given an input, checks whether the input is a request for python or notself.

    This can be a version, a python runtime name, or a generic 'python' or 'pythonX.Y'

    :param str line: A potential request to find python
    :returns: Whether the line is a python lookup
    :rtype: bool
    """

    if not isinstance(line, str):
        raise TypeError(f"Not a valid command to check: {line!r}")

    from pipenv.vendor.pythonfinder.utils import PYTHON_IMPLEMENTATIONS

    is_version = re.match(r"\d+(\.\d+)*", line)
    if (
        line.startswith("python")
        or is_version
        or any(line.startswith(v) for v in PYTHON_IMPLEMENTATIONS)
    ):
        return True
    # we are less sure about this but we can guess
    if line.startswith("py"):
        return True
    return False


@contextmanager
def temp_path():
    """Allow the ability to set os.environ temporarily"""
    path = list(sys.path)
    try:
        yield
    finally:
        sys.path = list(path)


def is_readonly_path(fn):
    """Check if a provided path exists and is readonly.

    Permissions check is `bool(path.stat & stat.S_IREAD)` or `not os.access(path, os.W_OK)`
    """
    if os.path.exists(fn):
        return (os.stat(fn).st_mode & stat.S_IREAD) or not os.access(fn, os.W_OK)

    return False


def set_write_bit(fn):
    if isinstance(fn, str) and not os.path.exists(fn):
        return
    os.chmod(fn, stat.S_IWRITE | stat.S_IWUSR | stat.S_IRUSR)
    return


def handle_remove_readonly(func, path, exc):
    """Error handler for shutil.rmtree.

    Windows source repo folders are read-only by default, so this error handler
    attempts to set them as writeable and then proceed with deletion."""
    # Check for read-only attribute
    default_warning_message = "Unable to remove file due to permissions restriction: {!r}"
    # split the initial exception out into its type, exception, and traceback
    exc_type, exc_exception, exc_tb = exc
    if is_readonly_path(path):
        # Apply write permission and call original function
        set_write_bit(path)
        try:
            func(path)
        except OSError as e:
            if e.errno in [errno.EACCES, errno.EPERM]:
                warnings.warn(
                    default_warning_message.format(path), ResourceWarning, stacklevel=1
                )
                return

    if exc_exception.errno in [errno.EACCES, errno.EPERM]:
        warnings.warn(default_warning_message.format(path), ResourceWarning, stacklevel=1)
        return

    raise exc


def style_no_color(text, fg=None, bg=None, **kwargs) -> str:
    """Wrap click style to ignore colors."""
    if hasattr(click, "original_style"):
        return click.original_style(text, **kwargs)
    return click.style(text, **kwargs)


def env_to_bool(val):
    """
    Convert **val** to boolean, returning True if truthy or False if falsey

    :param Any val: The value to convert
    :return: False if falsey, True if truthy
    :rtype: bool
    :raises:
        ValueError: if val is not a valid boolean-like
    """
    if isinstance(val, bool):
        return val

    try:
        if val.lower() in FALSE_VALUES:
            return False
        if val.lower() in TRUE_VALUES:
            return True
    except AttributeError:
        pass

    raise ValueError(f"Value is not a valid boolean-like: {val}")


def is_env_truthy(name):
    """An environment variable is truthy if it exists and isn't one of (0, false, no, off)"""
    return env_to_bool(os.getenv(name, False))  # noqa: PLW1508


def project_python(project, system=False):
    if not system:
        python = project._which("python")
    else:
        interpreters = [system_which(p) for p in ("python3", "python")]
        interpreters = [i for i in interpreters if i]  # filter out not found interpreters
        python = interpreters[0] if interpreters else None
    if not python:
        err.print("The Python interpreter can't be found.", style="red")
        sys.exit(1)
    return Path(python).as_posix()


def system_which(command, path=None):
    """Emulates the system's which. Returns None if not found."""
    import shutil

    result = shutil.which(command, path=path)
    if result is None:
        _which = "where" if os.name == "nt" else "which -a"
        env = {"PATH": path} if path else None
        c = subprocess_run(f"{_which} {command}", shell=True, env=env)
        if c.returncode == 127:
            err.print(
                f"[bold][red]Warning[/red][/bold]: the [yellow]{_which}[/yellow]"
                "system utility is required for Pipenv to find Python installations properly."
                "\nPlease install it."
            )
        if c.returncode == 0:
            result = next(iter(c.stdout.splitlines()), None)
    return result


def shorten_path(location, bold=False):
    """Returns a visually shorter representation of a given system path."""
    original = location
    short = os.sep.join(
        [s[0] if len(s) > (len("2long4")) else s for s in location.split(os.sep)]
    )
    short = short.split(os.sep)
    short[-1] = original.split(os.sep)[-1]
    if bold:
        short[-1] = f"[bold]{short[-1]}[/bold]"
    return os.sep.join(short)


def isatty(stream):
    try:
        is_a_tty = stream.isatty()
    except Exception:  # pragma: no cover
        is_a_tty = False
    return is_a_tty
