import base64
import collections.abc as collections
import decimal
import inspect
import json
import math
import numbers
import os
import pathlib
import platform
import typing
import sys
from dataclasses import fields, is_dataclass
from datetime import datetime, timedelta
from enum import Enum, IntEnum, EnumMeta
from functools import reduce
from types import MappingProxyType
from typing_extensions import Type, get_origin, get_args
from typing import Callable, ForwardRef, Union, TypeVar
from typing import List, Dict, Any

import marshmallow.fields as mf

from servicestack.log import Log
from servicestack.utils import to_timespan, to_datetime, to_bytearray, from_bytearray, from_datetime, from_timespan
from .utils import snakecase, uppercase, camelcase


def is_optional(cls: Type): return get_origin(cls) is Union and type(None) in get_args(cls)


def is_list(cls: Type): return cls == typing.List or cls == list or get_origin(cls) == list


def is_dict(cls: Type): return cls == typing.Dict or cls == dict or get_origin(cls) == dict or cls == MappingProxyType


def nameof(instance):
    return type(instance).__name__


def generic_args(cls: Type):
    if not hasattr(cls, '__args__'):
        raise TypeError(f"{cls} is not a Generic Type")
    return cls.__args__


def generic_arg(cls: Type): return generic_args(cls)[0]


def _get_type_vars_map(cls: Type, type_map=None):
    if type_map is None:
        type_map = {}
    if hasattr(cls, '__orig_bases__'):
        for base_cls in cls.__orig_bases__:
            _get_type_vars_map(base_cls, type_map)
    generic_def = get_origin(cls)
    if generic_def is not None:
        generic_type_args = get_args(cls)
        i = 0
        for t in generic_def.__parameters__:
            k = t
            if type(k) == TypeVar:
                k = k.__name__
            type_map[k] = generic_type_args[i]
            i += 1
    return type_map


def _dict_with_string_keys(d: dict):
    to = {}
    for k, v in d.items():
        to[f"{k}"] = v
    return to


def has_type_vars(cls: Type):
    if cls is None:
        return None
    return isinstance(cls, TypeVar) or any(isinstance(x, TypeVar) for x in cls.__args__)


def _empty(x):
    return x is None or x == {} or x == []


def _str(x: Any):
    if type(x) == str:
        return x
    return f"{x}"


def identity(x: Any): return x


_JSON_TYPES = {str, bool, int, float}
_BUILT_IN_TYPES = {str, bool, int, float, decimal.Decimal, datetime, timedelta, bytes, bytearray, complex}


def is_builtin(t: Type):
    try:
        return t in _BUILT_IN_TYPES or issubclass(t, Enum)
    except Exception as e:  # throws if t is not hashable
        return False


def to_dict(obj: Any, key_case: Callable[[str], str] = identity, remove_empty: bool = True):
    t = type(obj)
    if obj is None or is_builtin(t):
        return obj
    if is_list(t):
        to = []
        for o in obj:
            use_val = to_dict(o, key_case=key_case, remove_empty=remove_empty)
            if not remove_empty or use_val is not None:
                to.append(use_val)
    elif is_dict(t):
        to = {}
        for k, v in obj.items():
            use_key = key_case(_str(k))
            use_val = to_dict(v, key_case=key_case, remove_empty=remove_empty)
            if not remove_empty or use_val is not None:
                to[use_key] = use_val
    elif hasattr(obj, 'to_dict'):  # dataclass
        d = obj.to_dict()
        to = {}
        for k, v in d.items():
            use_key = key_case(_str(k))
            use_val = to_dict(v, key_case=key_case, remove_empty=remove_empty)
            if not remove_empty or use_val is not None:
                to[use_key] = use_val
    elif hasattr(obj, '__dict__'):
        to = to_dict(vars(obj), key_case=key_case, remove_empty=remove_empty)
    else:
        to = obj
    if remove_empty:
        return clean_any(to)
    return to


def _clean_list(d: list):
    return [v for v in (clean_any(v) for v in d) if not _empty(v)]


def _clean_dict(d: dict):
    return {k: v for k, v in ((k, clean_any(v)) for k, v in d.items()) if not _empty(v)}


def clean_any(d):
    """recursively remove empty lists, empty dicts, or None elements from a dictionary"""
    if is_dict(d):
        return _clean_dict(d)
    elif is_list(d):
        return _clean_list(d)
    else:
        return d


def _json_encoder(obj: Any):
    t = type(obj)
    if is_dataclass(t) or is_dict(t):
        return to_dict(obj)
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, timedelta):
        return to_timespan(obj)
    if isinstance(obj, bytes):
        return base64.b64encode(obj).decode('ascii')
    if isinstance(obj, decimal.Decimal):
        return float(obj)
    if t in _JSON_TYPES:
        return obj
    if is_builtin(t):
        return _str(obj)
    raise TypeError(f"Unsupported Type in JSON encoding: {t}")


def to_json(obj: Any, indent=None):
    return json.dumps(to_dict(obj), indent=indent, default=_json_encoder)


class TypeConverters:
    serializers = {}    # Py3.9: dict[Type, Callable[[Any], Any]]
    deserializers = {}  # Py3.9: dict[Type, Callable[[Any], Any]]

    @staticmethod
    def register(cls: Type, serializer: Callable[[Any], Any] = None, deserializer: Callable[[Any], Any] = None):
        if serializer is not None:
            TypeConverters.serializers[cls] = serializer
        if deserializer is not None:
            TypeConverters.deserializers[cls] = deserializer

    @staticmethod
    def serialize(obj: Any):
        cls = type(obj)
        if cls in TypeConverters.serializers:
            serializer = TypeConverters.serializers[cls]
            try:
                return serializer(obj)
            except Exception as e:
                Log.error(f"serializer(obj) {cls}({obj})", e)
                raise e

    @staticmethod
    def deserialize(cls: Type, obj: Any):
        deserializer = TypeConverters.deserializers[cls]
        try:
            return deserializer(obj)
        except Exception as e:
            Log.error(f"deserializer(obj) {cls}({obj})", e)
            raise e


TypeConverters.serializers = {
    datetime: to_datetime,
    timedelta: to_timespan,
    bytes: to_bytearray,
    bytearray: to_bytearray,
}
TypeConverters.deserializers = {
    mf.DateTime: from_datetime,
    mf.TimeDelta: from_timespan,
    datetime: from_datetime,
    timedelta: from_timespan,
    bytes: from_bytearray,
    bytearray: from_bytearray,
}


def _resolve_forwardref(cls: Type, orig: Type = None):
    type_name = cls.__forward_arg__
    if orig is not None and orig.__name__ == type_name:
        return orig
    if type_name not in globals():
        raise TypeError(f"Could not resolve ForwardRef('{type_name}')")
    return globals()[type_name]


def unwrap(cls: Type, module:str):
    if type(cls) == ForwardRef:
        cls = _resolve_forwardref(cls)
    if isinstance(cls, str):
        cls = eval(cls, {}, vars(sys.modules[module]))
    if is_optional(cls):
        return generic_arg(cls)
    return cls


def dict_get(name: str, obj: dict, case: Callable[[str], str] = None):
    if name in obj:
        return obj[name]
    if case:
        name_case = case(name)
        if name_case in obj:
            return obj[name_case]
    name_snake = snakecase(name)
    if name_snake in obj:
        return obj[name_snake]
    name_camel = camelcase(name)
    if name_camel in obj:
        return obj[name_camel]
    if name.endswith('_'):
        return dict_get(name.rstrip('_'), obj, case)
    return None


def sanitize_name(s: str):
    return s.replace('_', '').upper()


def enum_get(cls: Union[Enum, Type], key: Union[str, int]):
    if type(key) == int or issubclass(cls, IntEnum):
        return cls(key)
    try:
        return cls[key]
    except Exception as e:
        try:
            upper_snake = uppercase(snakecase(key))
            return cls[upper_snake]
        except Exception as e2:
            sanitize_key = sanitize_name(key)
            for value in cls.__members__.values():
                if sanitize_key == sanitize_name(value):
                    return value
            for member in cls.__members__.keys():
                if sanitize_key == sanitize_name(member):
                    return cls[member]
    raise TypeError(f"{key} is not a member of {nameof(Enum)}")


def _resolve_type(cls: Type, substitute_types: Dict[Type, type] ):
    if type(cls) == TypeVar:
        # TypeVar('T') == TypeVar('T') is false, 
        # I think this should work
        cls = cls.__name__
    if substitute_types is None:
        return cls
    return substitute_types[cls] if cls in substitute_types else cls


def convert(into: Type, obj: Any, substitute_types: Dict[Type, type] = None, module = None):
    if obj is None:
        return None
    into = unwrap(into, module)
    into = _resolve_type(into, substitute_types)
    if Log.debug_enabled():
        Log.debug(f"convert({into}, {substitute_types}, {obj})")

    is_type = type(into) == type
    if not is_type:
        Log.debug(f"type of {into} is not a class")

    generic_def = get_origin(into)
    if generic_def is not None and is_dataclass(generic_def):
        reified_types = _get_type_vars_map(into)
        return convert(generic_def, obj, reified_types)

    if is_dataclass(into):
        to = {}
        for f in fields(into):
            val = dict_get(f.name, obj)
            # print(f"to[{f.name}] = convert({f.type}, {val}, {substitute_types})")
            to[f.name] = convert(f.type, val, substitute_types, into.__module__)
            # print(f"to[{f.name}] = {to[f.name]}")
        return into(**to)
    elif is_list(into):
        el_type = _resolve_type(generic_arg(into), substitute_types)
        to = []
        for item in obj:
            to.append(convert(el_type, item, substitute_types, into.__module__))
        return to
    elif is_dict(into):
        key_type, val_type = generic_args(into)
        key_type = _resolve_type(key_type, substitute_types)
        val_type = _resolve_type(val_type, substitute_types)
        to = {}
        if not hasattr(obj, 'items'):
            Log.warn(f"dict {obj} ({type(type)}) does not have items()")
        for key, val in obj.items():
            to_key = convert(key_type, key, substitute_types, into.__module__)
            to_val = convert(val_type, val, substitute_types, into.__module__)
            to[to_key] = to_val
        return to
    else:
        if into in TypeConverters.deserializers:
            converter = TypeConverters.deserializers[into]
            try:
                return converter(obj)
            except Exception as e:
                Log.error(f"converter(obj) {into}({obj})", e)
                raise e
        elif inspect.isclass(into) and issubclass(into, mf.Field):
            try:
                return into().deserialize(obj)
            except Exception as e:
                Log.error(f"into().deserialize(obj) {into}({obj})", e)
                raise e
        elif is_type and (issubclass(into, Enum) or into == EnumMeta):
            try:
                return enum_get(into, obj)
            except Exception as e:
                print(into, type(into), obj, type(obj))
                Log.error(f"Enum into[obj] {into}[{obj}]", e)
                raise e
        else:
            try:
                return into(obj)
            except Exception as e:
                # if into == typing.Dict or get_origin(into) == typing.Dict:
                #     print("WAS A typing.Dict")
                Log.error(f"into(obj) {into}({obj})", e)
                raise e


def from_json(into: Type, json_str: str):
    if json_str is None or json_str == "":
        return None
    json_obj = json.loads(json_str)
    return convert(into, json_obj)


# inspect utils
def all_keys(obj):
    keys = []
    if not isinstance(obj, collections.Iterable):
        return keys
    for o in obj:
        if is_builtin(type(o)):
            continue
        for key in o:
            key = _str(key)
            if key is not None and key not in keys:
                keys.append(key)
    return keys


def inspect_vars(objs):
    if not isinstance(objs, dict):
        raise TypeError('objs must be a dictionary')

    to = to_dict(objs)

    inspect_vars_path = os.environ.get('INSPECT_VARS')
    if inspect_vars_path is None:
        return
    if platform.system() == 'Windows':
        inspect_vars_path = inspect_vars_path.replace("/", "\\")
    else:
        inspect_vars_path = inspect_vars_path.replace("\\", "/")

    pathlib.Path(os.path.dirname(inspect_vars_path)).mkdir(parents=True, exist_ok=True)

    with open(inspect_vars_path, 'w') as outfile:
        json_str = to_json(to)
        outfile.write(json_str)


def dump(obj):
    return to_json(obj, indent=4).replace('"', '').replace(': null', ':')


def printdump(obj):
    print(dump(obj))


def _align_left(s: str, length: int, pad: str = ' '):
    if length < 0:
        return ""
    alen = length + 1 - len(s)
    if alen <= 0:
        return s
    return pad + s + (pad * (length + 1 - len(s)))


def _align_center(s: str, length: int, pad: str = ' '):
    if length < 0:
        return ""
    nlen = len(s)
    half = math.floor(length / 2 - nlen / 2)
    odds = abs((nlen % 2) - (length % 2))
    return (pad * (half + 1)) + s + (pad * (half + 1 + odds))


def _align_right(s: str, length: int, pad: str = ' '):
    if length < 0:
        return ""
    alen = length + 1 - len(s)
    if alen <= 0:
        return s
    return (pad * (length + 1 - len(s))) + s + pad


def _align_auto(obj: Any, length: int, pad: str = ' '):
    s = _str(obj)
    if len(s) <= length:
        if isinstance(obj, numbers.Number):
            return _align_right(s, length, pad)
        return _align_left(s, length, pad)
    return s


def sanitize_key(key: str): return key.replace("_", " ").replace(" ", "").lower()


def lenient_getitem(obj: dict, key: str):
    if key in obj:
        return obj[key]
    sanitized_key = sanitize_key(key)
    for k in obj:
        if sanitize_key(k) == sanitized_key:
            return obj[k]
    return ""


def table(objs, headers=None):
    if not is_list(type(objs)):
        raise TypeError('objs must be a list')
    map_rows = to_dict(objs)
    if headers is None:
        headers = all_keys(map_rows)
    col_sizes: Dict[str, int] = {}

    for k in headers:
        max = len(k)
        for row in map_rows:
            col = lenient_getitem(row, k)
            if col != "":
                val_size = len(_str(col))
                if val_size > max:
                    max = val_size
            col_sizes[k] = max

    # sum + ' padding ' + |
    row_width = reduce(lambda x, y: x + y, col_sizes.values(), 0) + \
                (len(col_sizes) * 2) + \
                (len(col_sizes) + 1)
    sb: List[str] = [f"+{'-' * (row_width - 2)}+"]
    head = "|"
    for k in headers:
        head += _align_center(k, col_sizes[k]) + "|"
    sb.append(head)
    sb.append(f"|{'-' * (row_width - 2)}|")

    for row in map_rows:
        to = "|"
        for k in headers:
            to += '' + _align_auto(lenient_getitem(row, k), col_sizes[k]) + "|"
        sb.append(to)

    sb.append(f"+{'-' * (row_width - 2)}+")
    return '\n'.join(sb)


def printtable(obj, headers=None):
    print(table(obj, headers))


def htmllist(d: dict):
    sb: List[str] = ["<table><tbody>"]
    for k, v in d.items():
        sb.append(f"<tr><th>{_str(k)}</th><td>{htmldump(v)}</td></tr>")
    sb.append("</tbody></table>")
    return ''.join(sb)


def htmldump(objs, headers=None):
    if is_builtin(type(objs)):
        return _str(objs)

    map_rows = to_dict(objs)
    t = type(map_rows)
    if is_dict(t):
        return htmllist(map_rows)

    if headers is None:
        headers = all_keys(map_rows)

    # print(headers, map_rows)

    sb: List[str] = ["<table>"]
    row = ["<thead><tr>"]
    for k in headers:
        row.append(f"<th>{k}</th>")
    row.append("</tr></head>")
    if len(row) > 2:
        sb.append(''.join(row))
    sb.append("<tbody>")

    rows = []
    for item in map_rows:
        rows.append("<tr>")
        if len(headers) > 0:
            row = []
            for k in headers:
                val = lenient_getitem(item, k)
                row.append(f"<td>{htmldump(val)}</td>")
            rows.append(''.join(row))
        else:
            rows.append(f"<td>{htmldump(item)}</td>")
        rows.append("</tr>")

    sb.append(''.join(rows))
    sb.append("</tbody>")
    sb.append("</table>")
    return '\n'.join(sb)


def printhtmldump(obj, headers=None):
    print(htmldump(obj, headers))
