import keyword
import sys
import warnings

import rope.base.codeanalyze
import rope.base.evaluate
from rope.base import builtins
from rope.base import exceptions
from rope.base import libutils
from rope.base import pynames
from rope.base import pynamesdef
from rope.base import pyobjects
from rope.base import pyobjectsdef
from rope.base import pyscopes
from rope.base import worder
from rope.contrib import fixsyntax
from rope.refactor import functionutils


def code_assist(project, source_code, offset, resource=None,
                templates=None, maxfixes=1, later_locals=True):
    """Return python code completions as a list of `CodeAssistProposal`\s

    `resource` is a `rope.base.resources.Resource` object.  If
    provided, relative imports are handled.

    `maxfixes` is the maximum number of errors to fix if the code has
    errors in it.

    If `later_locals` is `False` names defined in this scope and after
    this line is ignored.

    """
    if templates is not None:
        warnings.warn('Codeassist no longer supports templates',
                      DeprecationWarning, stacklevel=2)
    assist = _PythonCodeAssist(
        project, source_code, offset, resource=resource,
        maxfixes=maxfixes, later_locals=later_locals)
    return assist()


def starting_offset(source_code, offset):
    """Return the offset in which the completion should be inserted

    Usually code assist proposals should be inserted like::

        completion = proposal.name
        result = (source_code[:starting_offset] +
                  completion + source_code[offset:])

    Where starting_offset is the offset returned by this function.

    """
    word_finder = worder.Worder(source_code, True)
    expression, starting, starting_offset = \
        word_finder.get_splitted_primary_before(offset)
    return starting_offset


def get_doc(project, source_code, offset, resource=None, maxfixes=1):
    """Get the pydoc"""
    fixer = fixsyntax.FixSyntax(project, source_code, resource, maxfixes)
    pyname = fixer.pyname_at(offset)
    if pyname is None:
        return None
    pyobject = pyname.get_object()
    return PyDocExtractor().get_doc(pyobject)


def get_calltip(project, source_code, offset, resource=None,
                maxfixes=1, ignore_unknown=False, remove_self=False):
    """Get the calltip of a function

    The format of the returned string is
    ``module_name.holding_scope_names.function_name(arguments)``.  For
    classes `__init__()` and for normal objects `__call__()` function
    is used.

    Note that the offset is on the function itself *not* after the its
    open parenthesis.  (Actually it used to be the other way but it
    was easily confused when string literals were involved.  So I
    decided it is better for it not to try to be too clever when it
    cannot be clever enough).  You can use a simple search like::

        offset = source_code.rindex('(', 0, offset) - 1

    to handle simple situations.

    If `ignore_unknown` is `True`, `None` is returned for functions
    without source-code like builtins and extensions.

    If `remove_self` is `True`, the first parameter whose name is self
    will be removed for methods.
    """
    fixer = fixsyntax.FixSyntax(project, source_code, resource, maxfixes)
    pyname = fixer.pyname_at(offset)
    if pyname is None:
        return None
    pyobject = pyname.get_object()
    return PyDocExtractor().get_calltip(pyobject, ignore_unknown, remove_self)


def get_definition_location(project, source_code, offset,
                            resource=None, maxfixes=1):
    """Return the definition location of the python name at `offset`

    Return a (`rope.base.resources.Resource`, lineno) tuple.  If no
    `resource` is given and the definition is inside the same module,
    the first element of the returned tuple would be `None`.  If the
    location cannot be determined ``(None, None)`` is returned.

    """
    fixer = fixsyntax.FixSyntax(project, source_code, resource, maxfixes)
    pyname = fixer.pyname_at(offset)
    if pyname is not None:
        module, lineno = pyname.get_definition_location()
        if module is not None:
            return module.get_module().get_resource(), lineno
    return (None, None)


def find_occurrences(*args, **kwds):
    import rope.contrib.findit
    warnings.warn('Use `rope.contrib.findit.find_occurrences()` instead',
                  DeprecationWarning, stacklevel=2)
    return rope.contrib.findit.find_occurrences(*args, **kwds)


def get_canonical_path(project, resource, offset):
    """Get the canonical path to an object.

    Given the offset of the object, this returns a list of
    (name, name_type) tuples representing the canonical path to the
    object. For example, the 'x' in the following code:

        class Foo(object):
            def bar(self):
                class Qux(object):
                    def mux(self, x):
                        pass

    we will return:

        [('Foo', 'CLASS'), ('bar', 'FUNCTION'), ('Qux', 'CLASS'),
         ('mux', 'FUNCTION'), ('x', 'PARAMETER')]

    `resource` is a `rope.base.resources.Resource` object.

    `offset` is the offset of the pyname you want the path to.

    """
    # Retrieve the PyName.
    pymod = project.get_pymodule(resource)
    pyname = rope.base.evaluate.eval_location(pymod, offset)

    # Now get the location of the definition and its containing scope.
    defmod, lineno = pyname.get_definition_location()
    if not defmod:
        return None
    scope = defmod.get_scope().get_inner_scope_for_line(lineno)

    # Start with the name of the object we're interested in.
    names = []
    if isinstance(pyname, pynamesdef.ParameterName):
        names = [(worder.get_name_at(pymod.get_resource(), offset),
                  'PARAMETER') ]
    elif isinstance(pyname, pynamesdef.AssignedName):
        names = [(worder.get_name_at(pymod.get_resource(), offset),
                  'VARIABLE')]

    # Collect scope names.
    while scope.parent:
        if isinstance(scope, pyscopes.FunctionScope):
            scope_type = 'FUNCTION'
        elif isinstance(scope, pyscopes.ClassScope):
            scope_type = 'CLASS'
        else:
            scope_type = None
        names.append((scope.pyobject.get_name(), scope_type))
        scope = scope.parent

    names.append((defmod.get_resource().real_path, 'MODULE'))
    names.reverse()
    return names


class CompletionProposal(object):
    """A completion proposal

    The `scope` instance variable shows where proposed name came from
    and can be 'global', 'local', 'builtin', 'attribute', 'keyword',
    'imported', 'parameter_keyword'.

    The `type` instance variable shows the approximate type of the
    proposed object and can be 'instance', 'class', 'function', 'module',
    and `None`.

    All possible relations between proposal's `scope` and `type` are shown
    in the table below (different scopes in rows and types in columns):

                      | instance | class | function | module | None
                local |    +     |   +   |    +     |   +    |
               global |    +     |   +   |    +     |   +    |
              builtin |    +     |   +   |    +     |        |
            attribute |    +     |   +   |    +     |   +    |
             imported |    +     |   +   |    +     |   +    |
              keyword |          |       |          |        |  +
    parameter_keyword |          |       |          |        |  +

    """

    def __init__(self, name, scope, pyname=None):
        self.name = name
        self.pyname = pyname
        self.scope = self._get_scope(scope)

    def __str__(self):
        return '%s (%s, %s)' % (self.name, self.scope, self.type)

    def __repr__(self):
        return str(self)

    @property
    def parameters(self):
        """The names of the parameters the function takes.

        Returns None if this completion is not a function.
        """
        pyname = self.pyname
        if isinstance(pyname, pynames.ImportedName):
            pyname = pyname._get_imported_pyname()
        if isinstance(pyname, pynames.DefinedName):
            pyobject = pyname.get_object()
            if isinstance(pyobject, pyobjects.AbstractFunction):
                return pyobject.get_param_names()

    @property
    def type(self):
        pyname = self.pyname
        if isinstance(pyname, builtins.BuiltinName):
            pyobject = pyname.get_object()
            if isinstance(pyobject, builtins.BuiltinFunction):
                return 'function'
            elif isinstance(pyobject, builtins.BuiltinClass):
                return 'class'
            elif isinstance(pyobject, builtins.BuiltinObject) or \
                    isinstance(pyobject, builtins.BuiltinName):
                return 'instance'
        elif isinstance(pyname, pynames.ImportedModule):
            return 'module'
        elif isinstance(pyname, pynames.ImportedName) or \
                isinstance(pyname, pynames.DefinedName):
            pyobject = pyname.get_object()
            if isinstance(pyobject, pyobjects.AbstractFunction):
                return 'function'
            if isinstance(pyobject, pyobjects.AbstractClass):
                return 'class'
        return 'instance'

    def _get_scope(self, scope):
        if isinstance(self.pyname, builtins.BuiltinName):
            return 'builtin'
        if isinstance(self.pyname, pynames.ImportedModule) or \
           isinstance(self.pyname, pynames.ImportedName):
            return 'imported'
        return scope

    def get_doc(self):
        """Get the proposed object's docstring.

        Returns None if it can not be get.
        """
        if not self.pyname:
            return None
        pyobject = self.pyname.get_object()
        if not hasattr(pyobject, 'get_doc'):
            return None
        return self.pyname.get_object().get_doc()

    @property
    def kind(self):
        warnings.warn("the proposal's `kind` property is deprecated, "
                      "use `scope` instead")
        return self.scope


# leaved for backward compatibility
CodeAssistProposal = CompletionProposal


class NamedParamProposal(CompletionProposal):
    """A parameter keyword completion proposal

    Holds reference to ``_function`` -- the function which
    parameter ``name`` belongs to. This allows to determine
    default value for this parameter.
    """
    def __init__(self, name, function):
        self.argname = name
        name = '%s=' % name
        super(NamedParamProposal, self).__init__(name, 'parameter_keyword')
        self._function = function

    def get_default(self):
        """Get a string representation of a param's default value.

        Returns None if there is no default value for this param.
        """
        definfo = functionutils.DefinitionInfo.read(self._function)
        for arg, default in definfo.args_with_defaults:
            if self.argname == arg:
                return default
        return None


def sorted_proposals(proposals, scopepref=None, typepref=None):
    """Sort a list of proposals

    Return a sorted list of the given `CodeAssistProposal`\s.

    `scopepref` can be a list of proposal scopes.  Defaults to
    ``['parameter_keyword', 'local', 'global', 'imported',
    'attribute', 'builtin', 'keyword']``.

    `typepref` can be a list of proposal types.  Defaults to
    ``['class', 'function', 'instance', 'module', None]``.
    (`None` stands for completions with no type like keywords.)
    """
    sorter = _ProposalSorter(proposals, scopepref, typepref)
    return sorter.get_sorted_proposal_list()


def starting_expression(source_code, offset):
    """Return the expression to complete"""
    word_finder = worder.Worder(source_code, True)
    expression, starting, starting_offset = \
        word_finder.get_splitted_primary_before(offset)
    if expression:
        return expression + '.' + starting
    return starting


def default_templates():
    warnings.warn('default_templates() is deprecated.',
                  DeprecationWarning, stacklevel=2)
    return {}


class _PythonCodeAssist(object):

    def __init__(self, project, source_code, offset, resource=None,
                 maxfixes=1, later_locals=True):
        self.project = project
        self.code = source_code
        self.resource = resource
        self.maxfixes = maxfixes
        self.later_locals = later_locals
        self.word_finder = worder.Worder(source_code, True)
        self.expression, self.starting, self.offset = \
            self.word_finder.get_splitted_primary_before(offset)

    keywords = keyword.kwlist

    def _find_starting_offset(self, source_code, offset):
        current_offset = offset - 1
        while current_offset >= 0 and (source_code[current_offset].isalnum() or
                                       source_code[current_offset] in '_'):
            current_offset -= 1
        return current_offset + 1

    def _matching_keywords(self, starting):
        result = []
        for kw in self.keywords:
            if kw.startswith(starting):
                result.append(CompletionProposal(kw, 'keyword'))
        return result

    def __call__(self):
        if self.offset > len(self.code):
            return []
        completions = list(self._code_completions().values())
        if self.expression.strip() == '' and self.starting.strip() != '':
            completions.extend(self._matching_keywords(self.starting))
        return completions

    def _dotted_completions(self, module_scope, holding_scope):
        result = {}
        found_pyname = rope.base.evaluate.eval_str(holding_scope,
                                                   self.expression)
        if found_pyname is not None:
            element = found_pyname.get_object()
            compl_scope = 'attribute'
            if isinstance(element, (pyobjectsdef.PyModule,
                                    pyobjectsdef.PyPackage)):
                compl_scope = 'imported'
            for name, pyname in element.get_attributes().items():
                if name.startswith(self.starting):
                    result[name] = CompletionProposal(name, compl_scope,
                                                      pyname)
        return result

    def _undotted_completions(self, scope, result, lineno=None):
        if scope.parent is not None:
            self._undotted_completions(scope.parent, result)
        if lineno is None:
            names = scope.get_propagated_names()
        else:
            names = scope.get_names()
        for name, pyname in names.items():
            if name.startswith(self.starting):
                compl_scope = 'local'
                if scope.get_kind() == 'Module':
                    compl_scope = 'global'
                if lineno is None or self.later_locals or \
                   not self._is_defined_after(scope, pyname, lineno):
                    result[name] = CompletionProposal(name, compl_scope,
                                                      pyname)

    def _from_import_completions(self, pymodule):
        module_name = self.word_finder.get_from_module(self.offset)
        if module_name is None:
            return {}
        pymodule = self._find_module(pymodule, module_name)
        result = {}
        for name in pymodule:
            if name.startswith(self.starting):
                result[name] = CompletionProposal(name, scope='global',
                                                  pyname=pymodule[name])
        return result

    def _find_module(self, pymodule, module_name):
        dots = 0
        while module_name[dots] == '.':
            dots += 1
        pyname = pynames.ImportedModule(pymodule,
                                        module_name[dots:], dots)
        return pyname.get_object()

    def _is_defined_after(self, scope, pyname, lineno):
        location = pyname.get_definition_location()
        if location is not None and location[1] is not None:
            if location[0] == scope.pyobject.get_module() and \
               lineno <= location[1] <= scope.get_end():
                return True

    def _code_completions(self):
        lineno = self.code.count('\n', 0, self.offset) + 1
        fixer = fixsyntax.FixSyntax(self.project, self.code,
                                    self.resource, self.maxfixes)
        pymodule = fixer.get_pymodule()
        module_scope = pymodule.get_scope()
        code = pymodule.source_code
        lines = code.split('\n')
        result = {}
        start = fixsyntax._logical_start(lines, lineno)
        indents = fixsyntax._get_line_indents(lines[start - 1])
        inner_scope = module_scope.get_inner_scope_for_line(start, indents)
        if self.word_finder.is_a_name_after_from_import(self.offset):
            return self._from_import_completions(pymodule)
        if self.expression.strip() != '':
            result.update(self._dotted_completions(module_scope, inner_scope))
        else:
            result.update(self._keyword_parameters(module_scope.pyobject,
                                                   inner_scope))
            self._undotted_completions(inner_scope, result, lineno=lineno)
        return result

    def _keyword_parameters(self, pymodule, scope):
        offset = self.offset
        if offset == 0:
            return {}
        word_finder = worder.Worder(self.code, True)
        if word_finder.is_on_function_call_keyword(offset - 1):
            function_parens = word_finder.\
                find_parens_start_from_inside(offset - 1)
            primary = word_finder.get_primary_at(function_parens - 1)
            try:
                function_pyname = rope.base.evaluate.\
                    eval_str(scope, primary)
            except exceptions.BadIdentifierError:
                return {}
            if function_pyname is not None:
                pyobject = function_pyname.get_object()
                if isinstance(pyobject, pyobjects.AbstractFunction):
                    pass
                elif isinstance(pyobject, pyobjects.AbstractClass) and \
                        '__init__' in pyobject:
                    pyobject = pyobject['__init__'].get_object()
                elif '__call__' in pyobject:
                    pyobject = pyobject['__call__'].get_object()
                if isinstance(pyobject, pyobjects.AbstractFunction):
                    param_names = []
                    param_names.extend(
                        pyobject.get_param_names(special_args=False))
                    result = {}
                    for name in param_names:
                        if name.startswith(self.starting):
                            result[name + '='] = NamedParamProposal(
                                name, pyobject
                            )
                    return result
        return {}


class _ProposalSorter(object):
    """Sort a list of code assist proposals"""

    def __init__(self, code_assist_proposals, scopepref=None, typepref=None):
        self.proposals = code_assist_proposals
        if scopepref is None:
            scopepref = ['parameter_keyword', 'local', 'global', 'imported',
                         'attribute', 'builtin', 'keyword']
        self.scopepref = scopepref
        if typepref is None:
            typepref = ['class', 'function', 'instance', 'module', None]
        self.typerank = dict((type, index)
                             for index, type in enumerate(typepref))

    def get_sorted_proposal_list(self):
        """Return a list of `CodeAssistProposal`"""
        proposals = {}
        for proposal in self.proposals:
            proposals.setdefault(proposal.scope, []).append(proposal)
        result = []
        for scope in self.scopepref:
            scope_proposals = proposals.get(scope, [])
            scope_proposals = [proposal for proposal in scope_proposals
                               if proposal.type in self.typerank]
            scope_proposals.sort(key=self._proposal_key)
            result.extend(scope_proposals)
        return result

    def _proposal_key(self, proposal1):
        def _underline_count(name):
             return sum(1 for c in name if c == "_")
        return (self.typerank.get(proposal1.type, 100),
                _underline_count(proposal1.name),
                proposal1.name)
        #if proposal1.type != proposal2.type:
        #    return cmp(self.typerank.get(proposal1.type, 100),
        #               self.typerank.get(proposal2.type, 100))
        #return self._compare_underlined_names(proposal1.name,
        #                                      proposal2.name)


class PyDocExtractor(object):

    def get_doc(self, pyobject):
        if isinstance(pyobject, pyobjects.AbstractFunction):
            return self._get_function_docstring(pyobject)
        elif isinstance(pyobject, pyobjects.AbstractClass):
            return self._get_class_docstring(pyobject)
        elif isinstance(pyobject, pyobjects.AbstractModule):
            return self._trim_docstring(pyobject.get_doc())
        return None

    def get_calltip(self, pyobject, ignore_unknown=False, remove_self=False):
        try:
            if isinstance(pyobject, pyobjects.AbstractClass):
                pyobject = pyobject['__init__'].get_object()
            if not isinstance(pyobject, pyobjects.AbstractFunction):
                pyobject = pyobject['__call__'].get_object()
        except exceptions.AttributeNotFoundError:
            return None
        if ignore_unknown and not isinstance(pyobject, pyobjects.PyFunction):
            return
        if isinstance(pyobject, pyobjects.AbstractFunction):
            result = self._get_function_signature(pyobject, add_module=True)
            if remove_self and self._is_method(pyobject):
                return result.replace('(self)', '()').replace('(self, ', '(')
            return result

    def _get_class_docstring(self, pyclass):
        contents = self._trim_docstring(pyclass.get_doc(), 2)
        supers = [super.get_name() for super in pyclass.get_superclasses()]
        doc = 'class %s(%s):\n\n' % (pyclass.get_name(), ', '.join(supers)) \
            + contents

        if '__init__' in pyclass:
            init = pyclass['__init__'].get_object()
            if isinstance(init, pyobjects.AbstractFunction):
                doc += '\n\n' + self._get_single_function_docstring(init)
        return doc

    def _get_function_docstring(self, pyfunction):
        functions = [pyfunction]
        if self._is_method(pyfunction):
            functions.extend(self._get_super_methods(pyfunction.parent,
                                                     pyfunction.get_name()))
        return '\n\n'.join([self._get_single_function_docstring(function)
                            for function in functions])

    def _is_method(self, pyfunction):
        return isinstance(pyfunction, pyobjects.PyFunction) and \
            isinstance(pyfunction.parent, pyobjects.PyClass)

    def _get_single_function_docstring(self, pyfunction):
        signature = self._get_function_signature(pyfunction)
        docs = self._trim_docstring(pyfunction.get_doc(), indents=2)
        return signature + ':\n\n' + docs

    def _get_super_methods(self, pyclass, name):
        result = []
        for super_class in pyclass.get_superclasses():
            if name in super_class:
                function = super_class[name].get_object()
                if isinstance(function, pyobjects.AbstractFunction):
                    result.append(function)
            result.extend(self._get_super_methods(super_class, name))
        return result

    def _get_function_signature(self, pyfunction, add_module=False):
        location = self._location(pyfunction, add_module)
        if isinstance(pyfunction, pyobjects.PyFunction):
            info = functionutils.DefinitionInfo.read(pyfunction)
            return location + info.to_string()
        else:
            return '%s(%s)' % (location + pyfunction.get_name(),
                               ', '.join(pyfunction.get_param_names()))

    def _location(self, pyobject, add_module=False):
        location = []
        parent = pyobject.parent
        while parent and not isinstance(parent, pyobjects.AbstractModule):
            location.append(parent.get_name())
            location.append('.')
            parent = parent.parent
        if add_module:
            if isinstance(pyobject, pyobjects.PyFunction):
                location.insert(0, self._get_module(pyobject))
            if isinstance(parent, builtins.BuiltinModule):
                location.insert(0, parent.get_name() + '.')
        return ''.join(location)

    def _get_module(self, pyfunction):
        module = pyfunction.get_module()
        if module is not None:
            resource = module.get_resource()
            if resource is not None:
                return libutils.modname(resource) + '.'
        return ''

    def _trim_docstring(self, docstring, indents=0):
        """The sample code from :PEP:`257`"""
        if not docstring:
            return ''
        # Convert tabs to spaces (following normal Python rules)
        # and split into a list of lines:
        lines = docstring.expandtabs().splitlines()
        # Determine minimum indentation (first line doesn't count):
        indent = sys.maxsize
        for line in lines[1:]:
            stripped = line.lstrip()
            if stripped:
                indent = min(indent, len(line) - len(stripped))
        # Remove indentation (first line is special):
        trimmed = [lines[0].strip()]
        if indent < sys.maxsize:
            for line in lines[1:]:
                trimmed.append(line[indent:].rstrip())
        # Strip off trailing and leading blank lines:
        while trimmed and not trimmed[-1]:
            trimmed.pop()
        while trimmed and not trimmed[0]:
            trimmed.pop(0)
        # Return a single string:
        return '\n'.join((' ' * indents + line for line in trimmed))


# Deprecated classes

class TemplateProposal(CodeAssistProposal):
    def __init__(self, name, template):
        warnings.warn('TemplateProposal is deprecated.',
                      DeprecationWarning, stacklevel=2)
        super(TemplateProposal, self).__init__(name, 'template')
        self.template = template


class Template(object):

    def __init__(self, template):
        self.template = template
        warnings.warn('Template is deprecated.',
                      DeprecationWarning, stacklevel=2)

    def variables(self):
        return []

    def substitute(self, mapping):
        return self.template

    def get_cursor_location(self, mapping):
        return len(self.template)
