import inspect
import json
from datetime import datetime, date
from decimal import Decimal
from enum import Enum
from typing import Any, Union, List, Dict, TypeVar, Type
from uuid import UUID

import iso8601

from httpmessageprocessor import ApiRequest
from httpmessageprocessorutil.automapped.exception import PropertyError, ListPropertyError
from ._util import to_camel_case, convert_underscore_to_hyphen, to_pascal_case
from .exception import BodyNotJson, PropertyNotSent


class ApiRequestParser(object):
    def __init__(self, request_type, extra_mapping):
        self.request_type = request_type
        self.extra_mapping = extra_mapping if extra_mapping is not None else {}
        self.context_mappings = []

        self.cls_attributes = self.build_type_attributes(self.request_type)
        self.json_body = None

    def parse(self, api_request):
        # type: (ApiRequest) -> Any
        if self.request_type is None:
            return None
        constructor_args = self.get_variables_from_request(api_request)
        if self.extra_mapping.get("removeNotSent", False):
            full_constructor_args = {}
            attributes_def = self._get_type_attributes(self.request_type)
            for attr_name in attributes_def.keys():
                if attr_name in constructor_args:
                    full_constructor_args[attr_name] = constructor_args[attr_name]
                else:
                    full_constructor_args[attr_name] = None
        else:
            full_constructor_args = constructor_args
        value = self.request_type(**full_constructor_args)
        self.remove_not_sent(full_constructor_args, constructor_args, value)

        return value

    def remove_not_sent(self, full_constructor_args, constructor_args, value):
        if self.extra_mapping.get("removeNotSent", False):
            for attr_name in full_constructor_args:
                if getattr(value, attr_name) is None and attr_name not in constructor_args:
                    delattr(value, attr_name)

    def get_variables_from_request(self, api_request):
        constructor_args = {}
        not_found_variables = []
        self.get_variables_from_path_and_query(api_request, constructor_args, not_found_variables)
        if len(not_found_variables) > 0:
            self.get_variables_from_body(api_request, constructor_args, not_found_variables)
        return constructor_args

    def _has_type_attributes(self, request_type):
        return request_type in self.cls_attributes

    def _get_type_attributes(self, request_type):
        return self.cls_attributes.get(request_type) or {}

    def _get_type_attribute_def(self, request_type, attr_name):
        attributes = self._get_type_attributes(request_type)
        return (attributes or {}).get(attr_name) or {}

    def _get_type_attribute_type(self, request_type, attr_name):
        attr_definition = self._get_type_attribute_def(request_type, attr_name)
        return attr_definition.get("type") or Any

    def _get_type_attribute_default(self, request_type, attr_name):
        attr_definition = self._get_type_attribute_def(request_type, attr_name)
        return attr_definition.get("default")

    def _is_type_attribute_optional(self, request_type, attr_name):
        attr_definition = self._get_type_attribute_def(request_type, attr_name)
        return attr_definition.get("optional") or False

    def get_variables_from_path_and_query(
        self,
        api_request,
        constructor_args,
        not_found_variables
    ):
        for attr_name in self._get_type_attributes(self.request_type).keys():
            value = self.get_from_path_or_query(api_request, attr_name)
            if value is not None:
                constructor_args[attr_name] = value
            else:
                not_found_variables.append(attr_name)

    def get_variables_from_body(self, api_request, constructor_args, not_found_variables):
        body_variables = self.get_from_body(api_request, not_found_variables)
        for variable_name in body_variables:
            if variable_name not in constructor_args:
                constructor_args[variable_name] = body_variables[variable_name]

    def build_type_attributes(self, cls: Type, cls_attributes=None):
        ret = cls_attributes or {}
        if (
            cls is None
            or cls in ret
            or self.is_scalar_type(cls)
            or cls in [inspect.Signature.empty, Any, UUID]
            or (inspect.isclass(cls) and issubclass(cls, Enum))
        ):
            return ret

        is_union, union_types = self._check_union(cls)
        if is_union:
            for union_type in union_types:
                ret = self.build_type_attributes(union_type, ret)
            return ret
        elif self._is_list(cls):
            type_args = self._get_type_params(cls)
            if len(type_args) > 0:
                ret = self.build_type_attributes(type_args[0], ret)
                return ret
        elif self._is_optional(cls):
            optional_type = self._get_inner_optional_type(cls)
            ret = self.build_type_attributes(optional_type, ret)
            return ret
        elif self._is_dict(cls):
            type_args = self._get_type_params(cls)
            for dict_type in type_args:
                ret = self.build_type_attributes(dict_type, ret)
            return ret

        ret[cls] = {}

        init_params = {}
        if hasattr(cls, "_real_cls"):
            init_params = inspect.signature(cls._real_cls.__init__).parameters
        elif hasattr(cls, "__init__"):
            init_params = inspect.signature(cls.__init__).parameters

        for arg_name in init_params.keys():
            self._set_attr_mapping(arg_name)
            try:
                value = init_params[arg_name]
                if arg_name == "self":
                    continue

                attrs = {
                    "optional": False
                }
                if value.annotation != inspect.Signature.empty:
                    attr_type = value.annotation
                    if self._is_optional(attr_type):
                        attrs["optional"] = True
                        attr_type = self._get_inner_optional_type(attr_type)
                    ret = self.build_type_attributes(attr_type, ret)

                    attrs["type"] = attr_type
                else:
                    attrs["type"] = Any

                if value.default != inspect.Signature.empty:
                    attrs["optional"] = True
                    attrs["default"] = value.default

                attrs["optional"] = attrs["optional"] or attrs["type"] == Any

                if hasattr(cls, "_cls_attrs") and arg_name in (getattr(cls, '_cls_attrs') or {}):
                    for key in cls._cls_attrs[arg_name].keys():
                        attrs[key] = cls._cls_attrs[arg_name][key]

                extra_mapping = self.get_current_mapping()
                if extra_mapping is not None:
                    for key in extra_mapping.keys():
                        extra_mapping_value = extra_mapping[key]
                        if not isinstance(extra_mapping_value, dict):
                            attrs[key] = extra_mapping_value

                ret[cls][arg_name] = attrs
            finally:
                self._pop_attr_mapping()

        return ret

    def get_from_path_or_query(self, api_request, attr_name):
        attributes = self._get_type_attributes(self.request_type)
        attr_map_properties = attributes[attr_name]
        name = attr_name
        if "name" in attr_map_properties:
            name = attr_map_properties["name"]

        if "location" in attr_map_properties:
            location = attr_map_properties["location"]
            if location == "query":
                selected_dict = api_request.query_parameters
                error_code = PropertyError.NOT_FOUND_IN_QUERY_STRING
            elif location == "path":
                selected_dict = api_request.path_parameters
                error_code = PropertyError.NOT_FOUND_IN_PATH
            elif location == "header":
                selected_dict = api_request.headers
                error_code = PropertyError.NOT_FOUND_IN_HEADERS
            elif location == "body":
                return None
            else:
                raise Exception("Invalid Location: {}".format(location))

            found_name = None
            attr_name_alternatives = self._get_attr_name_alternatives(self.request_type, name)
            for attr_name_alt in attr_name_alternatives:
                if attr_name_alt in selected_dict:
                    found_name = attr_name_alt
                    break

            if found_name is None:
                raise PropertyError(name, error_code)

            if "type" in attr_map_properties:
                return self._parse_to_type(
                    name,
                    attr_map_properties["type"],
                    selected_dict[found_name]
                )
            else:
                return selected_dict[name]
        else:
            return self.get_attr_value_from_path_or_query(attr_name, name, api_request)

    def get_attr_value_from_path_or_query(self, attr_name, variable_name, api_request):
        attributes = self._get_type_attributes(self.request_type)
        value = self.get_attr_value_from_dicts(self.request_type, variable_name, api_request)
        if value is None:
            return value
        if "type" in attributes[attr_name]:
            return self.transform_value(self.request_type, attr_name, value)
        return value

    def get_attr_value_from_dicts(self, request_type, attr_name, api_request):
        attr_name_alternatives = self._get_attr_name_alternatives(request_type, attr_name)
        for attr_name_alt in attr_name_alternatives:
            if attr_name_alt in api_request.path_parameters:
                return api_request.path_parameters[attr_name_alt]
            if attr_name_alt in api_request.query_parameters:
                return api_request.query_parameters[attr_name_alt]
        return None

    def get_from_body(self, api_request, variable_names):
        body_str, force_json = self.encode_body(api_request)
        try:
            self.json_body = json.loads(body_str)
        except ValueError:
            if force_json:
                raise BodyNotJson(body_str)

        body_variables = {}

        for attr_name in variable_names:
            try:
                if self.json_body is None:
                    raise ValueError("No JSON Body")
                body_variables[attr_name] = self.build_object_from_body(attr_name, self.request_type, self.json_body)
            except ValueError:

                if self._is_type_attribute_optional(self.request_type, attr_name) and body_str == "":
                    body_variables[attr_name] = self._get_type_attribute_default(self.request_type, attr_name)

                if len(body_str) > 0:
                    try:
                        body_variables[attr_name] = self.transform_value(self.request_type, attr_name, body_str, True)
                    except (ValueError, PropertyError):
                        if attr_name not in body_variables:
                            raise
            except PropertyNotSent:
                continue
            except (BodyNotJson, PropertyError):
                raise
            except Exception as ex:
                raise PropertyError(attr_name, PropertyError.UNKNOWN_ERROR) from ex
        return body_variables

    @staticmethod
    def encode_body(api_request):
        encoding_name = "utf-8"
        force_json = False
        if "Content-Type" in api_request.headers:
            if api_request.headers["Content-Type"].startswith("application/json"):
                force_json = True

            separator_index = api_request.headers["Content-Type"].find(";")
            if separator_index >= 0:
                charset_specification = api_request.headers["Content-Type"][separator_index + 1:].strip()
                parts = charset_specification.split("=")
                if len(parts) > 1:
                    encoding_name = parts[1]
        body_str = api_request.body.decode(encoding_name)
        return body_str, force_json

    def build_object_from_body(self, attr_name, request_type, json_body):
        attr_name_alternatives = self._get_attr_name_alternatives(request_type, attr_name)
        for attr_name_alt in attr_name_alternatives:
            if attr_name_alt in (json_body or {}):
                return self.transform_value(request_type, attr_name, json_body[attr_name_alt])
        return self.transform_value(request_type, attr_name, None, True)

    def _set_attr_mapping(self, attr_name):
        current_mapping = self.get_current_mapping()
        if attr_name in (current_mapping or {}):
            self.context_mappings.append(current_mapping[attr_name])
        else:
            self.context_mappings.append(None)

    def _pop_attr_mapping(self):
        self.context_mappings.pop()

    def get_mapping_search_name(self, request_type, attr_name):
        attribute_def = self._get_type_attribute_def(request_type, attr_name)
        if (
            attribute_def is not None
            and "name" in attribute_def
            and isinstance(attribute_def["name"], str)
        ):
            return attribute_def["name"]
        return None

    #
    def get_mapping_type(self, request_type, attr_name):
        attribute_def = self._get_type_attribute_def(request_type, attr_name)
        if 'type' in attribute_def:
            return attribute_def['type']

        if (
            attribute_def is not None
            and 'extra_mappings' in attribute_def
            and "type" in attribute_def['extra_mappings']
            and self._is_type(attribute_def['extra_mappings']["type"])
        ):
            return attribute_def['extra_mappings']["type"]
        return None

    def is_scalar_type(self, type_param):
        type_value = type_param
        if self._is_optional(type_param):
            type_value = self._get_inner_optional_type(type_value)

        return (
            type_value == int or
            type_value == float or
            type_value == bool or
            type_value == str or
            type_value == datetime or
            type_value == date
        )

    def _parse_to_type(
        self,
        attr_name,
        new_type,
        value
    ):
        if value is None:
            return None

        if new_type is None or new_type == Any:
            return value

        if self._is_optional(new_type):
            new_type = self._get_inner_optional_type(new_type)

        if self._is_list(new_type):
            if not isinstance(value, list):
                raise PropertyError(attr_name, PropertyError.NOT_A_LIST)
            type_args = self._get_type_params(new_type)

            array_type = Any
            if len(type_args) > 0 and not isinstance(type_args[0], TypeVar):
                array_type = type_args[0]
            ret = []
            count = 0
            for arr_value in value:
                try:
                    ret.append(self._parse_to_type(attr_name, array_type, arr_value))
                except PropertyError as ex:
                    raise ListPropertyError(
                        attr_name + "[" + str(count) + "]",
                        PropertyError.LIST_CONTENT_IS_INVALID,
                        ex
                    )
                except Exception:
                    raise PropertyError(
                        attr_name + "[" + str(count) + "]",
                        PropertyError.LIST_CONTENT_IS_INVALID
                    )
                count += 1

            return ret

        is_union, union_types = self._check_union(new_type)
        if is_union:
            result = None
            found_value = False
            for union_type in union_types:
                try:
                    result = self._parse_to_type(
                        attr_name,
                        union_type,
                        value
                    )
                    found_value = True
                    break
                except:
                    continue

            if not found_value:
                raise PropertyError(attr_name, PropertyError.UNION_ERROR)

            return result

        if self._is_dict(new_type):
            if not isinstance(value, dict):
                raise PropertyError(attr_name, PropertyError.NOT_A_DICT)
            type_args = self._get_type_params(new_type)
            key_type = Any
            value_type = Any
            if len(type_args) > 0:
                key_type = type_args[0]
                if len(type_args) > 1:
                    value_type = type_args[1]

            ret = {}
            for json_key in value.keys():
                try:
                    dict_key = self._parse_to_type(attr_name, key_type, json_key)
                except:
                    raise PropertyError(attr_name, PropertyError.DICT_KEY_IS_INVALID)

                try:
                    dict_value = self._parse_to_type(attr_name, value_type, value[json_key])
                except:
                    raise PropertyError(attr_name, PropertyError.DICT_VALUE_IS_INVALID)

                ret[dict_key] = dict_value

            return ret

        if new_type == str:
            try:
                return str(value)
            except:
                raise PropertyError(attr_name, PropertyError.NOT_A_STRING)

        if new_type == int:
            try:
                return int(value)
            except:
                raise PropertyError(attr_name, PropertyError.NOT_AN_INTEGER)

        if new_type == float:
            try:
                return float(value)
            except:
                raise PropertyError(attr_name, PropertyError.NOT_A_FLOAT)

        if new_type == Decimal:
            try:
                return Decimal(value)
            except:
                raise PropertyError(attr_name, PropertyError.NOT_A_DECIMAL)

        if new_type == bool:
            if isinstance(value, str):
                return value.lower() == "true"
            if isinstance(value, bool):
                return value
            if isinstance(value, int):
                return value != 0
            raise PropertyError(attr_name, PropertyError.NOT_A_BOOLEAN)

        if new_type == datetime or new_type == date:
            try:
                date_ret = iso8601.parse_date(value)
                if new_type == date:
                    date_ret = date_ret.date()
                return date_ret
            except:
                if new_type == datetime:
                    raise PropertyError(attr_name, PropertyError.NOT_A_DATETIME)
                else:
                    raise PropertyError(attr_name, PropertyError.NOT_A_DATE)

        if new_type == UUID:
            try:
                return UUID(hex=value)
            except:
                raise PropertyError(attr_name, PropertyError.NOT_A_UUID)

        if issubclass(new_type, Enum):
            try:
                return new_type(value)
            except:
                raise PropertyError(attr_name, PropertyError.NOT_VALID_ENUM_VALUE)

        if self._has_type_attributes(new_type):
            type_attributes = self._get_type_attributes(new_type)
            constructor_args = {}
            full_constructor_args = {}
            for attr_name in type_attributes.keys():
                try:
                    constructor_args[attr_name] = self.build_object_from_body(
                        attr_name,
                        new_type,
                        value
                    )
                except PropertyNotSent:
                    constructor_args[attr_name] = None

            for attr_name in constructor_args.keys():
                full_constructor_args[attr_name] = constructor_args[attr_name]

            value = new_type(**full_constructor_args)
            self.remove_not_sent(full_constructor_args, constructor_args, value)
            return value

        raise PropertyError(attr_name, PropertyError.UNKNOWN_TYPE_ERROR)

    def _get_attr_name_alternatives(self, request_type, attr_name):
        ret = []

        search_name = self.get_mapping_search_name(request_type, attr_name)
        if search_name is not None:
            ret.append(search_name)

        ret.append(attr_name)
        ret.append(to_camel_case(attr_name))
        ret.append(convert_underscore_to_hyphen(attr_name))
        ret.append(to_pascal_case(attr_name))
        return ret

    def transform_value(
        self,
        request_type,
        attr_name,
        value,
        property_not_sent=False
    ):
        attribute_def = self._get_type_attribute_def(request_type, attr_name)
        if value is None and "default" in attribute_def:
            return attribute_def['default']

        new_type = self.get_mapping_type(request_type, attr_name)
        ret = self._parse_to_type(attr_name, new_type, value)

        if ret is None:
            if self._is_type_attribute_optional(request_type, attr_name):
                if property_not_sent and self.extra_mapping.get("removeNotSent", False):
                    raise PropertyNotSent()
                return None
            raise PropertyError(attr_name, PropertyError.CANNOT_BE_NULL)

        return ret

    @staticmethod
    def _check_union(_cls):
        union_types = []
        is_union = False
        if getattr(_cls, '__origin__', _cls) == Union:
            is_union = True
            for arg in _cls.__args__:
                union_types.append(arg)
        return is_union, union_types

    def get_current_mapping(self):
        if len(self.context_mappings) > 0:
            return self.context_mappings[-1]
        else:
            return self.extra_mapping

    @staticmethod
    def _get_type_params(cls):
        return getattr(cls, "__args__", []) or []

    @staticmethod
    def _is_list(cls):
        return getattr(cls, '__origin__', cls) in [list, List]

    @staticmethod
    def _is_dict(cls):
        return getattr(cls, '__origin__', cls) in [dict, Dict]

    @staticmethod
    def _is_type(cls):
        return isinstance(getattr(cls, '__origin__', cls), type)

    def _is_optional(self, cls):
        type_args = self._get_type_params(cls)
        return (
            getattr(cls, "__origin__", None) == Union and
            len(type_args) == 2 and
            (isinstance(None, type_args[1]) or isinstance(None, type_args[0]))
        )

    def _get_inner_optional_type(self, cls):
        type_args = self._get_type_params(cls)
        if isinstance(None, type_args[1]):
            return type_args[0]
        else:
            return type_args[1]
