import inspect
import warnings
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union

from pydantic import BaseModel
from pydantic.fields import Undefined
from typing_extensions import TypedDict

from pait.field import BaseField, Depends
from pait.model.core import PaitCoreModel
from pait.util import FuncSig, create_pydantic_model, get_func_sig, get_parameter_list_from_class


class _IgnoreField(BaseField):
    pass


class FieldSchemaRawTypeDict(TypedDict):
    param_name: str
    schema: dict
    parent_schema: dict
    annotation: Type
    field: BaseField


class FieldSchemaTypeDict(TypedDict):
    param_name: str
    description: str
    default: Any
    type: str
    other: dict
    raw: FieldSchemaRawTypeDict


FieldDictType = Dict[Type[BaseField], List[FieldSchemaTypeDict]]


class PaitBaseParse(object):
    def __init__(self, pait_dict: Dict[str, PaitCoreModel], undefined: Any = Undefined):
        self._undefined: Any = undefined
        self._group_list: List[str] = []
        self._group_pait_dict: Dict[str, List[PaitCoreModel]] = {}

        self._init(pait_dict)
        self._content_type: str = ""

    @property
    def content(self) -> str:
        return ""

    def _init(self, pait_dict: Dict[str, PaitCoreModel]) -> None:
        """read from `pait_id_dict` and write PaitMd attributes"""
        for pait_id, pait_model in pait_dict.items():
            if not pait_model.operation_id:
                continue
            group: str = pait_model.group
            if group not in self._group_pait_dict:
                self._group_pait_dict[group] = [pait_model]
            else:
                self._group_pait_dict[group].append(pait_model)
        self._group_list = sorted(self._group_pait_dict.keys())

    def _parse_schema(
        self, schema_dict: dict, definition_dict: Optional[dict] = None, parent_key: str = ""
    ) -> List[FieldSchemaTypeDict]:
        """gen pait field dict from pydantic basemodel schema"""
        field_dict_list: List[FieldSchemaTypeDict] = []
        # model property openapi dict
        # e.g. : {'code': {'title': 'Code', 'description': 'api code', 'default': 1, 'type': 'integer'}}
        if "properties" not in schema_dict:
            return field_dict_list
        property_dict: dict = schema_dict["properties"]
        # class schema in the parent schema
        if not definition_dict:
            definition_dict = schema_dict.get("definitions", {})
        for param_name, param_dict in property_dict.items():
            if parent_key:
                all_param_name: str = f"{parent_key}.{param_name}"
            else:
                all_param_name = param_name

            if "$ref" in param_dict and definition_dict:
                # ref support
                key: str = param_dict["$ref"].split("/")[-1]
                if isinstance(definition_dict, dict):
                    field_dict_list.extend(self._parse_schema(definition_dict[key], definition_dict, all_param_name))
            elif "items" in param_dict and "$ref" in param_dict["items"]:
                # mad item ref support
                key = param_dict["items"]["$ref"].split("/")[-1]
                if isinstance(definition_dict, dict):
                    field_dict_list.extend(self._parse_schema(definition_dict[key], definition_dict, all_param_name))
            elif "allOf" in param_dict:
                for item in param_dict["allOf"]:
                    key = item["$ref"].split("/")[-1]
                    if not isinstance(definition_dict, dict):
                        continue
                    if "enum" in definition_dict[key]:
                        if len(param_dict["allOf"]) > 1:
                            raise RuntimeError("Not support")
                        default: Any = definition_dict[key].get("enum", self._undefined)
                        if default is not self._undefined:
                            default = f'Only choose from: {",".join(["`" + i + "`" for i in default])}'
                        _type: str = "enum"
            else:
                if "enum" in param_dict:
                    # enum support
                    default = param_dict.get("enum", self._undefined)
                    if default is not self._undefined:
                        default = f'Only choose from: {",".join(["`" + i + "`" for i in default])}'
                    _type = "enum"
                else:
                    default = param_dict.get("default", self._undefined)
                    _type = param_dict.get("type", "object")

                field_dict_list.append(
                    {
                        "param_name": all_param_name,
                        "description": param_dict.get("description", ""),
                        "default": default,
                        "type": _type,
                        "other": {
                            key: value
                            for key, value in param_dict.items()
                            if key not in {"description", "title", "type", "default"}
                        },
                        "raw": {
                            "param_name": param_name,
                            "schema": param_dict,
                            "parent_schema": schema_dict,
                            # can not parse annotation and field
                            "annotation": str,
                            "field": _IgnoreField.i(),
                        },
                    }
                )
        return field_dict_list

    def _parse_base_model_to_field_dict(
        self, field_dict: FieldDictType, _pydantic_model: Type[BaseModel], specified_field: Optional[BaseField] = None
    ) -> None:
        """
        write field_dict from _pydantic_model or param_field_dict
        :param field_dict: collection parse result data
        :param _pydantic_model: Pydantic Model
        :param specified_field: Adapt each property of pydantic.BaseModel to pait.field
            Convert Field classes of pydantic.
            Model properties to Field classes of genuine request types, such as: Body, Query, Header, etc.
        """
        param_field_dict: Dict[str, BaseField] = {}
        param_annotation_dict: Dict[str, Type] = {}
        from typing import get_type_hints

        for field_name, model_field in _pydantic_model.__fields__.items():
            param_annotation = get_type_hints(_pydantic_model)[field_name]
            field = model_field.field_info
            if specified_field:
                field = specified_field.from_pydantic_field(field)
            if isinstance(field, BaseField) and field.alias:
                field_name = field.alias
            param_field_dict[field_name] = field  # type: ignore
            param_annotation_dict[field_name] = param_annotation

        property_dict: Dict[str, Any] = _pydantic_model.schema()["properties"]
        for param_name, param_dict in property_dict.items():
            pait_field: BaseField = param_field_dict[param_name]
            pait_field_class: Type[BaseField] = pait_field.__class__
            if "$ref" in param_dict:
                # ref support
                key: str = param_dict["$ref"].split("/")[-1]
                param_dict = _pydantic_model.schema()["definitions"][key]
            elif "items" in param_dict and "$ref" in param_dict["items"]:
                # mad item ref support
                key = param_dict["items"]["$ref"].split("/")[-1]
                param_dict = _pydantic_model.schema()["definitions"][key]
            elif "allOf" in param_dict:
                if len(param_dict["allOf"]) > 1:
                    warnings.warn(f"{param_dict['param_name']} only support 1 item")

                param_dict.update(param_dict["allOf"][0])
                key = param_dict["$ref"].split("/")[-1]
                param_dict = _pydantic_model.schema()["definitions"][key]
            if "enum" in param_dict:
                # enum support
                default: Any = param_dict.get("enum", self._undefined)
                if default is not self._undefined:
                    default = f'Only choose from: {",".join(["`" + str(i) + "`" for i in default])}'
                _type: str = "enum"
                description: str = pait_field.description
            else:
                default = param_dict.get("default", self._undefined)
                _type = param_dict.get("type", self._undefined)
                description = param_dict.get("description")
            # NOTE: I do not know <pydandic.Filed(default=None)> can not found default value
            if default is self._undefined and param_name not in _pydantic_model.schema().get("required", []):
                default = pait_field.default
            _field_dict: FieldSchemaTypeDict = {
                "param_name": param_name,
                "description": description,
                "default": default,
                "type": _type,
                "other": {
                    key: value
                    for key, value in param_dict.items()
                    if key not in {"description", "title", "type", "default"}
                },
                "raw": {
                    "param_name": param_name,
                    "schema": param_dict,
                    "parent_schema": _pydantic_model.schema(),
                    "annotation": param_annotation_dict[param_name],
                    "field": pait_field,
                },
            }
            if pait_field_class not in field_dict:
                field_dict[pait_field_class] = [_field_dict]
            else:
                field_dict[pait_field_class].append(_field_dict)

    def parameter_list_handle(
        self,
        parameter_list: List["inspect.Parameter"],
        field_dict: FieldDictType,
        single_field_list: List[Tuple[str, "inspect.Parameter"]],
        pait_model: PaitCoreModel,
    ) -> None:
        """parse parameter_list to field_dict and single_field_list"""
        for parameter in parameter_list:
            if parameter.default != parameter.empty:
                annotation: type = parameter.annotation
                pait_field: Union[BaseField, Depends] = parameter.default
                if (
                    inspect.isclass(annotation)
                    and issubclass(annotation, BaseModel)
                    and not isinstance(pait_field, Depends)
                ):
                    # support def test(pait_model_route: BaseModel = Body())
                    if not pait_field.raw_return:
                        # support raw_return is True
                        self._parse_base_model_to_field_dict(
                            field_dict,
                            create_pydantic_model({parameter.name: (parameter.annotation, pait_field)}),
                            specified_field=pait_field,
                        )
                    else:
                        self._parse_base_model_to_field_dict(field_dict, annotation, specified_field=pait_field)
                else:
                    # def test(pait_model_route: int = Body())
                    if isinstance(pait_field, Depends):
                        field_dict.update(self._parse_func_param_to_field_dict(pait_field.func, pait_model))
                    else:
                        field_name: str = pait_field.__class__.__name__.lower()
                        single_field_list.append((field_name, parameter))

                # parse link
                # TODO mv to gen tree
                if not isinstance(pait_field, Depends) and pait_field.link:
                    pait_field.link.register(pait_model, parameter.name, pait_field)

            elif inspect.isclass(parameter.annotation) and issubclass(parameter.annotation, BaseModel):
                # def test(pait_model_route: PaitBaseModel)
                self._parse_base_model_to_field_dict(field_dict, parameter.annotation)

    def _parse_func_param_to_field_dict(self, func: Callable, pait_model: PaitCoreModel) -> FieldDictType:
        """gen filed dict from func
        {
            "Body": [
                {
                    'field': {
                        'param_name': str,
                        'description': str,
                        'default': str,
                        'type': type,
                        'other': dict,
                        'raw': {
                            'param_name': str,
                            'schema': dict,
                            'parent_schema': pydantic base model.schema(),
                            'annotation': annotation,
                            'field': basefield,
                        }
                    }
                }
            ]
        }
        """
        field_dict: FieldDictType = {}
        func_sig: FuncSig = get_func_sig(func)
        single_field_list: List[Tuple[str, "inspect.Parameter"]] = []

        qualname = func.__qualname__.split(".<locals>", 1)[0].rsplit(".", 1)[0]
        class_ = getattr(inspect.getmodule(func), qualname)
        if inspect.isclass(class_):
            parameter_list: List["inspect.Parameter"] = get_parameter_list_from_class(class_)
            self.parameter_list_handle(parameter_list, field_dict, single_field_list, pait_model)
        self.parameter_list_handle(func_sig.param_list, field_dict, single_field_list, pait_model)

        if single_field_list:
            annotation_dict: Dict[str, Tuple[Type, Any]] = {}
            _column_name_set: Set[str] = set()
            for field_name, parameter in single_field_list:
                field: BaseField = parameter.default
                key: str = field.alias or parameter.name
                if key in _column_name_set:
                    # Since the same name cannot exist together in a Dict,
                    #  it will be parsed directly when a Key exists
                    # fix
                    #  class Demo(BaseModel):
                    #      header_token: str = Header(alias="token")
                    #      query_token: str = Query(alias="token")
                    _pydantic_model: Type[BaseModel] = create_pydantic_model(
                        {parameter.name: (parameter.annotation, field)}
                    )
                    self._parse_base_model_to_field_dict(field_dict, _pydantic_model)
                else:
                    _column_name_set.add(key)
                    annotation_dict[parameter.name] = (parameter.annotation, field)

            _pydantic_model = create_pydantic_model(annotation_dict)
            self._parse_base_model_to_field_dict(field_dict, _pydantic_model)

        for extra_openapi_model in pait_model.extra_openapi_model_list:
            self._parse_base_model_to_field_dict(field_dict, extra_openapi_model)
        return field_dict

    def _parse_pait_model_to_field_dict(self, pait_model: PaitCoreModel) -> FieldDictType:
        """Extracting request and response information through routing functions"""
        all_field_dict: FieldDictType = self._parse_func_param_to_field_dict(pait_model.func, pait_model)
        for pre_depend in pait_model.pre_depend_list:
            for field_class, field_dict_list in self._parse_func_param_to_field_dict(pre_depend, pait_model).items():
                if field_class not in all_field_dict:
                    all_field_dict[field_class] = field_dict_list
                else:
                    all_field_dict[field_class].extend(field_dict_list)
        return all_field_dict

    def __str__(self) -> str:
        return self.content

    def output(self, filename: Optional[str], suffix: str = "") -> None:
        if not suffix:
            suffix = self._content_type
        if not filename:
            print(self.content)
        else:
            if not filename.endswith(suffix):
                filename += suffix
            with open(filename, mode="w") as f:
                f.write(self.content)
