import ast
from inspect import formatannotation, isgeneratorfunction, Signature
import os
from types import ModuleType
from typing import Any, get_type_hints

from docstring_parser import DocstringParam, DocstringReturns
from pdocs.doc import Doc, External, Function, Module

from omnipy.util.helpers import recursive_module_import

IGNORED = None
IGNORE_PARAMS = ['cls', 'self']


def filter_internal_external(cls: type, members: list[Doc]):
    internal_members = []
    external_members = []

    for member in members:
        try:
            if is_member(cls, member.name) or is_internally_inherited(cls, member.name, ['omnipy']):
                internal_members.append(member)
            else:
                external_members.append(member)
        except AttributeError as e:
            print(e)

    return internal_members, external_members


def is_member(cls: type, member_name: str):
    return member_name in vars(cls)


def is_internally_inherited(cls: type,
                            member_name: str,
                            internal_packages: list[str],
                            outer: bool = True):
    if outer and member_name not in dir(cls):
        raise AttributeError(f'"{member_name}" is not a member of class "{cls.__name__}"')

    if is_member(cls, member_name):
        if outer:
            return False
        else:
            is_internal = any(cls.__module__.startswith(pkg) for pkg in internal_packages)
            return is_internal
    else:
        for base_cls in cls.__bases__:
            if is_internally_inherited(base_cls, member_name, internal_packages, outer=False):
                return True
        return False


def merge_signature_with_docstring(func: Function,
                                   signature: Signature,
                                   ds_params: list[DocstringParam],
                                   ds_returns: DocstringReturns):
    ds_params_map = {ds_param.arg_name: ds_param for ds_param in ds_params}
    params = []

    for name, param in signature.parameters.items():
        if name in IGNORE_PARAMS:
            continue

        description = ''
        if name in ds_params_map:
            description = ds_params_map[name].description

        type_name = get_type_name_from_annotation(func.module.module, param.annotation, param.empty)

        default = param.default if param.default is not param.empty else ''

        params.append(
            DocstringParam(
                args=[],
                description=description,
                arg_name=name,
                type_name=type_name,
                is_optional=IGNORED,
                default=default))

    description = ds_returns.description if ds_returns else ''

    if type_name := get_type_name_from_annotation(func.module.module,
                                                  signature.return_annotation,
                                                  signature.empty):
        returns = DocstringReturns(
            args=[],
            description=description,
            type_name=type_name,
            is_generator=isgeneratorfunction(func.func),
            return_name=IGNORED)
    else:
        returns = None

    return params, returns


def get_type_name_from_annotation(module: ModuleType, annotation, empty_obj):
    if annotation is not empty_obj:
        # (f'Before: {repr(annotation)}')
        type_name = convert_to_qual_name_type_hint_str(module, annotation)
        # print(f'After: {repr(type_name)}')
    else:
        type_name = ''

    return type_name


def convert_to_qual_name_type_hint_str(module: ModuleType, type_hint: Any) -> str:
    def fixed_get_type_hints(obj: Any) -> str:
        """
        Workaround from https://stackoverflow.com/a/66845686, due to limitations in get_type_hints()
        per Python 3.10
        """
        try:

            class X:
                x: obj

            return get_type_hints(X, globalns=recursive_module_import(module))['x']
        except NameError as e:
            print(e)
            return obj

    return formatannotation(fixed_get_type_hints(type_hint))


def format_type_as_markdown(module, type_name: str):
    cleaned_type_name = cleanup_type_hint_str_before_parsing(type_name)
    for basic_type_name in set(parse_type_hint(cleaned_type_name)):
        refname, url = lookup(module, basic_type_name)
        if refname:
            type_name = type_name.replace(basic_type_name, f'[{refname}]({url})')
    return type_name


_TYPE_HINTS_REPLACE_MAP = {
    '~': '',
    '+': '',
    '-': '',
}


def cleanup_type_hint_str_before_parsing(type_hint):
    for from_str, to_str in _TYPE_HINTS_REPLACE_MAP.items():
        type_hint = type_hint.replace(from_str, to_str)
    return type_hint


def parse_type_hint(type_hint_string):
    """
    Parses a Python type hint string and returns the individual types as a list of strings.

    Generated by ChatGPT May 21, 2023, in a guided session. This is the 22nd attempt.

    Args:
        type_hint_string (str): The type hint string to parse.

    Returns:
        list: The individual types parsed from the type hint string.

    Example:
        type_hint = "Union[list[str], dict[str, int], tuple[int, str], Optional[int]]"
        parsed_types = parse_type_hint(type_hint)
        print(parsed_types)
        # Output: ['Union', 'list', 'str', 'dict', 'str', 'int', 'tuple', 'int',
                   'str', 'Optional', 'int']
    """
    # print(f'Parsing: {repr(type_hint_string)}')
    source = f'def f() -> {type_hint_string}: pass'
    tree = ast.parse(source)

    type_strings = []

    def process_node(node, qual_names=None):
        """
        Recursively processes the AST nodes and extracts the type names.

        Args:
            node (ast.AST): The AST node to process.
            qual_names (list): A list of qualified names encountered during processing.

        Returns:
            None
        """
        qual_names = qual_names or []

        if isinstance(node, ast.Name):
            type_strings.append('.'.join(reversed(qual_names + [node.id])))

        elif isinstance(node, ast.Subscript):
            process_node(node.value, qual_names)
            process_node(node.slice, qual_names)

        elif isinstance(node, ast.Attribute):
            process_node(node.value, qual_names + [node.attr])

        elif isinstance(node, ast.Tuple):
            for elt in node.elts:
                process_node(elt, qual_names)

    returns_annotation = tree.body[0].returns
    process_node(returns_annotation)

    return type_strings


def lookup(module, refname):
    """
    Adapted from https://github.com/timothycrosley/pdocs/blob/master/pdocs/html_helpers.py
    """

    if '.' not in refname:
        refname = f'{module.name}.{refname}'

    d = module.find_ident(refname)

    if isinstance(d, External):
        return None, None
    if isinstance(d, Module):
        return d.refname, module_url(module, d)

    return d.name, f'{module_url(module, d.module)}#{d.name.lower()}'


def module_url(parent, m):
    """
    Adapted from https://github.com/timothycrosley/pdocs/blob/master/pdocs/html_helpers.py
    """

    relpath = os.path.relpath(m.name.replace('.', '/'), parent.name.replace('.', '/'))
    if relpath == '.':
        return ''
    else:
        return relpath + '/'
