# Copyright (c) 2017  Niklas Rosenstein
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
This module provides implementations to load documentation information from
an identifier as it is specified in the `pydocmk.yml:generate` configuration
key. A loader basically takes care of loading the documentation content for
that name, but is not supposed to apply preprocessing.
"""

from .imputil import import_object_with_scope
import collections
import inspect
import types
import uuid
from yapf.yapflib.yapf_api import FormatCode

function_types = (types.FunctionType, types.LambdaType, types.MethodType,
                  types.BuiltinFunctionType, types.BuiltinMethodType)
if hasattr(types, 'UnboundMethodType'):
    function_types += (types.UnboundMethodType,)


NotSet = object()


def trim(docstring):
    if not docstring:
        return ''
    lines = [x.rstrip() for x in docstring.split('\n')]
    lines[0] = lines[0].lstrip()

    indent = None
    for i, line in enumerate(lines):
        if i == 0 or not line:
            continue
        new_line = line.lstrip()
        delta = len(line) - len(new_line)
        if indent is None:
            indent = delta
        elif delta > indent:
            new_line = ' ' * (delta - indent) + new_line
        lines[i] = new_line

    return '\n'.join(lines)


class PythonLoader:
    """
    Expects absolute identifiers to import with #import_object_with_scope().
    """

    def __init__(self, config):
        self.config = config

    def load_section(self, section):
        """
        Loads the contents of a #Section. The `section.identifier` is the name
        of the object that we need to load.

        # Arguments
          section (Section): The section to load. Fill the `section.title` and
            `section.content` values. Optionally, `section.loader_context` can
            be filled with custom arbitrary data to reference at a later point.
        """

        assert section.identifier is not None
        obj, scope = import_object_with_scope(section.identifier)

        if '.' in section.identifier:
            default_title = section.identifier.rsplit('.', 1)[1]
        else:
            default_title = section.identifier

        section.title = getattr(obj, '__name__', default_title)
        section.content = trim(get_docstring(obj))
        section.loader_context = {'obj': obj, 'scope': scope}

        strip_self_param = inspect.isclass(scope)
        if isinstance(obj, (staticmethod, classmethod)):
            strip_self_param = strip_self_param and not isinstance(
                obj, staticmethod)
            obj = obj.__func__

        # Add the function signature in a code-block.
        if callable(obj):
            owner_class = scope if inspect.isclass(scope) else None
            sig = get_function_signature(obj, owner_class, strip_self_param)
            section.content = f'```python\n{sig.strip()}\n```\n' + section.content


def get_docstring(function):
    if isinstance(function, (staticmethod, classmethod)):
        return function.__func__.__doc__ or ''
    elif hasattr(function, '__name__') or isinstance(function, property):
        return function.__doc__ or ''
    elif hasattr(function, '__call__'):
        return function.__call__.__doc__ or ''
    elif isinstance(function, type):
        return function.__doc__ or ''
    elif hasattr(function, '__doc__'):
        return function.__doc__ or ''
    else:
        return ''


def get_full_arg_spec(func):
    if hasattr(inspect, 'getfullargspec'):
        spec = inspect.getfullargspec(func)
        return {k: getattr(spec, k) for k in dir(spec) if not k.startswith('_')}
    spec = inspect.getargspec(func)
    return {
        'args': spec.args,
        'varargs': spec.varargs,
        'varkw': spec.keywords,
        'defaults': spec.defaults,
        'kwonlyargs': [],
        'kwonlydefaults': [],
        'annotations': {}
    }


class Parameter:
    POS = 'POS'
    KWONLY = 'KWONLY'
    VARARGS_POS = 'VARARGS_POS'
    VARARGS_KW = 'VARARGS_KW'

    Value = collections.namedtuple('Value', 'value')

    def __init__(self, kind, name, annotation=None, default=None):
        self.kind = kind
        self.name = name
        self.annotation = annotation
        self.default = default

    def __repr__(self):
        return 'Parameter(kind={}, name={!r}, annotation={!r}, default={!r})'\
            .format(self.kind, self.name, self.annotation, self.default)

    def __str__(self):
        result = self.name
        if self.annotation:
            result += ': ' + repr(self.annotation.value)
        if self.default:
            result += ' = ' + repr(self.default.value)
        if self.kind == self.VARARGS_POS:
            result = '*' + result
        if self.kind == self.VARARGS_KW:
            result = '**' + result
        return result

    def replace(self, name=NotSet, annotation=NotSet,
                default=NotSet, kind=NotSet):
        return Parameter(
            self.kind if kind is NotSet else kind,
            self.name if name is NotSet else name,
            self.annotation if annotation is NotSet else annotation,
            self.default if default is NotSet else default)


def get_paramaters_from_arg_spec(argspec, strip_self=False):
    args = [Parameter(Parameter.POS, x) for x in argspec['args'][:]]
    try:
        if argspec['defaults']:
            offset = len(args) - len(argspec['defaults'])
            for i, default in enumerate(argspec['defaults']):
                args[i + offset] = args[i +
                                        offset].replace(default=Parameter.Value(default))

        if argspec['varargs']:
            args.append(Parameter(Parameter.VARARGS_POS, argspec['varargs']))

        for name in argspec['kwonlyargs']:
            args.append(Parameter(Parameter.KWONLY, name))

        if argspec['varkw']:
            args.append(Parameter(Parameter.VARARGS_KW, argspec['varkw']))

        if strip_self and args and args[0].name in ('cls', 'self'):
            args.pop(0)

        for i, param in enumerate(args):
            if param.name in argspec['annotations']:
                annotation = argspec['annotations'][param.name]
                args[i] = param.replace(annotation=Parameter.Value(annotation))
    except:
        pass
    return args


def format_parameters_list(parameters):
    found_kwonly = False
    result = []
    for param in parameters:
        if param.kind == Parameter.KWONLY and not found_kwonly:
            found_kwonly = True
            result.append('*')
        try:
            result.append(str(param))
        except TypeError:
            result.append(repr(param))
    return ', '.join(result)


def get_function_signature(
    function,
    owner_class=None,
    strip_self_param=False,
    show_module=False,
    pretty=True,
):
    # Get base name.
    name_parts = []
    if show_module:
        name_parts.append(function.__module__)
    if owner_class:
        name_parts.append(owner_class.__name__)
    if hasattr(function, '__name__'):
        name_parts.append(function.__name__)
    else:
        name_parts.append(type(function).__name__)
        name_parts.append('__call__')
        function = function.__call__
    name = '.'.join(name_parts)

    try:
        argspec = get_full_arg_spec(function)
    except TypeError:
        parameters = []
    else:
        parameters = get_paramaters_from_arg_spec(
            argspec, strip_self=strip_self_param)

    # Prettify annotations.
    class repr_str(str):
        def __repr__(self):
            return self

    def prettify(val):  # type: (Parameter.Value) -> Parameter.Value
        if isinstance(val.value, type):
            val = Parameter.Value(repr_str(val.value.__name__))
        return val
    for i, param in enumerate(parameters):
        if param.annotation:
            param = param.replace(annotation=prettify(param.annotation))
        if param.default:
            param = param.replace(default=prettify(param.default))
        parameters[i] = param

    if pretty:
        # Replace annotations and defaults with placeholders that are valid syntax.
        supplements = {}
        counter = [0]

        def _add_supplement(value):
            annotation_id = f'_{counter[0]}'
            annotation_id += '_' * (len(repr(value)) - len(annotation_id))
            supplements[annotation_id] = value
            counter[0] += 1
            return repr_str(annotation_id)

        for i, param in enumerate(parameters):
            if param.annotation:
                param = param.replace(
                    annotation=Parameter.Value(_add_supplement(param.annotation.value)))
            if param.default:
                param = param.replace(
                    default=Parameter.Value(_add_supplement(param.default.value)))
            parameters[i] = param

    # Use a placeholder in pretty mode as the generated *name* may also
    # sometimes not be valid syntax (eg. if it includes the class name).
    name_placeholder = '_PH_' + '_' * (len(name)-2)
    sig = (name_placeholder if pretty else name) + \
        '(' + format_parameters_list(parameters) + ')'

    try:
        if pretty:
            sig, _ = FormatCode('def ' + sig + ': pass', style_config='pep8')
            sig = sig[4:].rpartition(':')[0]

            # Replace the annotation and default placeholders with the actual values.
            for placeholder, annotation in supplements.items():
                sig = sig.replace(placeholder, repr(annotation))

            # Replace the placeholder and fix indents.
            sig = sig.replace(name_placeholder, name)
            delta = len(name_placeholder) - len(name)
            lines = sig.split('\n')
            for i, line in enumerate(lines[1:], 1):
                indent = len(line) - len(line.lstrip()) - \
                    delta - 4  # 4 for "def "
                if indent <= 0 and line.strip() != ')':
                    indent = 4
                lines[i] = ' ' * indent + line.lstrip()
            sig = '\n'.join(lines)
    except:
        pass

    return sig
