from enum import Enum
from typing import Any, Collection, Dict, List, NamedTuple, Union, cast

from ..language import print_ast
from ..pyutils import inspect, Undefined
from ..type import (
    GraphQLEnumType,
    GraphQLField,
    GraphQLList,
    GraphQLNamedType,
    GraphQLNonNull,
    GraphQLInputType,
    GraphQLInterfaceType,
    GraphQLObjectType,
    GraphQLSchema,
    GraphQLType,
    GraphQLUnionType,
    is_enum_type,
    is_input_object_type,
    is_interface_type,
    is_list_type,
    is_named_type,
    is_non_null_type,
    is_object_type,
    is_required_argument,
    is_required_input_field,
    is_scalar_type,
    is_specified_scalar_type,
    is_union_type,
)
from ..utilities.sort_value_node import sort_value_node
from .ast_from_value import ast_from_value

__all__ = [
    "BreakingChange",
    "BreakingChangeType",
    "DangerousChange",
    "DangerousChangeType",
    "find_breaking_changes",
    "find_dangerous_changes",
]


class BreakingChangeType(Enum):
    TYPE_REMOVED = 10
    TYPE_CHANGED_KIND = 11
    TYPE_REMOVED_FROM_UNION = 20
    VALUE_REMOVED_FROM_ENUM = 21
    REQUIRED_INPUT_FIELD_ADDED = 22
    IMPLEMENTED_INTERFACE_REMOVED = 23
    FIELD_REMOVED = 30
    FIELD_CHANGED_KIND = 31
    REQUIRED_ARG_ADDED = 40
    ARG_REMOVED = 41
    ARG_CHANGED_KIND = 42
    DIRECTIVE_REMOVED = 50
    DIRECTIVE_ARG_REMOVED = 51
    REQUIRED_DIRECTIVE_ARG_ADDED = 52
    DIRECTIVE_REPEATABLE_REMOVED = 53
    DIRECTIVE_LOCATION_REMOVED = 54


class DangerousChangeType(Enum):
    VALUE_ADDED_TO_ENUM = 60
    TYPE_ADDED_TO_UNION = 61
    OPTIONAL_INPUT_FIELD_ADDED = 62
    OPTIONAL_ARG_ADDED = 63
    IMPLEMENTED_INTERFACE_ADDED = 64
    ARG_DEFAULT_VALUE_CHANGE = 65


class BreakingChange(NamedTuple):
    type: BreakingChangeType
    description: str


class DangerousChange(NamedTuple):
    type: DangerousChangeType
    description: str


Change = Union[BreakingChange, DangerousChange]


def find_breaking_changes(
    old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[BreakingChange]:
    """Find breaking changes.

    Given two schemas, returns a list containing descriptions of all the types of
    breaking changes covered by the other functions down below.
    """
    return [
        change
        for change in find_schema_changes(old_schema, new_schema)
        if isinstance(change.type, BreakingChangeType)
    ]


def find_dangerous_changes(
    old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[DangerousChange]:
    """Find dangerous changes.

    Given two schemas, returns a list containing descriptions of all the types of
    potentially dangerous changes covered by the other functions down below.
    """
    return [
        change
        for change in find_schema_changes(old_schema, new_schema)
        if isinstance(change.type, DangerousChangeType)
    ]


def find_schema_changes(
    old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[Change]:
    return find_type_changes(old_schema, new_schema) + find_directive_changes(
        old_schema, new_schema
    )


def find_directive_changes(
    old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[Change]:
    schema_changes: List[Change] = []

    directives_diff = list_diff(old_schema.directives, new_schema.directives)

    for directive in directives_diff.removed:
        schema_changes.append(
            BreakingChange(
                BreakingChangeType.DIRECTIVE_REMOVED, f"{directive.name} was removed."
            )
        )

    for old_directive, new_directive in directives_diff.persisted:
        args_diff = dict_diff(old_directive.args, new_directive.args)

        for arg_name, new_arg in args_diff.added.items():
            if is_required_argument(new_arg):
                schema_changes.append(
                    BreakingChange(
                        BreakingChangeType.REQUIRED_DIRECTIVE_ARG_ADDED,
                        f"A required arg {arg_name} on directive"
                        f" {old_directive.name} was added.",
                    )
                )

        for arg_name in args_diff.removed:
            schema_changes.append(
                BreakingChange(
                    BreakingChangeType.DIRECTIVE_ARG_REMOVED,
                    f"{arg_name} was removed from {new_directive.name}.",
                )
            )

        if old_directive.is_repeatable and not new_directive.is_repeatable:
            schema_changes.append(
                BreakingChange(
                    BreakingChangeType.DIRECTIVE_REPEATABLE_REMOVED,
                    f"Repeatable flag was removed from {old_directive.name}.",
                )
            )

        for location in old_directive.locations:
            if location not in new_directive.locations:
                schema_changes.append(
                    BreakingChange(
                        BreakingChangeType.DIRECTIVE_LOCATION_REMOVED,
                        f"{location.name} was removed from {new_directive.name}.",
                    )
                )

    return schema_changes


def find_type_changes(
    old_schema: GraphQLSchema, new_schema: GraphQLSchema
) -> List[Change]:
    schema_changes: List[Change] = []
    types_diff = dict_diff(old_schema.type_map, new_schema.type_map)

    for type_name, old_type in types_diff.removed.items():
        schema_changes.append(
            BreakingChange(
                BreakingChangeType.TYPE_REMOVED,
                (
                    f"Standard scalar {type_name} was removed"
                    " because it is not referenced anymore."
                    if is_specified_scalar_type(old_type)
                    else f"{type_name} was removed."
                ),
            )
        )

    for type_name, (old_type, new_type) in types_diff.persisted.items():
        if is_enum_type(old_type) and is_enum_type(new_type):
            schema_changes.extend(find_enum_type_changes(old_type, new_type))
        elif is_union_type(old_type) and is_union_type(new_type):
            schema_changes.extend(find_union_type_changes(old_type, new_type))
        elif is_input_object_type(old_type) and is_input_object_type(new_type):
            schema_changes.extend(find_input_object_type_changes(old_type, new_type))
        elif is_object_type(old_type) and is_object_type(new_type):
            schema_changes.extend(find_field_changes(old_type, new_type))
            schema_changes.extend(
                find_implemented_interfaces_changes(old_type, new_type)
            )
        elif is_interface_type(old_type) and is_interface_type(new_type):
            schema_changes.extend(find_field_changes(old_type, new_type))
            schema_changes.extend(
                find_implemented_interfaces_changes(old_type, new_type)
            )
        elif old_type.__class__ is not new_type.__class__:
            schema_changes.append(
                BreakingChange(
                    BreakingChangeType.TYPE_CHANGED_KIND,
                    f"{type_name} changed from {type_kind_name(old_type)}"
                    f" to {type_kind_name(new_type)}.",
                )
            )

    return schema_changes


def find_input_object_type_changes(
    old_type: Union[GraphQLObjectType, GraphQLInterfaceType],
    new_type: Union[GraphQLObjectType, GraphQLInterfaceType],
) -> List[Change]:
    schema_changes: List[Change] = []
    fields_diff = dict_diff(old_type.fields, new_type.fields)

    for field_name, new_field in fields_diff.added.items():
        if is_required_input_field(new_field):
            schema_changes.append(
                BreakingChange(
                    BreakingChangeType.REQUIRED_INPUT_FIELD_ADDED,
                    f"A required field {field_name} on"
                    f" input type {old_type.name} was added.",
                )
            )
        else:
            schema_changes.append(
                DangerousChange(
                    DangerousChangeType.OPTIONAL_INPUT_FIELD_ADDED,
                    f"An optional field {field_name} on"
                    f" input type {old_type.name} was added.",
                )
            )

    for field_name in fields_diff.removed:
        schema_changes.append(
            BreakingChange(
                BreakingChangeType.FIELD_REMOVED,
                f"{old_type.name}.{field_name} was removed.",
            )
        )

    for field_name, (old_field, new_field) in fields_diff.persisted.items():
        is_safe = is_change_safe_for_input_object_field_or_field_arg(
            old_field.type, new_field.type
        )
        if not is_safe:
            schema_changes.append(
                BreakingChange(
                    BreakingChangeType.FIELD_CHANGED_KIND,
                    f"{old_type.name}.{field_name} changed type"
                    f" from {old_field.type} to {new_field.type}.",
                )
            )

    return schema_changes


def find_union_type_changes(
    old_type: GraphQLUnionType, new_type: GraphQLUnionType
) -> List[Change]:
    schema_changes: List[Change] = []
    possible_types_diff = list_diff(old_type.types, new_type.types)

    for possible_type in possible_types_diff.added:
        schema_changes.append(
            DangerousChange(
                DangerousChangeType.TYPE_ADDED_TO_UNION,
                f"{possible_type.name} was added" f" to union type {old_type.name}.",
            )
        )

    for possible_type in possible_types_diff.removed:
        schema_changes.append(
            BreakingChange(
                BreakingChangeType.TYPE_REMOVED_FROM_UNION,
                f"{possible_type.name} was removed from union type {old_type.name}.",
            )
        )

    return schema_changes


def find_enum_type_changes(
    old_type: GraphQLEnumType, new_type: GraphQLEnumType
) -> List[Change]:
    schema_changes: List[Change] = []
    values_diff = dict_diff(old_type.values, new_type.values)

    for value_name in values_diff.added:
        schema_changes.append(
            DangerousChange(
                DangerousChangeType.VALUE_ADDED_TO_ENUM,
                f"{value_name} was added to enum type {old_type.name}.",
            )
        )

    for value_name in values_diff.removed:
        schema_changes.append(
            BreakingChange(
                BreakingChangeType.VALUE_REMOVED_FROM_ENUM,
                f"{value_name} was removed from enum type {old_type.name}.",
            )
        )

    return schema_changes


def find_implemented_interfaces_changes(
    old_type: Union[GraphQLObjectType, GraphQLInterfaceType],
    new_type: Union[GraphQLObjectType, GraphQLInterfaceType],
) -> List[Change]:
    schema_changes: List[Change] = []
    interfaces_diff = list_diff(old_type.interfaces, new_type.interfaces)

    for interface in interfaces_diff.added:
        schema_changes.append(
            DangerousChange(
                DangerousChangeType.IMPLEMENTED_INTERFACE_ADDED,
                f"{interface.name} added to interfaces implemented by {old_type.name}.",
            )
        )

    for interface in interfaces_diff.removed:
        schema_changes.append(
            BreakingChange(
                BreakingChangeType.IMPLEMENTED_INTERFACE_REMOVED,
                f"{old_type.name} no longer implements interface {interface.name}.",
            )
        )

    return schema_changes


def find_field_changes(
    old_type: Union[GraphQLObjectType, GraphQLInterfaceType],
    new_type: Union[GraphQLObjectType, GraphQLInterfaceType],
) -> List[Change]:
    schema_changes: List[Change] = []
    fields_diff = dict_diff(old_type.fields, new_type.fields)

    for field_name in fields_diff.removed:
        schema_changes.append(
            BreakingChange(
                BreakingChangeType.FIELD_REMOVED,
                f"{old_type.name}.{field_name} was removed.",
            )
        )

    for field_name, (old_field, new_field) in fields_diff.persisted.items():
        schema_changes.extend(
            find_arg_changes(old_type, field_name, old_field, new_field)
        )
        is_safe = is_change_safe_for_object_or_interface_field(
            old_field.type, new_field.type
        )
        if not is_safe:
            schema_changes.append(
                BreakingChange(
                    BreakingChangeType.FIELD_CHANGED_KIND,
                    f"{old_type.name}.{field_name} changed type"
                    f" from {old_field.type} to {new_field.type}.",
                )
            )

    return schema_changes


def find_arg_changes(
    old_type: Union[GraphQLObjectType, GraphQLInterfaceType],
    field_name: str,
    old_field: GraphQLField,
    new_field: GraphQLField,
) -> List[Change]:
    schema_changes: List[Change] = []
    args_diff = dict_diff(old_field.args, new_field.args)

    for arg_name in args_diff.removed:
        schema_changes.append(
            BreakingChange(
                BreakingChangeType.ARG_REMOVED,
                f"{old_type.name}.{field_name} arg" f" {arg_name} was removed.",
            )
        )

    for arg_name, (old_arg, new_arg) in args_diff.persisted.items():
        is_safe = is_change_safe_for_input_object_field_or_field_arg(
            old_arg.type, new_arg.type
        )
        if not is_safe:
            schema_changes.append(
                BreakingChange(
                    BreakingChangeType.ARG_CHANGED_KIND,
                    f"{old_type.name}.{field_name} arg"
                    f" {arg_name} has changed type from"
                    f" {old_arg.type} to {new_arg.type}.",
                )
            )
        elif old_arg.default_value is not Undefined:
            if new_arg.default_value is Undefined:
                schema_changes.append(
                    DangerousChange(
                        DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE,
                        f"{old_type.name}.{field_name} arg"
                        f" {arg_name} defaultValue was removed.",
                    )
                )
            else:
                # Since we are looking only for client's observable changes we should
                # compare default values in the same representation as they are
                # represented inside introspection.
                old_value_str = stringify_value(old_arg.default_value, old_arg.type)
                new_value_str = stringify_value(new_arg.default_value, new_arg.type)

                if old_value_str != new_value_str:
                    schema_changes.append(
                        DangerousChange(
                            DangerousChangeType.ARG_DEFAULT_VALUE_CHANGE,
                            f"{old_type.name}.{field_name} arg"
                            f" {arg_name} has changed defaultValue"
                            f" from {old_value_str} to {new_value_str}.",
                        )
                    )

    for arg_name, new_arg in args_diff.added.items():
        if is_required_argument(new_arg):
            schema_changes.append(
                BreakingChange(
                    BreakingChangeType.REQUIRED_ARG_ADDED,
                    f"A required arg {arg_name} on"
                    f" {old_type.name}.{field_name} was added.",
                )
            )
        else:
            schema_changes.append(
                DangerousChange(
                    DangerousChangeType.OPTIONAL_ARG_ADDED,
                    f"An optional arg {arg_name} on"
                    f" {old_type.name}.{field_name} was added.",
                )
            )

    return schema_changes


def is_change_safe_for_object_or_interface_field(
    old_type: GraphQLType, new_type: GraphQLType
) -> bool:
    if is_list_type(old_type):
        return (
            # if they're both lists, make sure underlying types are compatible
            is_list_type(new_type)
            and is_change_safe_for_object_or_interface_field(
                cast(GraphQLList, old_type).of_type, cast(GraphQLList, new_type).of_type
            )
        ) or (
            # moving from nullable to non-null of same underlying type is safe
            is_non_null_type(new_type)
            and is_change_safe_for_object_or_interface_field(
                old_type, cast(GraphQLNonNull, new_type).of_type
            )
        )

    if is_non_null_type(old_type):
        # if they're both non-null, make sure underlying types are compatible
        return is_non_null_type(
            new_type
        ) and is_change_safe_for_object_or_interface_field(
            cast(GraphQLNonNull, old_type).of_type,
            cast(GraphQLNonNull, new_type).of_type,
        )

    return (
        # if they're both named types, see if their names are equivalent
        is_named_type(new_type)
        and cast(GraphQLNamedType, old_type).name
        == cast(GraphQLNamedType, new_type).name
    ) or (
        # moving from nullable to non-null of same underlying type is safe
        is_non_null_type(new_type)
        and is_change_safe_for_object_or_interface_field(
            old_type, cast(GraphQLNonNull, new_type).of_type
        )
    )


def is_change_safe_for_input_object_field_or_field_arg(
    old_type: GraphQLType, new_type: GraphQLType
) -> bool:
    if is_list_type(old_type):

        return is_list_type(
            # if they're both lists, make sure underlying types are compatible
            new_type
        ) and is_change_safe_for_input_object_field_or_field_arg(
            cast(GraphQLList, old_type).of_type, cast(GraphQLList, new_type).of_type
        )

    if is_non_null_type(old_type):
        return (
            # if they're both non-null, make sure the underlying types are compatible
            is_non_null_type(new_type)
            and is_change_safe_for_input_object_field_or_field_arg(
                cast(GraphQLNonNull, old_type).of_type,
                cast(GraphQLNonNull, new_type).of_type,
            )
        ) or (
            # moving from non-null to nullable of same underlying type is safe
            not is_non_null_type(new_type)
            and is_change_safe_for_input_object_field_or_field_arg(
                cast(GraphQLNonNull, old_type).of_type, new_type
            )
        )

    return (
        # if they're both named types, see if their names are equivalent
        is_named_type(new_type)
        and cast(GraphQLNamedType, old_type).name
        == cast(GraphQLNamedType, new_type).name
    )


def type_kind_name(type_: GraphQLNamedType) -> str:
    if is_scalar_type(type_):
        return "a Scalar type"
    if is_object_type(type_):
        return "an Object type"
    if is_interface_type(type_):
        return "an Interface type"
    if is_union_type(type_):
        return "a Union type"
    if is_enum_type(type_):
        return "an Enum type"
    if is_input_object_type(type_):
        return "an Input type"

    # Not reachable. All possible output types have been considered.
    raise TypeError(f"Unexpected type {inspect(type)}")


def stringify_value(value: Any, type_: GraphQLInputType) -> str:
    ast = ast_from_value(value, type_)
    if ast is None:  # pragma: no cover
        raise TypeError(f"Invalid value: {inspect(value)}")
    return print_ast(sort_value_node(ast))


class ListDiff(NamedTuple):
    """Tuple with added, removed and persisted list items."""

    added: List
    removed: List
    persisted: List


def list_diff(old_list: Collection, new_list: Collection) -> ListDiff:
    """Get differences between two lists of named items."""
    added = []
    persisted = []
    removed = []

    old_set = {item.name for item in old_list}
    new_map = {item.name: item for item in new_list}

    for old_item in old_list:
        new_item = new_map.get(old_item.name)
        if new_item:
            persisted.append([old_item, new_item])
        else:
            removed.append(old_item)

    for new_item in new_list:
        if new_item.name not in old_set:
            added.append(new_item)

    return ListDiff(added, removed, persisted)


class DictDiff(NamedTuple):
    """Tuple with added, removed and persisted dict entries."""

    added: Dict
    removed: Dict
    persisted: Dict


def dict_diff(old_dict: Dict, new_dict: Dict) -> DictDiff:
    """Get differences between two dicts."""
    added = {}
    removed = {}
    persisted = {}

    for old_name, old_item in old_dict.items():
        new_item = new_dict.get(old_name)
        if new_item:
            persisted[old_name] = [old_item, new_item]
        else:
            removed[old_name] = old_item

    for new_name, new_item in new_dict.items():
        if new_name not in old_dict:
            added[new_name] = new_item

    return DictDiff(added, removed, persisted)
