# SPDX-License-Identifier: GPL-2.0-or-later

# Copyright (c) 2009 www.stani.be

import inspect
import re


# regular expression constants
DEF_DOC = r'%s\s*(\(.*?\))'
DEF_SOURCE = r'def\s+%s\s*(\(.*?\)):'
RE_EMPTY_LINE = re.compile(r'^\s*\n')
RE_FLAG = re.MULTILINE | re.DOTALL
RE_NEWLINE = re.compile('\n+')
RE_SPACE = re.compile(r'\s+')
RE_DEF_COMPLETE = re.compile(
    # don't start with a quote
    '''(?:^|[^"'a-zA-Z0-9_])'''
    # start with a \w = [a-zA-Z0-9_]
    r'''((\w+'''
    # allow also dots and closed bracket pairs []
    r'''(?:\w|[.]|\[.+?\])*'''
    # allow empty string
    '''|)'''
    # allow opening bracket(s)
    r'''(?:\(|\s)*)$''')


def reduce_newlines(text):
    """Reduces multiple newlines to a single newline.

    :param text: text with multiple newlines
    :type text: str
    :returns: text with single newlines
    :rtype: str

    >>> reduce_newlines('hello\\n\\nworld')
    'hello\\nworld'
    """
    return RE_NEWLINE.sub('\n', text)


def reduce_spaces(text):
    """Reduces multiple whitespaces to a single space.

    :param text: text with multiple spaces
    :type text: str
    :returns: text with single spaces
    :rtype: str

    >>> reduce_spaces('hello    \\nworld')
    'hello world'
    """
    return RE_SPACE.sub(' ', text)


def get_doc(obj):
    """Get the doc string or comments for an object.

    :param object: object
    :returns: doc string
    :rtype: str

    >>> get_doc(abs)
    'abs(number) -> number\\n\\nReturn the absolute value of the argument.'
    """
    result = inspect.getdoc(obj) or inspect.getcomments(obj)
    return result and RE_EMPTY_LINE.sub('', result.rstrip()) or ''


def get_argspec(func, *, strip_self=True, doc=None, source=None):
    """Get argument specifications.

    :param strip_self: strip `self` from argspec
    :type strip_self: bool
    :param doc: doc string of func (optional)
    :type doc: str
    :param source: source code of func (optional)
    :type source: str
    :returns: argument specification
    :rtype: str

    >>> get_argspec(inspect.getclasstree)
    '(classes, unique=0)'
    >>> get_argspec(abs)
    '(number)'
    """
    # get the function object of the class
    try:
        func = func.__func__
    except AttributeError:
        pass
    # is callable?
    if not hasattr(func, '__call__'):
        return ''
    # func should have a name
    try:
        func_name = func.__name__
    except AttributeError:
        return ''
    # from docstring
    if doc is None:
        doc = get_doc(func)
    match = re.search(DEF_DOC % func_name, doc, RE_FLAG)
    # from source code
    if not match:
        if source is None:
            try:
                source = inspect.getsource(func)
            except (TypeError, IOError):
                source = ''
        if source:
            match = re.search(DEF_SOURCE % func_name, source, RE_FLAG)
    if match:
        argspec = reduce_spaces(match.group(1))
    else:
        # try with the inspect.getarg* functions
        try:
            argspec = inspect.formatargspec(*inspect.getfullargspec(func))
        except:
            try:
                argspec = inspect.formatargvalues(
                    *inspect.getargvalues(func))
            except:
                argspec = ''
        if strip_self:
            argspec = argspec.replace('self, ', '')
    return argspec


def complete(line, cursor, namespace):
    """Complete callable with calltip.

    :param line: incomplete text line
    :type line: str
    :param cursor: current character position
    :type cursor: int
    :param namespace: namespace
    :type namespace: dict
    :returns: (matches, world, scrollback)
    :rtype: (list of str, str, str)

    >>> import os
    >>> complete('os.path.isdir(', 14, {'os': os})[-1]
    'isdir(s)\\nReturn true if the pathname refers to an existing directory.'
    >>> complete('abs(', 4, {})[-1]
    'abs(number) -> number\\nReturn the absolute value of the argument.'
    """
    matches = []
    word = ''
    scrollback = ''
    match = RE_DEF_COMPLETE.search(line[:cursor])

    if match:
        word = match.group(1)
        func_word = match.group(2)
        try:
            func = eval(func_word, namespace)
        except Exception:
            func = None

        if func:
            doc = get_doc(func)
            argspec = get_argspec(func, doc=doc)
            scrollback = func_word.split('.')[-1] + (argspec or '()')
            if doc.startswith(scrollback):
                scrollback = doc
            elif doc:
                scrollback += '\n' + doc
            scrollback = reduce_newlines(scrollback)

    return matches, word, scrollback
