"""Helper functions for dealing with FieldEdge classes and interservice comms.
"""
import inspect
import itertools
import json
import logging
import re

from fieldedge_utilities.logger import verbose_logging

__all__ = ['camel_case', 'snake_case', 'get_class_tag',
           'camel_to_snake', 'snake_to_camel',
           'get_class_properties', 'get_instance_properties_values',
           'json_compatible', 'hasattr_static',
           'property_is_read_only', 'property_is_async', 'tag_class_properties',
           'tag_class_property', 'untag_class_property', 'tag_merge',
           'equivalent_attributes', 'READ_ONLY', 'READ_WRITE']

READ_ONLY = 'info'
READ_WRITE = 'config'

_log = logging.getLogger(__name__)


def camel_to_snake(camel_str: str, skip_caps: bool = False) -> str:
    """Converts a camelCase string to snake_case.
    
    **DEPRECATED** use `snake_case` instead.

    Args:
        camel_str: The string to convert.
        skip_caps: A flag if `True` will return CAPITAL_CASE unchanged.
        
    Returns:
        The input string in snake_case format.
        
    Raises:
        `ValueError` if camel_str is not a valid string.
        
    """
    return snake_case(camel_str, skip_caps, skip_pascal=True)


def snake_to_camel(snake_str: str, skip_caps: bool = False) -> str:
    """Converts a snake_case string to camelCase.
    
    **DEPRECATED** use `camel_case` instead.
    
    Args:
        snake_str: The string to convert.
        skip_caps: If `True` will return CAPITAL_CASE unchanged
    
    Returns:
        The input string in camelCase structure.
        
    """
    return camel_case(snake_str, skip_caps, skip_pascal=True)


def snake_case(original: str,
               skip_caps: bool = False,
               skip_pascal: bool = False) -> str:
    """Converts a string to snake_case.
    
    Args:
        original: The string to convert.
        skip_caps: A flag if `True` will return CAPITAL_CASE unchanged.
        skip_pascal: A flag if `True` will return PascalCase unchanged.
        
    Returns:
        The original string converted to snake_case format.
        
    Raises:
        `ValueError` if original is not a valid string.
        
    """
    if not isinstance(original, str) or not original:
        raise ValueError('Invalid string input')
    if original.isupper() and skip_caps:
        return original
    snake = re.compile(r'(?<!^)(?=[A-Z])').sub('_', original).lower()
    if '__' in snake:
        words = snake.split('__')
        snake = '_'.join(f'{word.replace("_", "")}' for word in words)
    words = snake.split('_')
    if original[0].isupper() and skip_pascal:
        if all(word.title() in original for word in words):
            return original
    return snake


def camel_case(original: str,
               skip_caps: bool = False,
               skip_pascal: bool = False) -> str:
    """Converts a string to camelCase.
    
    Args:
        original: The string to convert.
        skip_caps: If `True` will return CAPITAL_CASE unchanged
        skip_pascal: If `True` will return PascalCase unchanged
    
    Returns:
        The input string in camelCase structure.
        
    """
    if not isinstance(original, str) or not original:
        raise ValueError('Invalid string input')
    if original.isupper() and skip_caps:
        return original
    words = original.split('_')
    if len(words) == 1:
        regex = '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)'
        matches = re.finditer(regex, original)
        words = [m.group(0) for m in matches]
    if skip_pascal and all(word.title() == word for word in words):
        return original
    return words[0].lower() + ''.join(w.title() for w in words[1:])


def pascal_case(original: str, skip_caps: bool = False) -> str:
    """Returns the string converted to PascalCase.
    
    Args:
        original: The original string.
        skip_caps: A flag that returns the original if CAPITAL_CASE.

    """
    camel = camel_case(original, skip_caps)
    return camel[0].upper() + camel[1:]


def get_class_tag(cls: type) -> str:
    """Returns a lowercase name to use as the tag for a class."""
    if isinstance(cls, type):
        return cls.__name__.lower()
    return cls.__class__.__name__.lower()


def get_class_properties(cls: type, ignore: 'list[str]' = None) -> 'list[str]':
    """Returns non-hidden, non-callable properties/values of a Class instance.
    
    Also ignores CAPITAL_CASE attributes which are assumed to be constants.
    
    Args:
        cls: The Class whose properties will be derived
        ignore: A list of names to ignore (optional)
    
    Returns:
        A list of exposed property names.
        
    Raises:
        ValueError if `cls` does not have a `dir()` method or is not a `type`.
        
    """
    if not dir(cls):
        raise ValueError('Invalid cls_instance - must have dir() method')
    if isinstance(cls, type) and '__slots__' not in dir(cls):
        _log.warning('No __slots__: attributes in __init__ will be missed')
    if not isinstance(ignore, list):
        ignore = []
    attrs = [attr for attr in dir(cls)
             if not attr.startswith(('_',)) and
             attr not in ignore and
             not callable(inspect.getattr_static(cls, attr)) and
             not attr.isupper()]
    return attrs


def get_instance_properties_values(instance: object) -> dict:
    """Returns the instance properties and values."""
    props_list = get_class_properties(instance)
    props_values = {}
    for prop in props_list:
        props_values[prop] = getattr(instance, prop)
    return props_values


def json_compatible(obj: object,
                    camel_keys: bool = True,
                    skip_caps: bool = True) -> dict:
    """Returns a dictionary compatible with `json.dumps` function.

    Nested objects are converted to dictionaries.
    
    `LOG_VERBOSE` optional key: `tags`
    
    Args:
        obj: The source object.
        camel_keys: Flag indicating whether to convert all nested dictionary
            keys to `camelCase`.
        skip_caps: Preserves `CAPITAL_CASE` keys if True
        
    Returns:
        A dictionary with nested arrays, dictionaries and other compatible with
            `json.dumps`.

    """
    res = obj
    if camel_keys:
        if isinstance(obj, dict):
            res = {}
            for key, val in obj.items():
                if ((isinstance(key, str) and key.isupper() and skip_caps) or
                    not isinstance(key, str)):
                    # no change
                    camel_key = key
                else:
                    camel_key = camel_case(str(key))
                if camel_key != key and verbose_logging('tags'):
                    _log.debug('Changed %s to %s', key, camel_key)
                res[camel_key] = json_compatible(val, camel_keys, skip_caps)
        elif isinstance(obj, list):
            res = [json_compatible(i) for i in obj]
    try:
        json.dumps(res)
        return res
    except TypeError:
        try:
            if callable(res):
                res = f'<function:{res.__name__}>'
            elif isinstance(res, list):
                res = [json_compatible(v, camel_keys, skip_caps)
                       for v in res]
            elif hasattr(res, '__dict__'):
                res = json_compatible(get_instance_properties_values(res),
                                      camel_keys,
                                      skip_caps)
            elif isinstance(res, dict):
                res = {k:json_compatible(v, camel_keys, skip_caps)
                       for k, v in res.items()}
            elif hasattr(res, '__slots__'):
                res = {s: json_compatible(getattr(res, s, None))
                       for s in res.__slots__}
            else:
                res = '<non-serializable>'
            return res
        except Exception as exc:
            _log.error(exc)
            raise exc


def hasattr_static(obj: object, attr: str) -> bool:
    """Determines if an object has an attribute without calling the attribute.
    
    Args:
        obj: The object to inspect.
        attr: The name of the attribute to query.
    
    Returns:
        `True` if the object has the attribute.
        
    """
    try:
        inspect.getattr_static(obj, attr)
        return True
    except AttributeError:
        return False


def property_is_read_only(instance: object, property_name: str) -> bool:
    """Returns True if the instance attribute has no fset method."""
    if not hasattr_static(instance, property_name):
        raise ValueError(f'Object has no property {property_name}')
    prop = inspect.getattr_static(instance, property_name)
    try:
        return prop.fset is None
    except AttributeError:
        return False


def property_is_async(instance: object, property_name: str) -> bool:
    """Returns True if an object is awaitable."""
    if not hasattr_static(instance, property_name):
        raise ValueError(f'Object has no property {property_name}')
    return inspect.isawaitable(getattr(instance, property_name))


def tag_class_properties(cls: type,
                         tag: str = None,
                         auto_tag: bool = True,
                         use_json: bool = True,
                         categorize: bool = False,
                         ignore: 'list[str]' = None,
                         ) -> 'list|dict':
    """Retrieves the class public properties tagged with a routing prefix.
    
    If a `tag` is not provided and `auto_tag` is `True` then the lowercase name
    of the instance's class will be used e.g. MyClass.property becomes
    myclassProperty.
    
    Using the defaults will return a simple list of tagged property names
    with the form `['tagProp1Name', 'tagProp2Name']`
    
    If `tag` is `None` and `auto_tag` is `False` then no tag will be applied
    and the native property names will be returned as JSON if `json` is `True`.
    
    If `categorize` is `True` a dictionary is returned of the form
    `{ 'info': ['tagProp1Name'], 'config': ['tagProp2Name']}` where
    the category is not present if no properties meet the respective criteria.
    
    If `json` is `False` the above applies but property names will use
    their original case e.g. `tag_prop1_name`
    
    `LOG_VERBOSE` optional key: `tags`.
    
    Args:
        cls: A class to tag.
        tag: The name of the routing prefix. If `None`, the calling function's
            module `__name__` will be used.
        auto_tag: If `True` will use the class name in lowercase.
        json: A flag indicating whether to use camelCase keys.
        categorize: A flag indicating whether to group as `info` and `config`.
        ignore: A list of property names to ignore.
    
    Retuns:
        A dictionary or list of strings (see docstring).
        
    """
    # TODO: class checking seems not to work for certain subclasses
    if isinstance(cls, type) and verbose_logging('tags'):
        _log.debug('Processing for class type')
    # elif issubclass(cls, ABC):
    #     _log.debug('Processing for microservice')
    if auto_tag and not tag:
        tag = get_class_tag(cls)
    class_props = get_class_properties(cls, ignore)
    if not categorize:
        return [tag_class_property(prop, tag, use_json) for prop in class_props]
    result = {}
    for prop in class_props:
        if property_is_read_only(cls, prop):
            if READ_ONLY not in result:
                result[READ_ONLY] = []
            result[READ_ONLY].append(tag_class_property(prop, tag, use_json))
        else:
            if READ_WRITE not in result:
                result[READ_WRITE] = []
            result[READ_WRITE].append(tag_class_property(prop, tag, use_json))
    return result


def tag_class_property(prop: str,
                       tag_or_cls: 'str|type' = None,
                       use_json: bool = True) -> str:
    """Converts a property for ISC adding an optional tag."""
    if tag_or_cls is None:
        tagged = prop
    else:
        if isinstance(tag_or_cls, type):
            tag = get_class_tag(tag_or_cls)
        elif isinstance(tag_or_cls, str):
            tag = tag_or_cls
        else:
            raise ValueError('tag_or_cls must be a string or class type')
        tagged = f'{tag.lower()}_{prop}'
    if use_json:
        return camel_case(f'{tagged}')
    return f'{tag}_{prop}'


def untag_class_property(property_name: str,
                         is_tagged: bool = True,
                         include_tag: bool = False,
                         ) -> 'str|tuple[str, str]':
    """Reverts a JSON-format tagged property to its PEP representation.
    
    Expects a JSON-format tagged value e.g. `modemUniqueId` would return
    `(unique_id, modem)` where it assumes the first word is the tag.

    Args:
        property_name: The property name, assumes using camelCase.
        include_tag: If True, a tuple is returned with the tag as the second
            element.
    
    Returns:
        A string with the original property name, or a tuple with the original
            property value in snake_case, and the tag

    """
    prop = snake_case(property_name)
    tag = None
    if is_tagged:
        if '_' not in prop:
            raise ValueError(f'Invalid tagged {property_name}')
        tag, prop = prop.split('_', 1)
    if not include_tag:
        return prop
    return (prop, tag)


def tag_merge(*args) -> 'list|dict':
    """Merge multiple tagged property lists/dictionaries.
    
    Args:
        *args: A set of dictionaries or lists, must all be the same structure.
    
    Returns:
        Merged structure of whatever was passed in.

    """
    container_type = args[0].__class__.__name__
    if container_type not in ('list', 'dict'):
        raise ValueError('tag merge must be of list or dict type')
    if not all(arg.__class__.__name__ == container_type for arg in args):
        raise ValueError('args must all be of same type')
    if container_type == 'list':
        return list(itertools.chain(*args))
    merged = {}
    categories = [READ_ONLY, READ_WRITE]
    dict_0: dict = args[0]
    if any(k in categories for k in dict_0):
        for arg in args:
            assert isinstance(arg, dict)
            if not any(k in categories for k in arg):
                raise ValueError('Not all dictionaries are categorized')
            merged = _nested_tag_merge(arg, merged)
    else:
        for arg in args:
            assert isinstance(arg, dict)
            for key, val in arg.items():
                merged[key] = val
    return merged


def _nested_tag_merge(add: dict, merged: dict) -> dict:
    for key, val in add.items():
        if key not in merged:
            merged[key] = val
        else:
            if isinstance(merged[key], list):
                merged[key] = merged[key] + val
            else:
                assert isinstance(merged[key], dict)
                assert isinstance(val, dict)
                for nested_key, nested_val in val.items():
                    merged[key][nested_key] = nested_val
    return merged


def equivalent_attributes(ref: object,
                          other: object,
                          exclude: 'list[str]' = None,
                          dbg: str = '',
                          ) -> bool:
    """Confirms attribute equivalence between objects of the same type.
    
    Args:
        ref: The reference object being compared to.
        other: The object comparing against the reference.
        exclude: Optional list of attribute names to exclude from comparison.
    
    Returns:
        True if all (non-excluded) attribute name/values match.

    """
    if not isinstance(other, type(ref)):
        return False
    if not hasattr(ref, '__dict__') or not hasattr(other, '__dict__'):
        return ref == other
    if not isinstance(exclude, list):
        exclude = []
    if dbg:
        dbg += '.'
    for attr in dir(ref):
        if attr.startswith('__') or attr in exclude:
            continue
        if not hasattr(other, attr):
            _log.debug('Other missing %s%s', dbg, attr)
            return False
        ref_val = getattr(ref, attr)
        if callable(ref_val):
            continue
        other_val = getattr(other, attr)
        if any(hasattr(ref_val, a) for a in ['__dict__', '__slots__']):
            if not equivalent_attributes(ref_val, other_val, dbg=attr):
                return False
        elif ref_val != other_val:
            _log.debug('%s%s mismatch', dbg, attr)
            return False
    return True
