import enum

from graphql.language import ast
from graphql.language.parser import parse

from ..query import Node, Field, Link, merge


class NodeVisitor:

    def visit(self, obj):
        visit_method = getattr(self, 'visit_{}'.format(obj.kind))
        if visit_method is None:
            raise NotImplementedError('Not implemented node type: {!r}'
                                      .format(obj))
        return visit_method(obj)

    def visit_document(self, obj):
        for definition in obj.definitions:
            self.visit(definition)

    def visit_operation_definition(self, obj):
        self.visit(obj.selection_set)

    def visit_fragment_definition(self, obj):
        self.visit(obj.selection_set)

    def visit_selection_set(self, obj):
        for i in obj.selections:
            self.visit(i)

    def visit_field(self, obj):
        pass

    def visit_fragment_spread(self, obj):
        pass

    def visit_inline_fragment(self, obj):
        self.visit(obj.selection_set)


class OperationGetter(NodeVisitor):

    def __init__(self, operation_name=None):
        self._operations = {}
        self._operation_name = operation_name

    @classmethod
    def get(cls, doc, operation_name=None):
        self = cls(operation_name=operation_name)
        self.visit(doc)
        if not self._operations:
            raise TypeError('No operations in the document')

        if self._operation_name is None:
            if len(self._operations) > 1:
                raise TypeError('Document should contain exactly one operation '
                                'when no operation name was provided')
            return next(iter(self._operations.values()))
        else:
            try:
                return self._operations[self._operation_name]
            except KeyError:
                raise ValueError('Undefined operation name: {!r}'
                                 .format(self._operation_name))

    def visit_fragment_definition(self, obj):
        pass  # skip visit here

    def visit_operation_definition(self, obj):
        name = obj.name.value if obj.name is not None else None
        if name in self._operations:
            raise TypeError('Duplicate operation definition: {!r}'
                            .format(name))
        self._operations[name] = obj


class FragmentsCollector(NodeVisitor):

    def __init__(self):
        self.fragments_map = {}

    def visit_operation_definition(self, obj):
        pass  # not interested in operations here

    def visit_fragment_definition(self, obj):
        if obj.name.value in self.fragments_map:
            raise TypeError('Duplicated fragment name: "{}"'
                            .format(obj.name.value))
        self.fragments_map[obj.name.value] = obj


class SelectionSetVisitMixin:

    def transform_fragment(self, name):
        raise NotImplementedError(type(self))

    @property
    def query_variables(self):
        raise NotImplementedError(type(self))

    @property
    def query_name(self):
        raise NotImplementedError(type(self))

    def lookup_variable(self, name):
        try:
            return self.query_variables[name]
        except KeyError:
            raise TypeError('Variable ${} is not defined in query {}'
                            .format(name, self.query_name or '<unnamed>'))

    def visit_selection_set(self, obj):
        for i in obj.selections:
            for j in self.visit(i):
                yield j

    def _should_skip(self, obj):
        if not obj.directives:
            return

        skip = next((d for d in obj.directives if d.name.value == 'skip'),
                    None)
        if skip is not None:
            if len(skip.arguments) != 1:
                raise TypeError('@skip directive accepts exactly one '
                                'argument, {} provided'
                                .format(len(skip.arguments)))
            skip_arg = skip.arguments[0]
            if skip_arg.name.value != 'if':
                raise TypeError('@skip directive does not accept "{}" '
                                'argument'
                                .format(skip_arg.name.value))
            return self.visit(skip_arg.value)

        include = next((d for d in obj.directives if d.name.value == 'include'),
                       None)
        if include is not None:
            if len(include.arguments) != 1:
                raise TypeError('@include directive accepts exactly one '
                                'argument, {} provided'
                                .format(len(include.arguments)))
            include_arg = include.arguments[0]
            if include_arg.name.value != 'if':
                raise TypeError('@include directive does not accept "{}" '
                                'argument'
                                .format(include_arg.name.value))
            return not self.visit(include_arg.value)

    def visit_field(self, obj):
        if self._should_skip(obj):
            return

        if obj.arguments:
            options = {arg.name.value: self.visit(arg.value)
                       for arg in obj.arguments}
        else:
            options = None

        if obj.alias is not None:
            alias = obj.alias.value
        else:
            alias = None

        if obj.selection_set is None:
            yield Field(obj.name.value, options=options, alias=alias)
        else:
            node = Node(list(self.visit(obj.selection_set)))
            yield Link(obj.name.value, node, options=options, alias=alias)

    def visit_variable(self, obj):
        return self.lookup_variable(obj.name.value)

    def visit_null_value(self, obj):
        return None

    def visit_int_value(self, obj):
        return int(obj.value)

    def visit_float_value(self, obj):
        return float(obj.value)

    def visit_string_value(self, obj):
        return obj.value

    def visit_boolean_value(self, obj):
        return obj.value

    def visit_enum_value(self, obj):
        return obj.value

    def visit_list_value(self, obj):
        return [self.visit(i) for i in obj.values]

    def visit_object_value(self, obj):
        return {f.name.value: self.visit(f.value) for f in obj.fields}

    def visit_fragment_spread(self, obj):
        if self._should_skip(obj):
            return
        for i in self.transform_fragment(obj.name.value):
            yield i

    def visit_inline_fragment(self, obj):
        if self._should_skip(obj):
            return
        for i in self.visit(obj.selection_set):
            yield i


class FragmentsTransformer(SelectionSetVisitMixin, NodeVisitor):
    query_name = None
    query_variables = None

    def __init__(self, document, query_name, query_variables):
        collector = FragmentsCollector()
        collector.visit(document)
        self.query_name = query_name
        self.query_variables = query_variables
        self.fragments_map = collector.fragments_map
        self.cache = {}
        self.pending_fragments = set()

    def transform_fragment(self, name):
        return self.visit(self.fragments_map[name])

    def visit_operation_definition(self, obj):
        pass  # not interested in operations here

    def visit_fragment_definition(self, obj):
        if obj.name.value in self.cache:
            return self.cache[obj.name.value]
        else:
            if obj.name.value in self.pending_fragments:
                raise TypeError('Cyclic fragment usage: "{}"'
                                .format(obj.name.value))
            self.pending_fragments.add(obj.name.value)
            try:
                selection_set = list(self.visit(obj.selection_set))
            finally:
                self.pending_fragments.discard(obj.name.value)
            self.cache[obj.name.value] = selection_set
            return selection_set


class GraphQLTransformer(SelectionSetVisitMixin, NodeVisitor):
    query_name = None
    query_variables = None
    fragments_transformer = None

    def __init__(self, document, variables=None):
        self.document = document
        self.variables = variables

    @classmethod
    def transform(cls, document, op, variables=None):
        visitor = cls(document, variables)
        return visitor.visit(op)

    def transform_fragment(self, name):
        return self.fragments_transformer.transform_fragment(name)

    def visit_operation_definition(self, obj):
        variables = self.variables or {}
        query_name = obj.name.value if obj.name else '<unnamed>'
        query_variables = {}
        for var_defn in obj.variable_definitions or ():
            name = var_defn.variable.name.value
            try:
                value = variables[name]  # TODO: check variable type
            except KeyError:
                if var_defn.default_value is not None:
                    value = self.visit(var_defn.default_value)
                elif isinstance(var_defn.type, ast.NonNullTypeNode):
                    raise TypeError('Variable "{}" is not provided for query {}'
                                    .format(name, query_name))
                else:
                    value = None
            query_variables[name] = value

        self.query_name = query_name
        self.query_variables = query_variables
        self.fragments_transformer = FragmentsTransformer(self.document,
                                                          self.query_name,
                                                          self.query_variables)
        ordered = obj.operation is ast.OperationType.MUTATION
        try:
            node = Node(list(self.visit(obj.selection_set)),
                        ordered=ordered)
        finally:
            self.query_name = None
            self.query_variables = None
            self.fragments_transformer = None
        return merge([node])


def read(src, variables=None, operation_name=None):
    """Reads a query from the GraphQL document

    Example:

    .. code-block:: python

        query = read('{ foo bar }')
        result = engine.execute(graph, query)

    :param str src: GraphQL query
    :param dict variables: query variables
    :param str operation_name: Name of the operation to execute
    :return: :py:class:`hiku.query.Node`, ready to execute query object
    """
    doc = parse(src)
    op = OperationGetter.get(doc, operation_name=operation_name)
    if op.operation is not ast.OperationType.QUERY:
        raise TypeError('Only "query" operations are supported, '
                        '"{}" operation was provided'
                        .format(op.operation.value))

    return GraphQLTransformer.transform(doc, op, variables)


class OperationType(enum.Enum):
    """Enumerates GraphQL operation types"""
    #: query operation
    QUERY = ast.OperationType.QUERY
    #: mutation operation
    MUTATION = ast.OperationType.MUTATION
    #: subscription operation
    SUBSCRIPTION = ast.OperationType.SUBSCRIPTION


class Operation:
    """Represents requested GraphQL operation"""
    def __init__(self, type_, query, name=None):
        #: type of the operation
        self.type = type_
        #: operation's query
        self.query = query
        #: optional name of the operation
        self.name = name


def read_operation(src, variables=None, operation_name=None):
    """Reads an operation from the GraphQL document

    Example:

    .. code-block:: python

        op = read_operation('{ foo bar }')
        if op.type is OperationType.QUERY:
            result = engine.execute(query_graph, op.query)

    :param str src: GraphQL document
    :param dict variables: query variables
    :param str operation_name: Name of the operation to execute
    :return: :py:class:`Operation`
    """
    doc = parse(src)
    op = OperationGetter.get(doc, operation_name=operation_name)
    query = GraphQLTransformer.transform(doc, op, variables)
    type_ = OperationType._value2member_map_.get(op.operation)
    name = op.name.value if op.name else None
    if type_ is None:
        raise TypeError('Unsupported operation type: {}'.format(op.operation))
    else:
        return Operation(type_, query, name)
