#!/usr/bin/env python3
from typing import Dict, List

from graphql import GraphQLSchema

from .query_parser import (
    ParsedField,
    ParsedObject,
    ParsedOperation,
    ParsedQuery,
    ParsedVariableDefinition,
)
from .utils_codegen import (
    CodeChunk,
    camel_case_to_lower_case,
)


class DataclassesRenderer:
    def __init__(self, schema: GraphQLSchema) -> None:
        self.schema = schema

    def render(
        self, parsed_query: ParsedQuery, fragment_name_to_importpath: Dict[str, str]
    ) -> str:
        buffer = CodeChunk()
        buffer.write("#!/usr/bin/env python3")
        buffer.write("# @" + "generated AUTOGENERATED file. Do not Change!")
        buffer.write("")
        buffer.write("from dataclasses import dataclass")
        buffer.write("from datetime import datetime")
        buffer.write("from gql_client.runtime.datetime_utils import DATETIME_FIELD")
        buffer.write("from gql_client.runtime.graphql_client import GraphqlClient")
        buffer.write("from gql_client.runtime.reporter import FailedOperationException")
        buffer.write("from gql import gql")
        buffer.write("from gql.transport.exceptions import TransportQueryError")
        buffer.write("from functools import partial")
        buffer.write("from numbers import Number")
        buffer.write("from typing import Any, Callable, List, Mapping, Optional, Dict")
        buffer.write("from time import perf_counter")
        buffer.write("from dataclasses_json import DataClassJsonMixin")
        buffer.write("")
        for fragment_name in sorted(set(parsed_query.used_fragments)):
            importpath = fragment_name_to_importpath[fragment_name]
            buffer.write(
                f"from {importpath} import {fragment_name}, QUERY as {fragment_name}Query"
            )
        enum_names = set()
        for enum in parsed_query.enums:
            enum_names.add(enum.name)
        if enum_names:
            buffer.write("from gql_client.runtime.enum_utils import enum_field")
            for enum_name in sorted(enum_names):
                buffer.write(
                    f"from ..enum.{camel_case_to_lower_case(enum_name)} import {enum_name}"
                )
            buffer.write("")
        input_object_names = set()
        for input_object in parsed_query.input_objects:
            input_object_names.add(input_object.name)
        if input_object_names:
            for input_object_name in sorted(input_object_names):
                buffer.write(
                    f"from ..input.{camel_case_to_lower_case(input_object_name)} "
                    f"import {input_object_name}"
                )
            buffer.write("")

        sorted_objects = sorted(
            parsed_query.objects,
            key=lambda obj: 1 if isinstance(obj, ParsedOperation) else 0,
        )
        for obj in sorted_objects:
            buffer.write("")
            if isinstance(obj, ParsedObject):
                self.__render_object(parsed_query, buffer, obj)
            elif isinstance(obj, ParsedOperation):
                self.__render_operation(parsed_query, buffer, obj)

        if parsed_query.fragment_objects:
            if parsed_query.used_fragments:
                queries = [
                    f"{fragment_name}Query"
                    for fragment_name in sorted(set(parsed_query.used_fragments))
                ]
                buffer.write(f'QUERY: List[str] = {" + ".join(queries)} + ["""')
            else:
                buffer.write('QUERY: List[str] = ["""')
            buffer.write(parsed_query.query)
            buffer.write('"""]')
            buffer.write("")

        for fragment_obj in parsed_query.fragment_objects:
            self.__render_fragment(parsed_query, buffer, fragment_obj)

        return str(buffer)

    def render_enums(self, parsed_query: ParsedQuery) -> Dict[str, str]:
        result = {}

        for enum in parsed_query.enums + parsed_query.internal_enums:
            buffer = CodeChunk()
            buffer.write("#!/usr/bin/env python3")
            buffer.write("# @" + "generated AUTOGENERATED file. Do not Change!")
            buffer.write("")
            buffer.write("from enum import Enum")
            buffer.write("")
            with buffer.write_block(f"class {enum.name}(Enum):"):
                for value_name, value in enum.values.items():
                    if isinstance(value, str):
                        value = f'"{value}"'

                    buffer.write(f"{value_name} = {value}")
                buffer.write('MISSING_ENUM = ""')
                buffer.write("")
                buffer.write("@classmethod")
                with buffer.write_block(
                    f'def _missing_(cls, value: object) -> "{enum.name}":'
                ):
                    buffer.write("return cls.MISSING_ENUM")
            buffer.write("")
            result[enum.name] = str(buffer)

        return result

    def render_input_objects(self, parsed_query: ParsedQuery) -> Dict[str, str]:
        result = {}

        for input_object in parsed_query.input_objects + parsed_query.internal_inputs:
            buffer = CodeChunk()
            buffer.write("#!/usr/bin/env python3")
            buffer.write("# @" + "generated AUTOGENERATED file. Do not Change!")
            buffer.write("")
            buffer.write("from dataclasses import dataclass")
            buffer.write("from datetime import datetime")
            buffer.write("from functools import partial")
            buffer.write("from gql_client.runtime.datetime_utils import DATETIME_FIELD")
            buffer.write("from numbers import Number")
            buffer.write("from typing import Any, Callable, List, Mapping, Optional")
            buffer.write("")
            buffer.write("from dataclasses_json import DataClassJsonMixin")
            buffer.write("")
            enum_names = set()
            for enum in input_object.input_enums:
                enum_names.add(enum.name)
            if enum_names:
                buffer.write("from gql_client.runtime.enum_utils import enum_field")
                for enum_name in sorted(enum_names):
                    buffer.write(
                        f"from ..enum.{camel_case_to_lower_case(enum_name)} import {enum_name}"
                    )
                buffer.write("")
            input_object_names = set()
            for input_dep in input_object.inputs:
                input_object_names.add(input_dep.name)
            for input_object_name in sorted(input_object_names):
                buffer.write(
                    f"from ..input.{camel_case_to_lower_case(input_object_name)} "
                    f"import {input_object_name}"
                )

            self.__render_object(parsed_query, buffer, input_object, True)
            buffer.write("")
            result[input_object.name] = str(buffer)

        return result

    def __render_object(
        self,
        parsed_query: ParsedQuery,
        buffer: CodeChunk,
        obj: ParsedObject,
        is_input: bool = False,
    ) -> None:
        class_parents = (
            "(DataClassJsonMixin)" if not obj.parents else f'({", ".join(obj.parents)})'
        )

        buffer.write("@dataclass")
        with buffer.write_block(f"class {obj.name}{class_parents}:"):
            # render child objects
            children_names = set()
            for child_object in obj.children:
                if child_object.name not in children_names:
                    self.__render_object(parsed_query, buffer, child_object, is_input)
                children_names.add(child_object.name)

            # render fields
            sorted_fields = self.__sort_fields(parsed_query, obj.fields, is_input)
            for field in sorted_fields:
                self.__render_field(parsed_query, buffer, field, is_input)

            # pass if not children or fields
            if not (obj.children or obj.fields):
                buffer.write("pass")

        buffer.write("")

    def __render_fragment(
        self, parsed_query: ParsedQuery, buffer: CodeChunk, obj: ParsedObject
    ) -> None:
        class_parents = (
            "(DataClassJsonMixin)" if not obj.parents else f'({", ".join(obj.parents)})'
        )

        buffer.write("@dataclass")
        with buffer.write_block(f"class {obj.name}{class_parents}:"):

            # render child objects
            children_names = set()
            for child_object in obj.children:
                if child_object.name not in children_names:
                    self.__render_object(parsed_query, buffer, child_object)
                children_names.add(child_object.name)

            # render fields
            sorted_fields = self.__sort_fields(parsed_query, obj.fields)
            for field in sorted_fields:
                self.__render_field(parsed_query, buffer, field)

        buffer.write("")

    def __render_operation(
        self, parsed_query: ParsedQuery, buffer: CodeChunk, parsed_op: ParsedOperation
    ) -> None:
        if len(parsed_query.used_fragments):
            queries = [
                f"{fragment_name}Query"
                for fragment_name in sorted(set(parsed_query.used_fragments))
            ]
            buffer.write(f'QUERY: List[str] = {" + ".join(queries)} + ["""')
        else:
            buffer.write('QUERY: List[str] = ["""')
        buffer.write(parsed_query.query)
        buffer.write('"""]')
        buffer.write("")
        with buffer.write_block(f"class {parsed_op.name}(DataClassJsonMixin):"):
            # Render children
            for child_object in parsed_op.children:
                self.__render_object(parsed_query, buffer, child_object)

            # Execution functions
            if parsed_op.variables:
                vars_args = ", " + ", ".join(
                    [
                        self.__render_variable_definition(var)
                        for var in parsed_op.variables
                    ]
                )
                variables_dict = (
                    "{"
                    + ", ".join(
                        f'"{var.name}": {var.name}' for var in parsed_op.variables
                    )
                    + "}"
                )
            else:
                vars_args = ""
                variables_dict = "{}"

            buffer.write("@classmethod")
            assert len(parsed_op.children) == 1
            child = parsed_op.children[0]
            assert len(child.fields) == 1
            query = child.fields[0]
            query_name = query.name
            query_result_type = f"{query.type}"
            if query_result_type not in ("int", "str", "bool"):
                query_result_type = f"{parsed_op.name}Data.{query.type}"
            if query.nullable:
                query_result_type = f"Optional[{query_result_type}]"
            if query.is_list:
                query_result_type = f"List[{query_result_type}]"
            with buffer.write_block(
                f"def execute(cls, client: GraphqlClient{vars_args})"
                f" -> {query_result_type}:"
            ):
                buffer.write(f"variables: Dict[str, Any] = {variables_dict}")
                with buffer.write_block("try:"):
                    buffer.write("network_start = perf_counter()")
                    buffer.write(
                        "response_text = client.call(''.join(set(QUERY)), "
                        "variables=variables)"
                    )
                    buffer.write("decode_start = perf_counter()")
                    buffer.write(
                        f"res = cls.{parsed_op.name}Data.from_dict(response_text)"
                    )
                    buffer.write("decode_time = perf_counter() - decode_start")
                    buffer.write("network_time = decode_start - network_start")
                    buffer.write(
                        f'client.reporter.log_successful_operation("{parsed_op.name}", variables, network_time, decode_time)'
                    )
                    buffer.write(f"return res.{query_name}")
                with buffer.write_block("except TransportQueryError as e:"):
                    with buffer.write_block("raise FailedOperationException("):
                        buffer.write("client.reporter,")
                        buffer.write("str(e.errors),")
                        buffer.write(f'"{parsed_op.name}",')
                        buffer.write("variables,")
                    buffer.write(")")
            buffer.write("")

    @staticmethod
    def __sort_fields(
        parsed_query: ParsedQuery, fields: List[ParsedField], is_input: bool = False
    ) -> List[ParsedField]:
        enum_names = [e.name for e in parsed_query.enums + parsed_query.internal_enums]

        def sort_key(field) -> int:
            if field.type in enum_names or field.type == "DateTime":
                return 1
            if field.nullable and is_input:
                return 2
            return 0

        return sorted(fields, key=sort_key)

    @staticmethod
    def __render_variable_definition(var: ParsedVariableDefinition):
        var_type = var.type

        if var_type == "DateTime":
            var_type = "datetime"
        elif var_type == "Cursor":
            var_type = "str"

        if var.is_list:
            return f"{var.name}: List[{var_type}] = []"

        if not var.nullable:
            return f"{var.name}: {var_type}"

        return f'{var.name}: Optional[{var_type}] = {var.default_value or "None"}'

    @staticmethod
    def __render_field(
        parsed_query: ParsedQuery,
        buffer: CodeChunk,
        field: ParsedField,
        is_input: bool = False,
    ) -> None:
        enum_names = [e.name for e in parsed_query.enums + parsed_query.internal_enums]
        is_enum = field.type in enum_names
        suffix = ""
        field_type = field.type
        if field.is_list:
            field_type = f"List[{field_type}]"

        if is_enum:
            suffix = f" = enum_field({field_type})"

        if field_type == "DateTime":
            suffix = " = DATETIME_FIELD"
            field_type = "datetime"

        if field.nullable:
            if is_input:
                suffix = f" = {field.default_value}"
            buffer.write(f"{field.name}: Optional[{field_type}]{suffix}")
        else:
            buffer.write(f"{field.name}: {field_type}{suffix}")
