"""Integration with Rope library."""

from __future__ import absolute_import, print_function

import os.path
import re
import site
import sys

from rope.base import project, libutils, exceptions, change, worder, pycore
from rope.base.fscommands import FileSystemCommands # noqa
from rope.base.taskhandle import TaskHandle # noqa
from rope.contrib import autoimport as rope_autoimport, codeassist, findit, generate # noqa
from rope.refactor import ModuleToPackage, ImportOrganizer, rename, extract, inline, usefunction, move, change_signature, importutils # noqa

from .environment import env


def look_ropeproject(path):
    """Search for ropeproject in current and parent dirs.

    :return str|None: A finded path

    """
    env.debug('Look project', path)
    p = os.path.abspath(path)

    while True:
        if '.ropeproject' in os.listdir(p):
            return p

        new_p = os.path.abspath(os.path.join(p, ".."))
        if new_p == p:
            return path

        p = new_p


@env.catch_exceptions
def completions():
    """ Search completions.

    :return None:

    """
    row, col = env.cursor
    if env.var('a:findstart', True):
        count = 0
        for char in reversed(env.current.line[:col]):
            if not re.match(r'[\w\d]', char):
                break
            count += 1
        env.debug('Complete find start', (col - count))
        return env.stop(col - count)

    base = env.var('a:base')
    source, offset = env.get_offset_params((row, col), base)
    proposals = get_proporsals(source, offset, base)
    return env.stop(proposals)


FROM_RE = re.compile(r'^\s*from\s+[\.\w\d_]+$')


@env.catch_exceptions
def complete(dot=False):
    """ Ctrl+Space completion.

    :return bool: success

    """
    row, col = env.cursor
    source, offset = env.get_offset_params()

    cline = env.current.line[:col]
    env.debug('dot completion', cline)
    if FROM_RE.match(cline) or cline.endswith('..') or cline.endswith('\.'):  # noqa
        return env.stop("")

    proposals = get_proporsals(source, offset, dot=dot)
    if not proposals:
        return False

    prefix = proposals[0]['word']

    # Find common part
    for p in proposals:
        common = len([
            c1 for c1, c2 in zip(prefix, p['word']) if c1 == c2 and c1 != ' '
        ])
        prefix = prefix[:common]
    s_offset = codeassist.starting_offset(source, offset)
    p_prefix = prefix[offset - s_offset:]
    line = env.lines[row - 1]
    cline = line[:col] + p_prefix + line[col:]
    if cline != line:
        env.curbuf[row - 1] = env.prepare_value(cline, dumps=False)
    env.current.window.cursor = (row, col + len(p_prefix))
    env.run('complete', col - len(prefix) + len(p_prefix) + 1, proposals)
    return True


def get_proporsals(source, offset, base='', dot=False):
    """ Code assist.

    :return str:

    """
    with RopeContext() as ctx:  # noqa

        try:
            proposals = codeassist.code_assist(
                ctx.project, source, offset, ctx.resource, maxfixes=3,
                later_locals=False)

        except exceptions.ModuleSyntaxError:
            proposals = []

        proposals = sorted(proposals, key=_sort_proporsals)

        out = []
        preview = 'preview' in ctx.options.get('completeopt')
        for p in proposals:
            out.append(dict(
                word=p.name,
                menu=p.type,
                kind=p.scope + ':',
                info=p.get_doc() or "No docs." if preview else "",
            ))

        out = _get_autoimport_proposals(out, ctx, source, offset, dot=dot)

    return out


@env.catch_exceptions
def goto():
    """ Goto definition. """
    with RopeContext() as ctx:
        source, offset = env.get_offset_params()

        found_resource, line = codeassist.get_definition_location(
            ctx.project, source, offset, ctx.resource, maxfixes=3)

        if not found_resource:
            env.error('Definition not found')
            return

        env.goto_file(
            found_resource.real_path,
            cmd=ctx.options.get('goto_definition_cmd'))
        env.goto_line(line)


@env.catch_exceptions
def show_doc():
    """ Show documentation. """
    with RopeContext() as ctx:
        source, offset = env.get_offset_params()
        try:
            doc = codeassist.get_doc(
                ctx.project, source, offset, ctx.resource, maxfixes=3)
            if not doc:
                raise exceptions.BadIdentifierError
            env.let('l:output', doc.split('\n'))
        except exceptions.BadIdentifierError:
            env.error("No documentation found.")


def find_it():
    """ Find occurrences. """
    with RopeContext() as ctx:
        _, offset = env.get_offset_params()
        try:
            occurrences = findit.find_occurrences(
                ctx.project, ctx.resource, offset)
        except exceptions.BadIdentifierError:
            occurrences = []

    lst = []
    for oc in occurrences:
        lst.append(dict(
            filename=oc.resource.path,
            text=env.lines[oc.lineno - 1] if oc.resource.real_path == env.curbuf.name else "", # noqa
            lnum=oc.lineno,
        ))
    env.let('loclist._loclist', lst)


def update_python_path(paths):
    """ Update sys.path and make sure the new items come first. """
    old_sys_path_items = list(sys.path)

    for path in paths:
        # see if it is a site dir
        if path.find('site-packages') != -1:
            site.addsitedir(path)

        else:
            sys.path.insert(0, path)

    # Reorder sys.path so new directories at the front.
    new_sys_path_items = set(sys.path) - set(old_sys_path_items)
    sys.path = list(new_sys_path_items) + old_sys_path_items


def organize_imports():
    """ Organize imports in current file. """
    with RopeContext() as ctx:
        organizer = ImportOrganizer(ctx.project)
        changes = organizer.organize_imports(ctx.resource)
        if changes is not None:
            progress = ProgressHandler('Organize imports')
            ctx.project.do(changes, task_handle=progress.handle)
            reload_changes(changes)


@env.catch_exceptions
def regenerate():
    """ Clear cache. """
    with RopeContext() as ctx:
        ctx.project.pycore._invalidate_resource_cache(ctx.resource) # noqa
        ctx.importer.generate_cache()
        ctx.project.sync()


def new():
    """ Create a new project. """
    root = None
    if env.var('a:0') != '0':
        root = env.var('a:1')
    else:
        default = env.var('g:pymode_rope_project_root')
        if not default:
            default = env.var('getcwd()')
        root = env.var('input("Enter project root: ", "%s")' % default)
    ropefolder = env.var('g:pymode_rope_ropefolder')
    prj = project.Project(projectroot=root, ropefolder=ropefolder)
    prj.close()
    env.message("Project is opened: %s" % root)


def undo():
    """ Undo last changes.

    :return bool:

    """
    with RopeContext() as ctx:
        changes = ctx.project.history.tobe_undone
        if changes is None:
            env.error('Nothing to undo!')
            return False

        if env.user_confirm('Undo [%s]?' % str(changes)):
            progress = ProgressHandler('Undo %s' % str(changes))
            for c in ctx.project.history.undo(task_handle=progress.handle):
                reload_changes(c)


def redo():
    """ Redo last changes.

    :return bool:

    """
    with RopeContext() as ctx:
        changes = ctx.project.history.tobe_redone
        if changes is None:
            env.error('Nothing to redo!')
            return False

        if env.user_confirm('Redo [%s]?' % str(changes)):
            progress = ProgressHandler('Redo %s' % str(changes))
            for c in ctx.project.history.redo(task_handle=progress.handle):
                reload_changes(c)


def cache_project(cls):
    """ Cache projects.

    :return func:

    """
    projects = dict()
    resources = dict()

    def get_ctx(*args, **kwargs):
        path = env.curbuf.name
        if resources.get(path):
            return resources.get(path)

        project_path = env.var('g:pymode_rope_project_root')
        if not project_path:
            project_path = env.curdir
            env.debug('Look ctx', project_path)
            if env.var('g:pymode_rope_lookup_project', True):
                project_path = look_ropeproject(project_path)

        if not os.path.exists(project_path):
            env.error("Rope project root not exist: %s" % project_path)
            ctx = None

        else:
            ctx = projects.get(project_path)

        if not ctx:
            projects[project_path] = ctx = cls(path, project_path)

        resources[path] = ctx
        return ctx
    return get_ctx


def autoimport():
    """ Autoimport modules.

    :return bool:

    """
    word = env.var('a:word')
    if not word:
        env.error("Should be word under cursor.")
        return False

    with RopeContext() as ctx:
        if not ctx.importer.names:
            ctx.generate_autoimport_cache()
        modules = ctx.importer.get_modules(word)
        if not modules:
            env.message('Global name %s not found.' % word)
            return False

        if len(modules) == 1:
            _insert_import(word, modules[0], ctx)

        else:
            module = env.user_input_choices(
                'Which module to import:', *modules)
            _insert_import(word, module, ctx)

        return True


@cache_project
class RopeContext(object):

    """ A context manager to have a rope project context. """

    projects = {}
    resource = {}

    def __init__(self, path=None, project_path=None):
        """ Init Rope context. """
        self.path = path

        self.project = project.Project(project_path, fscommands=FileSystemCommands())

        self.importer = rope_autoimport.AutoImport(
            project=self.project, observe=False)

        update_python_path(self.project.prefs.get('python_path', []))

        self.resource = None
        self.current = None
        self.options = dict(
            completeopt=env.var('&completeopt'),
            autoimport=env.var('g:pymode_rope_autoimport', True),
            autoimport_modules=env.var('g:pymode_rope_autoimport_modules'),
            goto_definition_cmd=env.var('g:pymode_rope_goto_definition_cmd'),
        )

        if os.path.exists("%s/__init__.py" % project_path):
            sys.path.append(project_path)

        if self.options.get('autoimport'):
            self.generate_autoimport_cache()

        env.debug('Context init', project_path)
        env.message('Init Rope project: %s' % project_path)

    def __enter__(self):
        """ Enter to Rope ctx. """
        env.let('g:pymode_rope_current', self.project.root.real_path)
        self.project.validate(self.project.root)
        self.resource = libutils.path_to_resource(
            self.project, env.curbuf.name, 'file')

        if not self.resource.exists() or os.path.isdir(
                self.resource.real_path):
            self.resource = None
        else:
            env.debug('Found resource', self.resource.path)

        return self

    def __exit__(self, t, value, traceback):
        """ Exit from Rope ctx. """
        if t is None:
            self.project.close()

    def generate_autoimport_cache(self):
        """ Update autoimport cache. """
        env.message('Regenerate autoimport cache.')
        modules = self.options.get('autoimport_modules', [])

        def _update_cache(importer, modules=None):
            importer.generate_cache()
            if modules:
                importer.generate_modules_cache(modules)
            importer.project.sync()

        _update_cache(self.importer, modules)


class ProgressHandler(object):

    """ Handle task progress. """

    def __init__(self, msg):
        """ Init progress handler. """
        self.handle = TaskHandle(name="refactoring_handle")
        self.handle.add_observer(self)
        self.message = msg

    def __call__(self):
        """ Show current progress. """
        percent_done = self.handle.current_jobset().get_percent_done()
        env.message('%s - done %s%%' % (self.message, percent_done))


_scope_weight = {
    'local': 10, 'attribute': 20, 'global': 30, 'imported': 40, 'builtin': 50}


def _sort_proporsals(p):
    return (
        _scope_weight.get(p.scope, 100), int(p.name.startswith('_')), p.name)


class Refactoring(object): # noqa

    """ Base class for refactor operations. """

    def run(self):
        """ Run refactoring.

        :return bool:

        """
        with RopeContext() as ctx:

            if not ctx.resource:
                env.error("You should save the file before refactoring.")
                return None

            try:
                env.message(self.__doc__)
                refactor = self.get_refactor(ctx)
                input_str = self.get_input_str(refactor, ctx)
                if not input_str:
                    return False

                action = env.user_input_choices(
                    'Choose what to do:', 'perform', 'preview',
                    'perform in class hierarchy',
                    'preview in class hierarchy')

                in_hierarchy = action.endswith("in class hierarchy")

                changes = self.get_changes(refactor, input_str, in_hierarchy)

                if not action:
                    return False

                if action.startswith('preview'):
                    print("\n   ")
                    print("-------------------------------")
                    print("\n%s\n" % changes.get_description())
                    print("-------------------------------\n\n")
                    if not env.user_confirm('Do the changes?'):
                        return False

                progress = ProgressHandler('Apply changes ...')
                ctx.project.do(changes, task_handle=progress.handle)
                reload_changes(changes)
            except exceptions.RefactoringError as e:
                env.error(str(e))

            except Exception as e: # noqa
                env.error('Unhandled exception in Pymode: %s' % e)

    @staticmethod
    def get_refactor(ctx):
        """ Get refactor object. """
        raise NotImplementedError

    @staticmethod
    def get_input_str(refactor, ctx):
        """ Get user input. Skip by default.

        :return bool: True

        """
        return True

    @staticmethod
    def get_changes(refactor, input_str, in_hierarchy=False):
        return refactor.get_changes(input_str)


class RenameRefactoring(Refactoring):

    """ Rename var/function/method/class. """

    def __init__(self, module=False):
        self.module = module
        super(RenameRefactoring, self).__init__()

    def get_refactor(self, ctx):
        """ Function description.

        :return Rename:

        """
        offset = None
        if not self.module:
            _, offset = env.get_offset_params()
        env.debug('Prepare rename', offset)
        return rename.Rename(ctx.project, ctx.resource, offset)

    def get_input_str(self, refactor, ctx):
        """ Return user input. """

        oldname = str(refactor.get_old_name())
        msg = 'Renaming method/variable. New name:'
        if self.module:
            msg = 'Renaming module. New name:'
        newname = env.user_input(msg, oldname)

        if newname == oldname:
            env.message("Nothing to do.")
            return False

        return newname

    @staticmethod
    def get_changes(refactor, input_str, in_hierarchy=False):
        """ Get changes.

        :return Changes:

        """
        return refactor.get_changes(input_str, in_hierarchy=in_hierarchy)


class ExtractMethodRefactoring(Refactoring):

    """ Extract method. """

    @staticmethod
    def get_input_str(refactor, ctx):
        """ Return user input. """

        return env.user_input('New method name:')

    @staticmethod
    def get_refactor(ctx):
        """ Function description.

        :return Rename:

        """
        cursor1, cursor2 = env.curbuf.mark('<'), env.curbuf.mark('>')
        _, offset1 = env.get_offset_params(cursor1)
        _, offset2 = env.get_offset_params(cursor2)
        return extract.ExtractMethod(
            ctx.project, ctx.resource, offset1, offset2)


class ExtractVariableRefactoring(Refactoring):

    """ Extract variable. """

    @staticmethod
    def get_input_str(refactor, ctx):
        """ Return user input. """

        return env.user_input('New variable name:')

    @staticmethod
    def get_refactor(ctx):
        """ Function description.

        :return Rename:

        """
        cursor1, cursor2 = env.curbuf.mark('<'), env.curbuf.mark('>')
        _, offset1 = env.get_offset_params(cursor1)
        _, offset2 = env.get_offset_params(cursor2)
        return extract.ExtractVariable(
            ctx.project, ctx.resource, offset1, offset2)


class InlineRefactoring(Refactoring):

    """ Inline variable/method. """

    @staticmethod
    def get_refactor(ctx):
        """ Function description.

        :return Rename:

        """
        _, offset = env.get_offset_params()
        return inline.create_inline(ctx.project, ctx.resource, offset)

    @staticmethod
    def get_changes(refactor, input_str, in_hierarchy=False):
        """ Get changes.

        :return Changes:

        """
        return refactor.get_changes()


class UseFunctionRefactoring(Refactoring):

    """ Use selected function as possible. """

    @staticmethod
    def get_refactor(ctx):
        """ Function description.

        :return Rename:

        """
        _, offset = env.get_offset_params()
        return usefunction.UseFunction(ctx.project, ctx.resource, offset)

    @staticmethod
    def get_changes(refactor, input_str, in_hierarchy=False):
        """ Get changes.

        :return Changes:

        """
        return refactor.get_changes()


class ModuleToPackageRefactoring(Refactoring):

    """ Convert module to package. """

    @staticmethod
    def get_refactor(ctx):
        """ Function description.

        :return Rename:

        """
        return ModuleToPackage(ctx.project, ctx.resource)

    @staticmethod
    def get_changes(refactor, input_str, in_hierarchy=False):
        """ Get changes.

        :return Changes:

        """
        return refactor.get_changes()


class MoveRefactoring(Refactoring):

    """ Move method/module to other class/global. """

    @staticmethod
    def get_input_str(refactor, ctx):
        """ Get destination.

        :return str:

        """

        return env.user_input('Enter destination:')

    @staticmethod
    def get_refactor(ctx):
        """ Function description.

        :return Rename:

        """
        _, offset = env.get_offset_params()
        if offset == 0:
            offset = None
        return move.create_move(ctx.project, ctx.resource, offset)


class ChangeSignatureRefactoring(Refactoring):

    """ Change function signature (add/remove/sort arguments). """

    @staticmethod
    def get_input_str(refactor, ctx):
        """ Get destination.

        :return str:

        """
        args = refactor.get_args()
        default = ', '.join(a[0] for a in args)
        return env.user_input('Change the signature:', default)

    @staticmethod
    def get_refactor(ctx):
        """ Function description.

        :return Rename:

        """
        _, offset = env.get_offset_params()
        return change_signature.ChangeSignature(
            ctx.project, ctx.resource, offset)

    def get_changes(self, refactor, input_string, in_hierarchy=False):
        """ Function description.

        :return Rope.changes:

        """
        args = re.sub(r'[\s\(\)]+', '', input_string).split(',')
        olds = [arg[0] for arg in refactor.get_args()]

        changers = []
        for arg in [a for a in olds if a not in args]:
            changers.append(change_signature.ArgumentRemover(olds.index(arg)))
            olds.remove(arg)

        order = []
        for index, arg in enumerate(args):
            if arg not in olds:
                changers.append(change_signature.ArgumentAdder(index, arg))
                olds.insert(index, arg)
            order.append(olds.index(arg))

        changers.append(change_signature.ArgumentReorderer(
            order, autodef='None'))

        return refactor.get_changes(changers, in_hierarchy=in_hierarchy)


class GenerateElementRefactoring(Refactoring):

    """ Class description. """

    def __init__(self, kind, *args, **kwargs):
        """ Function description. """
        self.kind = kind
        super(GenerateElementRefactoring, self).__init__(*args, **kwargs)

    def get_refactor(self, ctx):
        """ Function description.

        :return Rename:

        """
        _, offset = env.get_offset_params()
        return generate.create_generate(
            self.kind, ctx.project, ctx.resource, offset)

    def get_changes(self, refactor, input_str, in_hierarchy=False):
        """ Function description.

        :return Rope.changes:

        """

        return refactor.get_changes()


@env.catch_exceptions
def reload_changes(changes):
    """ Reload changed buffers. """

    resources = changes.get_changed_resources()
    moved = _get_moved_resources(changes) # noqa
    current = env.curbuf.number

    for f in resources:
        bufnr = env.var('bufnr("%s")' % f.real_path)
        env.goto_buffer(bufnr)

        path = env.curbuf.name
        if f in moved:
            path = moved[f].real_path

        env.debug('Reload', f.real_path, path, bufnr)
        env.goto_file(path, 'e!', force=True)
        env.message("%s has been changed." % f.real_path, history=True)

    env.goto_buffer(current)


def _get_moved_resources(changes):

    moved = dict()

    if isinstance(changes, change.ChangeSet):
        for c in changes.changes:
            moved.update(_get_moved_resources(c))

    if isinstance(changes, change.MoveResource):
        moved[changes.resource] = changes.new_resource

    return moved


def _get_autoimport_proposals(out, ctx, source, offset, dot=False):

    if not ctx.options.get('autoimport') or dot:
        return out

    if '.' in codeassist.starting_expression(source, offset):
        return out

    current_offset = offset - 1
    while current_offset > 0 and (
            source[current_offset].isalnum() or source[current_offset] == '_'):
        current_offset -= 1
    starting = source[current_offset:offset]
    starting = starting.strip()

    if not starting:
        return out

    for assist in ctx.importer.import_assist(starting):
        out.append(dict(
            abbr=' : '.join(assist),
            word=assist[0],
            kind='autoimport:',
        ))

    return out


@env.catch_exceptions
def complete_check():
    """ Function description.

    :return bool:

    """

    row, column = env.cursor
    line = env.lines[row - 1]
    word_finder = worder.Worder(line, True)
    parent, name, _ = word_finder.get_splitted_primary_before(column - 1)
    if parent:
        return False

    with RopeContext() as ctx:
        modules = ctx.importer.get_modules(name)

        if not modules:
            return False

        if name in ctx.project.pycore.resource_to_pyobject(ctx.resource):
            return False

        if not env.user_confirm("Import %s?" % name, True):
            return False

        if len(modules) == 1:
            _insert_import(name, modules[0], ctx)

        else:
            module = env.user_input_choices('With module to import:', *modules)
            if module:
                _insert_import(name, module, ctx)


def _insert_import(name, module, ctx):
    if not ctx.resource:
        source, _ = env.get_offset_params()
        lineno = ctx.importer.find_insertion_line(source)
        line = 'from %s import %s' % (module, name)
        env.curbuf[lineno - 1:lineno - 1] = [
            env.prepare_value(line, dumps=False)]
        return True

    pyobject = ctx.project.pycore.resource_to_pyobject(ctx.resource)
    import_tools = importutils.ImportTools(ctx.project.pycore)
    module_imports = import_tools.module_imports(pyobject)
    new_import = importutils.FromImport(module, 0, [[name, None]])
    module_imports.add_import(new_import)
    changes = change.ChangeContents(
        ctx.resource, module_imports.get_changed_source())

    action = env.user_input_choices(
        'Choose what to do:', 'perform', 'preview')

    if not action:
        return False

    if action == 'preview':
        print("\n   ")
        print("-------------------------------")
        print("\n%s\n" % changes.get_description())
        print("-------------------------------\n\n")
        if not env.user_confirm('Do the changes?'):
            return False

    progress = ProgressHandler('Apply changes ...')
    ctx.project.do(changes, task_handle=progress.handle)
    reload_changes(changes)


# Monkey patch Rope
def find_source_folders(self, folder):
    """Look only python files an packages."""
    for resource in folder.get_folders():
        if self._is_package(resource):  # noqa
            return [folder]

    for resource in folder.get_files():
        if resource.name.endswith('.py'):
            return [folder]

    return []

pycore.PyCore._find_source_folders = find_source_folders  # noqa
