import inspect
import json
import sys
from datetime import datetime, date
from typing import Any, Union, List
from uuid import UUID

import iso8601
from httpmessageprocessor import ApiRequest
from httpmessageprocessorutil.automapped.exception import PropertyError

from ._util import to_camel_case, convert_underscore_to_hyphen
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 = []

        if self.request_type is not None:
            self.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 = {}
            for attr_name in self.attributes.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 get_variables_from_path_and_query(self, api_request, constructor_args, not_found_variables):
        for attr_name in self.attributes.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):
        attributes = {}
        if hasattr(cls, "_real_cls"):
            init_params = inspect.signature(cls._real_cls.__init__).parameters
        else:
            init_params = inspect.signature(cls.__init__).parameters
        for arg_name in init_params.keys():
            value = init_params[arg_name]
            if arg_name == "self":
                continue
            attrs = {}
            if value.annotation != inspect.Signature.empty:
                attrs["type"] = value.annotation
            else:
                attrs["type"] = Any

            if hasattr(cls, "_cls_attrs") and arg_name in cls._cls_attrs:
                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 and arg_name in extra_mapping:
                for key in extra_mapping[arg_name].keys():
                    extra_mapping_value = extra_mapping[arg_name][key]
                    if not isinstance(extra_mapping_value, dict):
                        attrs[key] = extra_mapping_value

            attributes[arg_name] = attrs

        return attributes

    def get_from_path_or_query(self, api_request, attr_name):
        attr_map_properties = self.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 = name
            if name not in selected_dict:
                camel_name = to_camel_case(name)
                if camel_name not in selected_dict:
                    raise PropertyError(name, error_code)
                else:
                    found_name = camel_name

            if "type" in attr_map_properties:
                return self.transform_value(selected_dict[found_name], attr_map_properties["type"], 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):
        value = self.get_attr_value_from_dicts(variable_name, api_request)
        if value is None:
            return value
        if "type" in self.attributes[attr_name]:
            return self.transform_value(value, self.attributes[attr_name]["type"], attr_name)
        return value

    def get_attr_value_from_dicts(self, attr_name, api_request):
        if attr_name in api_request.path_parameters:
            return api_request.path_parameters[attr_name]
        camel_name = to_camel_case(attr_name)
        if camel_name in api_request.path_parameters:
            return api_request.path_parameters[camel_name]

        if attr_name in api_request.query_parameters:
            return api_request.query_parameters[attr_name]
        if camel_name in api_request.query_parameters:
            return api_request.query_parameters[camel_name]

    def get_from_body(self, api_request, variable_names):
        body_str, force_json = self.encode_body(api_request)

        body_variables = {}
        for attr_name in variable_names:
            attr_map_properties = self.attributes[attr_name]
            name = attr_name
            if "name" in attr_map_properties:
                name = attr_map_properties["name"]

            body_type = Any
            if "type" in attr_map_properties:
                body_type = attr_map_properties["type"]

            try:
                body_variables[attr_name] = self.build_object_from_body(body_type, body_str, name, force_json)
            except PropertyNotSent:
                pass
            except BodyNotJson:
                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, type, body, attr_name, force_json):
        try:
            if self.json_body is None:
                self.json_body = json.loads(body)
            if attr_name in self.json_body:
                try:
                    if attr_name in self.extra_mapping:
                        self.context_mappings.append(self.extra_mapping[attr_name])
                    else:
                        self.context_mappings.append(None)
                    return self.transform_value(self.json_body[attr_name], type, attr_name)
                finally:
                    self.context_mappings.pop()

            camel_attr_name = to_camel_case(attr_name)
            if camel_attr_name in self.json_body:
                return self.transform_value(self.json_body[camel_attr_name], type, attr_name)
            hyphen_attr_name = convert_underscore_to_hyphen(attr_name)
            if hyphen_attr_name in self.json_body:
                return self.transform_value(self.json_body[hyphen_attr_name], type, attr_name)

            return self.transform_value(None, type, attr_name, True)
        except ValueError:
            if force_json:
                raise BodyNotJson(body)

            body_type = str
            if attr_name is not None and self.is_scalar_type(type):
                body_type = type
            return self.transform_value(body, body_type, attr_name)

    @staticmethod
    def is_scalar_type(type):
        return type == int or type == float or type == bool or type == str or type == datetime or type == date

    def transform_value(self, value, new_type, attr_name, property_not_sent=False):
        if new_type == Any:
            return value

        if new_type == str:
            if value is None:
                raise PropertyError(attr_name, PropertyError.CANNOT_BE_NULL)
            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 == 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:
                return iso8601.parse_date(value)
            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 hasattr(new_type, "__origin__"):
            if new_type.__origin__ == list or new_type.__origin__ == List:
                inner_type = new_type.__args__[0]
                if not isinstance(value, list):
                    raise PropertyError(attr_name, PropertyError.NOT_A_LIST)

                ret = []
                for item in value:
                    try:
                        ret.append(self.transform_value(item, inner_type, attr_name))
                    except:
                        raise PropertyError(attr_name, PropertyError.LIST_CONTENT_IS_INVALID)
                return ret

            if new_type.__origin__ == dict:
                key_type = new_type.__args__[0]
                value_type = new_type.__args__[1]
                if not isinstance(value, dict):
                    raise PropertyError(attr_name, PropertyError.NOT_A_DICT)
                ret = {}
                for json_key in value.keys():
                    try:
                        dict_key = self.transform_value(json_key, key_type, attr_name)
                    except:
                        raise PropertyError(attr_name, PropertyError.DICT_KEY_IS_INVALID)

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

                    ret[dict_key] = dict_value

                return ret

            if new_type.__origin__ == Union:
                if len(new_type.__args__) == 2 and \
                        (isinstance(None, new_type.__args__[1]) or isinstance(None, new_type.__args__[0])):
                    if value is None:
                        if property_not_sent and self.extra_mapping.get("removeNotSent", False):
                            raise PropertyNotSent()
                        return None
                    else:
                        if isinstance(None, new_type.__args__[1]):
                            return self.transform_value(value, new_type.__args__[0], attr_name)
                        else:
                            return self.transform_value(value, new_type.__args__[1], attr_name)

            raise Exception("Unsupported type: {}".format(new_type))

        type_attributes = self.build_type_attributes(new_type)
        constructor_args = {}
        full_constructor_args = {}
        for attr_name in type_attributes.keys():
            search_name = self.get_search_name(attr_name)
            if search_name in value:
                constructor_args[attr_name] = self.transform_value(
                    value[search_name],
                    type_attributes[attr_name]["type"],
                    attr_name,
                )
            else:
                camel_attr_name = to_camel_case(search_name)
                hyphen_attr_name = convert_underscore_to_hyphen(search_name)
                if camel_attr_name in value:
                    constructor_args[attr_name] = self.transform_value(
                        value[camel_attr_name],
                        type_attributes[attr_name]["type"],
                        attr_name,
                    )
                elif hyphen_attr_name in value:
                    constructor_args[attr_name] = self.transform_value(
                        value[hyphen_attr_name],
                        type_attributes[attr_name]["type"],
                        attr_name,
                    )
                else:
                    try:
                        constructor_args[attr_name] = self.transform_value(
                            None,
                            type_attributes[attr_name]["type"],
                            attr_name,
                            property_not_sent=True,
                        )
                    except PropertyNotSent:
                        full_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

    def get_search_name(self, attr_name):
        current_mapping = self.get_current_mapping()
        if current_mapping is not None and attr_name in current_mapping and "name" in current_mapping[attr_name]:
            return current_mapping[attr_name]["name"]
        return attr_name

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