"""Imitate the parser representation."""
import inspect
import os
import re
import sys
from functools import partial

from jedi import debug
from jedi._compatibility import builtins as _builtins
from jedi._compatibility import unicode
from jedi.cache import memoize_method, underscore_memoization
from jedi.evaluate.helpers import FakeName
from jedi.evaluate.sys_path import get_sys_path
from jedi.parser.tree import Base, Operator, Param, zero_position_modifier

from . import fake

_sep = os.path.sep
if os.path.altsep is not None:
    _sep += os.path.altsep
_path_re = re.compile('(?:\.[^{0}]+|[{0}]__init__\.py)$'.format(re.escape(_sep)))
del _sep


class CheckAttribute:
    """Raises an AttributeError if the attribute X isn't available."""

    def __init__(self, func):
        self.func = func
        # Remove the py in front of e.g. py__call__.
        self.check_name = func.__name__[2:]

    def __get__(self, instance, owner):
        # This might raise an AttributeError. That's wanted.
        getattr(instance.obj, self.check_name)
        return partial(self.func, instance)


class CompiledObject(Base):
    # comply with the parser
    start_pos = 0, 0
    path = None  # modules have this attribute - set it to None.
    used_names = {}  # To be consistent with modules.

    def __init__(self, obj, parent=None):
        self.obj = obj
        self.parent = parent

    @property
    def py__call__(self):
        def actual(evaluator, params):
            if inspect.isclass(self.obj):
                from jedi.evaluate.representation import Instance
                return [Instance(evaluator, self, params)]
            return list(self._execute_function(evaluator, params))

        # Might raise an AttributeError, which is intentional.
        self.obj.__call__
        return actual

    @CheckAttribute
    def py__class__(self, evaluator):
        return CompiledObject(self.obj.__class__, parent=self.parent)

    @CheckAttribute
    def py__mro__(self, evaluator):
        return tuple(create(evaluator, cls, self.parent) for cls in self.obj.__mro__)

    @CheckAttribute
    def py__bases__(self, evaluator):
        return tuple(create(evaluator, cls) for cls in self.obj.__bases__)

    def py__bool__(self):
        return bool(self.obj)

    def py__file__(self):
        return self.obj.__file__

    def is_class(self):
        return inspect.isclass(self.obj)

    @property
    def doc(self):
        return inspect.getdoc(self.obj) or ''

    @property
    def params(self):
        params_str, ret = self._parse_function_doc()
        tokens = params_str.split(',')
        if inspect.ismethoddescriptor(self._cls().obj):
            tokens.insert(0, 'self')
        params = []
        for p in tokens:
            parts = [FakeName(part) for part in p.strip().split('=')]
            if len(parts) > 1:
                parts.insert(1, Operator(zero_position_modifier, '=', (0, 0)))
            params.append(Param(parts, self))
        return params

    def __repr__(self):
        return f'<{type(self).__name__}: {repr(self.obj)}>'

    @underscore_memoization
    def _parse_function_doc(self):
        if self.doc is None:
            return '', ''

        return _parse_function_doc(self.doc)

    def api_type(self) -> str | None:
        if fake.is_class_instance(self.obj):
            return 'instance'

        cls = self._cls().obj
        if inspect.isclass(cls):
            return 'class'
        if inspect.ismodule(cls):
            return 'module'
        if inspect.isbuiltin(cls) or inspect.ismethod(cls) \
                or inspect.ismethoddescriptor(cls):
            return 'function'
        return None

    @property
    def type(self) -> str | None:
        """Imitate the tree.Node.type values."""
        cls = self._cls().obj
        if inspect.isclass(cls):
            return 'classdef'
        if inspect.ismodule(cls):
            return 'file_input'
        if inspect.isbuiltin(cls) or inspect.ismethod(cls) \
                or inspect.ismethoddescriptor(cls):
            return 'funcdef'
        return None

    @underscore_memoization
    def _cls(self):
        # Ensures that a CompiledObject is returned that is not an instance (like list)
        if fake.is_class_instance(self.obj):
            try:
                c = self.obj.__class__
            except AttributeError:
                # happens with numpy.core.umath._UFUNC_API (you get it
                # automatically by doing `import numpy`.
                c = type(None)
            return CompiledObject(c, self.parent)
        return self

    @property
    def names_dict(self):
        # For compatibility with `representation.Class`.
        return self.names_dicts(False)[0]

    def names_dicts(self, search_global, is_instance=False):
        return self._names_dict_ensure_one_dict(is_instance)

    @memoize_method
    def _names_dict_ensure_one_dict(self, is_instance):
        """search_global shouldn't change the fact that there's one dict, this way
        there's only one `object`.
        """
        return [LazyNamesDict(self._cls(), is_instance)]

    def get_subscope_by_name(self, name):
        if name in dir(self._cls().obj):
            return CompiledName(self._cls(), name).parent
        raise KeyError(f"CompiledObject doesn't have an attribute '{name}'.")

    def get_index_types(self, evaluator, index_array=()):
        # If the object doesn't have `__getitem__`, just raise the
        # AttributeError.
        if not hasattr(self.obj, '__getitem__'):
            debug.warning('Tried to call __getitem__ on non-iterable.')
            return []
        if type(self.obj) not in (str, list, tuple, unicode, bytes, bytearray, dict):
            # Get rid of side effects, we won't call custom `__getitem__`s.
            return []

        result = []
        from jedi.evaluate.iterable import create_indexes_or_slices
        for typ in create_indexes_or_slices(evaluator, index_array):
            index = None
            try:
                index = typ.obj
                new = self.obj[index]
            except (KeyError, IndexError, TypeError, AttributeError):
                # Just try, we don't care if it fails, except for slices.
                if isinstance(index, slice):
                    result.append(self)
            else:
                result.append(CompiledObject(new))
        if not result:
            try:
                for obj in self.obj:
                    result.append(CompiledObject(obj))
            except TypeError:
                pass  # self.obj maynot have an __iter__ method.
        return result

    @property
    def name(self):
        # might not exist sometimes (raises AttributeError)
        return FakeName(self._cls().obj.__name__, self)

    def _execute_function(self, evaluator, params):
        if self.type != 'funcdef':
            return

        for name in self._parse_function_doc()[1].split():
            try:
                bltn_obj = _create_from_name(builtin, builtin, name)
            except AttributeError:
                continue
            else:
                if isinstance(bltn_obj, CompiledObject) and bltn_obj.obj is None:
                    # We want everything except None.
                    continue
                yield from evaluator.execute(bltn_obj, params)

    @property
    @underscore_memoization
    def subscopes(self):
        """Returns only the faked scopes - the other ones are not important for
        internal analysis.
        """
        module = self.get_parent_until()
        faked_subscopes = []
        for name in dir(self._cls().obj):
            f = fake.get_faked(module.obj, self.obj, name)
            if f:
                f.parent = self
                faked_subscopes.append(f)
        return faked_subscopes

    def is_scope(self) -> bool:
        return True

    def get_self_attributes(self):
        return []  # Instance compatibility

    def get_imports(self):
        return []  # Builtins don't have imports


class LazyNamesDict:
    """A names_dict instance for compiled objects, resembles the parser.tree."""

    def __init__(self, compiled_obj, is_instance):
        self._compiled_obj = compiled_obj
        self._is_instance = is_instance

    def __iter__(self):
        return (v[0].value for v in self.values())

    @memoize_method
    def __getitem__(self, name):
        try:
            getattr(self._compiled_obj.obj, name)
        except AttributeError:
            raise KeyError(f'{name} in {self._compiled_obj} not found.')
        return [CompiledName(self._compiled_obj, name)]

    def values(self):
        obj = self._compiled_obj.obj

        values = []
        for name in dir(obj):
            try:
                values.append(self[name])
            except KeyError:
                # The dir function can be wrong.
                pass

        # dir doesn't include the type names.
        if not inspect.ismodule(obj) and obj != type and not self._is_instance:
            values += _type_names_dict.values()
        return values


class CompiledName(FakeName):
    def __init__(self, obj, name):
        super().__init__(name)
        self._obj = obj
        self.name = name

    def __repr__(self):
        try:
            name = self._obj.name  # __name__ is not defined all the time
        except AttributeError:
            name = None
        return f'<{type(self).__name__}: ({name}).{self.name}>'

    def is_definition(self) -> bool:
        return True

    @property
    @underscore_memoization
    def parent(self):
        module = self._obj.get_parent_until()
        return _create_from_name(module, self._obj, self.name)

    @parent.setter
    def parent(self, value):
        pass  # Just ignore this, FakeName tries to overwrite the parent attribute.


def dotted_from_fs_path(fs_path, sys_path=None):
    """Changes `/usr/lib/python3.4/email/utils.py` to `email.utils`.  I.e.
    compares the path with sys.path and then returns the dotted_path. If the
    path is not in the sys.path, just returns None.
    """
    if sys_path is None:
        sys_path = get_sys_path()

    if os.path.basename(fs_path).startswith('__init__.'):
        # We are calculating the path. __init__ files are not interesting.
        fs_path = os.path.dirname(fs_path)

    # prefer
    #   - UNIX
    #     /path/to/pythonX.Y/lib-dynload
    #     /path/to/pythonX.Y/site-packages
    #   - Windows
    #     C:\path\to\DLLs
    #     C:\path\to\Lib\site-packages
    # over
    #   - UNIX
    #     /path/to/pythonX.Y
    #   - Windows
    #     C:\path\to\Lib
    path = ''
    for s in sys_path:
        if (fs_path.startswith(s) and len(path) < len(s)):
            path = s
    return _path_re.sub('', fs_path[len(path):].lstrip(os.path.sep)).replace(os.path.sep, '.')


def load_module(path=None, name=None):
    dotted_path = dotted_from_fs_path(path) if path is not None else name

    sys_path = get_sys_path()
    if dotted_path is None:
        p, _, dotted_path = path.partition(os.path.sep)
        sys_path.insert(0, p)

    temp, sys.path = sys.path, sys_path
    try:
        __import__(dotted_path)
    except RuntimeError:
        if 'PySide' in dotted_path or 'PyQt' in dotted_path:
            # RuntimeError: the PyQt4.QtCore and PyQt5.QtCore modules both wrap
            # the QObject class.
            # See https://github.com/davidhalter/jedi/pull/483
            return None
        raise
    except ImportError:
        # If a module is "corrupt" or not really a Python module or whatever.
        debug.warning('Module %s not importable.', path)
        return None
    finally:
        sys.path = temp

    # Just access the cache after import, because of #59 as well as the very
    # complicated import structure of Python.
    module = sys.modules[dotted_path]

    return CompiledObject(module)


docstr_defaults = {
    'floating point number': 'float',
    'character': 'str',
    'integer': 'int',
    'dictionary': 'dict',
    'string': 'str',
}


def _parse_function_doc(doc):
    """Takes a function and returns the params and return value as a tuple.
    This is nothing more than a docstring parser.

    TODO docstrings like utime(path, (atime, mtime)) and a(b [, b]) -> None
    TODO docstrings like 'tuple of integers'
    """
    # parse round parentheses: def func(a, (b,c))
    try:
        count = 0
        start = doc.index('(')
        for i, s in enumerate(doc[start:]):
            if s == '(':
                count += 1
            elif s == ')':
                count -= 1
            if count == 0:
                end = start + i
                break
        param_str = doc[start + 1:end]
    except (ValueError, UnboundLocalError):
        # ValueError for doc.index
        # UnboundLocalError for undefined end in last line
        debug.dbg('no brackets found - no param')
        end = 0
        param_str = ''
    else:
        # remove square brackets, that show an optional param ( = None)
        def change_options(m):
            args = m.group(1).split(',')
            for i, a in enumerate(args):
                if a and '=' not in a:
                    args[i] += '=None'
            return ','.join(args)

        while True:
            param_str, changes = re.subn(r' ?\[([^\[\]]+)\]',
                                         change_options, param_str)
            if changes == 0:
                break
    param_str = param_str.replace('-', '_')  # see: isinstance.__doc__

    # parse return value
    r = re.search('-[>-]* ', doc[end:end + 7])
    if r is None:
        ret = ''
    else:
        index = end + r.end()
        # get result type, which can contain newlines
        pattern = re.compile(r'(,\n|[^\n-])+')
        ret_str = pattern.match(doc, index).group(0).strip()
        # New object -> object()
        ret_str = re.sub(r'[nN]ew (.*)', r'\1()', ret_str)

        ret = docstr_defaults.get(ret_str, ret_str)

    return param_str, ret


class Builtin(CompiledObject):
    @memoize_method
    def get_by_name(self, name):
        return self.names_dict[name][0].parent


def _a_generator(foo):
    """Used to have an object to return for generators."""
    yield 42
    yield foo


def _create_from_name(module, parent, name):
    faked = fake.get_faked(module.obj, parent.obj, name)
    # only functions are necessary.
    if faked is not None:
        faked.parent = parent
        return faked

    try:
        obj = getattr(parent.obj, name)
    except AttributeError:
        # happens e.g. in properties of
        # PyQt4.QtGui.QStyleOptionComboBox.currentText
        # -> just set it to None
        obj = None
    return CompiledObject(obj, parent)


builtin = Builtin(_builtins)
magic_function_class = CompiledObject(type(load_module), parent=builtin)
generator_obj = CompiledObject(_a_generator(1.0))
_type_names_dict = builtin.get_by_name('type').names_dict
none_obj = builtin.get_by_name('None')
false_obj = builtin.get_by_name('False')
true_obj = builtin.get_by_name('True')
object_obj = builtin.get_by_name('object')


def keyword_from_value(obj):
    if obj is None:
        return none_obj
    if obj is False:
        return false_obj
    if obj is True:
        return true_obj
    raise NotImplementedError


def compiled_objects_cache(func):
    def wrapper(evaluator, obj, parent=builtin, module=None):
        # Do a very cheap form of caching here.
        key = id(obj), id(parent), id(module)
        try:
            return evaluator.compiled_cache[key][0]
        except KeyError:
            result = func(evaluator, obj, parent, module)
            # Need to cache all of them, otherwise the id could be overwritten.
            evaluator.compiled_cache[key] = result, obj, parent, module
            return result
    return wrapper


@compiled_objects_cache
def create(evaluator, obj, parent=builtin, module=None):
    """A very weird interface class to this module. The more options provided the
    more acurate loading compiled objects is.
    """
    if not inspect.ismodule(obj):
        faked = fake.get_faked(module and module.obj, obj)
        if faked is not None:
            faked.parent = parent
            return faked

    try:
        if parent == builtin and obj.__module__ in ('builtins', '__builtin__'):
            return builtin.get_by_name(obj.__name__)
    except AttributeError:
        pass

    return CompiledObject(obj, parent)
