import glob
import os
import sys

from jedi import cache, common, debug
from jedi._compatibility import exec_function, unicode
from jedi.evaluate.cache import memoize_default
from jedi.parser import Parser, tree


def get_sys_path():
    def check_virtual_env(sys_path):
        """Add virtualenv's site-packages to the `sys.path`."""
        venv = os.getenv('VIRTUAL_ENV')
        if not venv:
            return
        venv = os.path.abspath(venv)
        p = _get_venv_sitepackages(venv)
        if p not in sys_path:
            sys_path.insert(0, p)

        # Add all egg-links from the virtualenv.
        for egg_link in glob.glob(os.path.join(p, '*.egg-link')):
            with open(egg_link) as fd:
                sys_path.insert(0, fd.readline().rstrip())

    check_virtual_env(sys.path)
    return [p for p in sys.path if p != ""]


def _get_venv_sitepackages(venv):
    if os.name == 'nt':
        p = os.path.join(venv, 'lib', 'site-packages')
    else:
        p = os.path.join(venv, 'lib', 'python%d.%d' % sys.version_info[:2],
                         'site-packages')
    return p


def _execute_code(module_path, code):
    c = "import os; from os.path import *; result=%s"
    variables = {'__file__': module_path}
    try:
        exec_function(c % code, variables)
    except Exception:
        debug.warning('sys.path manipulation detected, but failed to evaluate.')
    else:
        try:
            res = variables['result']
            if isinstance(res, str):
                return [os.path.abspath(res)]
        except KeyError:
            pass
    return []


def _paths_from_assignment(evaluator, expr_stmt):
    """Extracts the assigned strings from an assignment that looks as follows::

    >>> sys.path[0:0] = ['module/path', 'another/module/path']

    This function is in general pretty tolerant (and therefore 'buggy').
    However, it's not a big issue usually to add more paths to Jedi's sys_path,
    because it will only affect Jedi in very random situations and by adding
    more paths than necessary, it usually benefits the general user.
    """
    for assignee, operator in zip(expr_stmt.children[::2], expr_stmt.children[1::2], strict=False):
        try:
            assert operator in ['=', '+=']
            assert tree.is_node(assignee, 'power') and len(assignee.children) > 1
            c = assignee.children
            assert c[0].type == 'name' and c[0].value == 'sys'
            trailer = c[1]
            assert trailer.children[0] == '.' and trailer.children[1].value == 'path'
            # TODO Essentially we're not checking details on sys.path
            # manipulation. Both assigment of the sys.path and changing/adding
            # parts of the sys.path are the same: They get added to the current
            # sys.path.
            """
            execution = c[2]
            assert execution.children[0] == '['
            subscript = execution.children[1]
            assert subscript.type == 'subscript'
            assert ':' in subscript.children
            """
        except AssertionError:
            continue

        from jedi.evaluate.iterable import get_iterator_types
        from jedi.evaluate.precedence import is_string
        for val in get_iterator_types(evaluator.eval_statement(expr_stmt)):
            if is_string(val):
                yield val.obj


def _paths_from_list_modifications(module_path, trailer1, trailer2):
    """Extract the path from either "sys.path.append" or "sys.path.insert"."""
    # Guarantee that both are trailers, the first one a name and the second one
    # a function execution with at least one param.
    if not (tree.is_node(trailer1, 'trailer') and trailer1.children[0] == '.'
            and tree.is_node(trailer2, 'trailer') and trailer2.children[0] == '('
            and len(trailer2.children) == 3):
        return []

    name = trailer1.children[1].value
    if name not in ['insert', 'append']:
        return []

    arg = trailer2.children[1]
    if name == 'insert' and len(arg.children) in (3, 4):  # Possible trailing comma.
        arg = arg.children[2]
    return _execute_code(module_path, arg.get_code())


def _check_module(evaluator, module):
    def get_sys_path_powers(names):
        for name in names:
            power = name.parent.parent
            if tree.is_node(power, 'power'):
                c = power.children
                if isinstance(c[0], tree.Name) and c[0].value == 'sys' \
                        and tree.is_node(c[1], 'trailer'):
                    n = c[1].children[1]
                    if isinstance(n, tree.Name) and n.value == 'path':
                        yield name, power

    sys_path = list(get_sys_path())  # copy
    try:
        possible_names = module.used_names['path']
    except KeyError:
        pass
    else:
        for name, power in get_sys_path_powers(possible_names):
            stmt = name.get_definition()
            if len(power.children) >= 4:
                sys_path.extend(_paths_from_list_modifications(module.path, *power.children[2:4]))
            elif name.get_definition().type == 'expr_stmt':
                sys_path.extend(_paths_from_assignment(evaluator, stmt))
    return sys_path


@memoize_default(evaluator_is_first_arg=True, default=[])
def sys_path_with_modifications(evaluator, module):
    if module.path is None:
        # Support for modules without a path is bad, therefore return the
        # normal path.
        return list(get_sys_path())

    curdir = os.path.abspath(os.curdir)
    with common.ignored(OSError):
        os.chdir(os.path.dirname(module.path))

    buildout_script_paths = set()

    result = _check_module(evaluator, module)
    result += _detect_django_path(module.path)
    for buildout_script in _get_buildout_scripts(module.path):
        for path in _get_paths_from_buildout_script(evaluator, buildout_script):
            buildout_script_paths.add(path)
    # cleanup, back to old directory
    os.chdir(curdir)
    return list(result) + list(buildout_script_paths)


def _get_paths_from_buildout_script(evaluator, buildout_script):
    def load(buildout_script):
        try:
            with open(buildout_script, 'rb') as f:
                source = common.source_to_unicode(f.read())
        except OSError:
            debug.dbg('Error trying to read buildout_script: %s', buildout_script)
            return None

        p = Parser(evaluator.grammar, source, buildout_script)
        cache.save_parser(buildout_script, p)
        return p.module

    cached = cache.load_parser(buildout_script)
    module = cached and cached.module or load(buildout_script)
    if not module:
        return

    yield from _check_module(evaluator, module)


def traverse_parents(path):
    while True:
        new = os.path.dirname(path)
        if new == path:
            return
        path = new
        yield path


def _get_parent_dir_with_file(path, filename):
    for parent in traverse_parents(path):
        if os.path.isfile(os.path.join(parent, filename)):
            return parent
    return None


def _detect_django_path(module_path):
    """Detects the path of the very well known Django library (if used)."""
    result = []

    for parent in traverse_parents(module_path):
        with common.ignored(IOError), open(parent + os.path.sep + 'manage.py'):
            debug.dbg('Found django path: %s', module_path)
            result.append(parent)
    return result


def _get_buildout_scripts(module_path):
    """If there is a 'buildout.cfg' file in one of the parent directories of the
    given module it will return a list of all files in the buildout bin
    directory that look like python files.

    :param module_path: absolute path to the module.
    :type module_path: str
    """
    project_root = _get_parent_dir_with_file(module_path, 'buildout.cfg')
    if not project_root:
        return []
    bin_path = os.path.join(project_root, 'bin')
    if not os.path.exists(bin_path):
        return []
    extra_module_paths = []
    for filename in os.listdir(bin_path):
        try:
            filepath = os.path.join(bin_path, filename)
            with open(filepath) as f:
                firstline = f.readline()
                if firstline.startswith('#!') and 'python' in firstline:
                    extra_module_paths.append(filepath)
        except OSError as e:
            # either permission error or race cond. because file got deleted
            # ignore
            debug.warning(unicode(e))
            continue
    return extra_module_paths
