import datetime
import json
import logging

from pypuppetdb.errors import APIError

log = logging.getLogger(__name__)


class BinaryOperator:
    """
    This is a parent helper class used to create PuppetDB AST queries
    for single key-value pairs for the available operators.

    It is possible to directly declare the various types of queries
    from this class. For instance the code
    BinaryOperator('=', 'certname', 'node1.example.com') generates the
    PuppetDB query '["=", "certname", "node1.example.com"]'. It is preferred
    to use the child classes as they may have restrictions specific
    to that operator.

    See
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#binary-operators
    for more information.

    :param operator: The binary query operation performed. There is
        no value checking on this field.
    :type operator: :obj:`string`
    :param field: The PuppetDB endpoint query field. See endpoint
                  documentation for valid values.
    :type field: any
    :param value: The values of the field to match, or not match.
    :type value: any
    """

    def __init__(self, operator, field, value):
        if isinstance(value, datetime.datetime):
            value = str(value)
        self.data = [operator, field, value]

    def __repr__(self):
        return f"Query: {self}"

    def __str__(self):
        return json.dumps(self.json_data())

    def json_data(self):
        return self.data


class BooleanOperator:
    """
    This is a parent helper class used to create PuppetDB AST queries
    for available boolean queries.

    It is possible to directly declare a boolean query from this class.
    For instance the code BooleanOperator("and") will create an empty
    query '["and",]'. An error will be raised if there are no queries
    added via :func:`~pypuppetdb.QueryBuilder.BooleanOperator.add`

    See
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#binary-operators
    for more information.

    :param operator: The boolean query operation to perform.
    :type operator: :obj:`string`
    """

    def __init__(self, operator):
        self.operator = operator
        self.operations = []

    def add(self, query):
        if type(query) == list:
            for i in query:
                self.add(i)
        elif type(query) == str:
            self.operations.append(json.loads(query))
        elif isinstance(query, (BinaryOperator, InOperator, BooleanOperator)):
            self.operations.append(query.json_data())
        else:
            raise APIError(
                "Can only accept fixed-string queries, arrays " + "or operator objects"
            )

    def __repr__(self):
        return f"Query: {self}"

    def __str__(self):
        return json.dumps(self.json_data())

    def json_data(self):
        if len(self.operations) == 0:
            raise APIError("At least one query operation is required")
        return [self.operator] + self.operations


class ExtractOperator:
    """
    Queries that either do not or cannot require all the key-value pairs
    from an endpoint can use the Extract Operator as described in
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#projection-operators.

    The syntax of this operator requires a function and/or a list of fields,
    an optional standard query and an optional group by clause including a
    list of fields.
    """

    def __init__(self):
        self.fields = []
        self.query = None
        self.group_by = []

    def add_field(self, field):
        if isinstance(field, list):
            for i in field:
                self.add_field(i)
        elif isinstance(field, str):
            self.fields.append(field)
        elif isinstance(field, FunctionOperator):
            self.fields.append(field.json_data())
        else:
            raise APIError(
                "ExtractOperator.add_field only supports " "lists and strings"
            )

    def add_query(self, query):
        if self.query is not None:
            raise APIError("Only one query is supported by ExtractOperator")
        elif isinstance(query, str):
            self.query = json.loads(query)
        elif isinstance(query, (BinaryOperator, SubqueryOperator, BooleanOperator)):
            self.query = query.json_data()
        else:
            raise APIError(
                "ExtractOperator.add_query only supports "
                "strings, BinaryOperator, BooleanOperator "
                "and SubqueryOperator objects"
            )

    def add_group_by(self, field):
        if isinstance(field, list):
            for i in field:
                self.add_group_by(i)
        elif isinstance(field, str):
            if len(self.group_by) == 0:
                self.group_by.append("group_by")
            self.group_by.append(field)
        elif isinstance(field, FunctionOperator):
            if len(self.group_by) == 0:
                self.group_by.append("group_by")
            self.group_by.append(field.json_data())
        else:
            raise APIError(
                "ExtractOperator.add_group_by only supports "
                "lists, strings, and FunctionOperator objects"
            )

    def __repr__(self):
        return f"Query: {self}"

    def __str__(self):
        return json.dumps(self.json_data())

    def json_data(self):
        if len(self.fields) == 0:
            raise APIError("ExtractOperator needs at least one field")

        arr = ["extract", self.fields]

        if self.query is not None:
            arr.append(self.query)
        if len(self.group_by) > 0:
            arr.append(self.group_by)

        return arr


class FunctionOperator:
    """
    Performs an aggregate function on the result of a subquery, full
    documentation is available at
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#function.
    This object can only be used in the field list or group by list of
    an ExtractOperator object.

    :param function: The name of the function to perform.
    :type function: :obj:`str`
    :param field: The name of the field to perform the function on. All
        functions with the exception of count require this value.
    :type field: :obj:`str`
    """

    def __init__(self, function, field=None, fmt=None):
        if function not in ["count", "avg", "sum", "min", "max", "to_string"]:
            raise APIError(f"Unsupport function: {function}")
        elif function != "count" and field is None:
            raise APIError("Function {} requires a field value".format(function))
        elif function == "to_string" and fmt is None:
            raise APIError("Function {0} requires an extra 'fmt' parameter")

        self.arr = ["function", function]

        if field is not None:
            self.arr.append(field)

        if function == "to_string":
            self.arr.append(fmt)

    def __repr__(self):
        return f"Query: {self}"

    def __str__(self):
        return json.dumps(self.json_data())

    def json_data(self):
        return self.arr


class SubqueryOperator:
    """
    Performs a subquery to another puppetDB object, full
    documentation is available at
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#subquery-operators
    This object must be used in combination with the InOperator according
    to documentation.

    :param endpoint: The name of the subquery object
    :type function: :obj:`str`
    """

    def __init__(self, endpoint):
        if endpoint not in [
            "catalogs",
            "edges",
            "environments",
            "events",
            "facts",
            "fact_contents",
            "fact_paths",
            "nodes",
            "reports",
            "resources",
        ]:
            raise APIError(f"Unsupported endpoint: {endpoint}")

        self.query = None
        self.arr = [f"select_{endpoint}"]

    def add_query(self, query):
        if self.query is not None:
            raise APIError("Only one query is supported by ExtractOperator")
        else:
            self.query = True
            self.arr.append(query.json_data())

    def __repr__(self):
        return f"Query: {self}"

    def __str__(self):
        return json.dumps(self.json_data())

    def json_data(self):
        return self.arr


class InOperator:
    """
    Performs boolean compare between a field a subquery result
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#subquery-operators
    This object must be used in combination with the SubqueryOperator according
    to documentation.

    :param field: The name of the subquery object
    :type function: :obj:`str`
    """

    def __init__(self, field):
        self.query = None
        self.arr = ["in", field]

    def add_query(self, query):
        if self.query is not None:
            raise APIError("Only one query is supported by ExtractOperator")
        elif isinstance(query, str):
            self.query = True
            self.arr.append(json.loads(query))
        elif isinstance(query, (ExtractOperator, FromOperator)):
            self.query = True
            self.arr.append(query.json_data())
        else:
            raise APIError(
                "InOperator.add_query only supports "
                "strings, ExtractOperator, and"
                "FromOperator objects"
            )

    def add_array(self, values):
        if self.query is not None:
            raise APIError("Only one array is supported by the InOperator")
        elif isinstance(values, list):

            def depth(a_list):
                return (isinstance(a_list, list) and len(a_list) != 0) and max(
                    map(depth, a_list)
                ) + 1

            if depth(values) == 1:
                self.query = True
                self.arr.append(["array", values])
            else:
                raise APIError(
                    "InOperator.add_array: cannot pass in "
                    "nested arrays (or empty arrays)"
                )
        else:
            raise APIError(
                "InOperator.add_array: Ill-formatted array, "
                "must be of the format: "
                "['array', [<array values>]]"
            )

    def __repr__(self):
        return f"Query: {self}"

    def __str__(self):
        return json.dumps(self.json_data())

    def json_data(self):
        return self.arr


class FromOperator:
    """
    From contextual operator that allows for queries on the root endpoint
    or subqueries into other entities:
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#from

    Ex.)
    fr = FromOperator("facts")
    fr.add_query(EqualsOperator("foo", "bar"))
    fr.add_order_by(["certname"])
    fr.add_limit(10)

    note: only supports single entity From operations
    """

    def __init__(self, endpoint):
        valid_entities = [
            "aggregate_event_counts",
            "catalogs",
            "edges",
            "environments",
            "event_counts",
            "events",
            "facts",
            "fact_contents",
            "fact_names",
            "fact_paths",
            "nodes",
            "producers",
            "reports",
            "resources",
        ]

        if endpoint in valid_entities:
            self.endpoint = endpoint
        else:
            raise APIError(
                "Endpoint is invalid. Must be "
                "one of the following : %s" % valid_entities
            )

        self.query = None
        self.order_by = []
        self.limit = None
        self.offset = None

    def add_query(self, query):
        if self.query is not None:
            raise APIError("Only one main query is supported by FromOperator")
        elif isinstance(
            query,
            (
                InOperator,
                ExtractOperator,
                BinaryOperator,
                BooleanOperator,
                FunctionOperator,
            ),
        ):
            self.query = query.json_data()
        else:
            raise APIError("FromOperator.add_field only supports " "Operator Objects")

    def add_order_by(self, fields):
        def depth(a_list):
            return isinstance(a_list, list) and max(map(depth, a_list)) + 1

        fields_depth = depth(fields)

        if isinstance(fields, list):
            if fields_depth == 1 or fields_depth == 2:
                self.order_by = fields
            else:
                raise APIError(
                    "ExtractOperator.add_order_by only "
                    "supports lists of fields of depth "
                    "one or two: [value, <desc/asc>] or "
                    "[value]"
                )
        else:
            raise APIError(
                "ExtractOperator.add_group_by only supports "
                "lists of one or more fields"
            )

    def add_limit(self, lim):
        if isinstance(lim, int):
            self.limit = lim
        else:
            raise APIError("ExtractOperator.add_limit only supports ints")

    def add_offset(self, off):
        if isinstance(off, int):
            self.offset = off
        else:
            raise APIError("ExtractOperator.add_offset only supports ints")

    def __repr__(self):
        return f"Query: {self}"

    def __str__(self):
        return json.dumps(self.json_data())

    def json_data(self):
        if self.query is None:
            raise APIError("FromOperator needs one main query")

        arr = ["from", self.endpoint, self.query]

        if len(self.order_by) > 0:
            arr.append(["order_by", self.order_by])
        if self.limit is not None:
            arr.append(["limit", self.limit])
        if self.offset is not None:
            arr.append(["offset", self.offset])

        return arr


class EqualsOperator(BinaryOperator):
    """
    Builds an equality filter based on the supplied field-value pair as
    described
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#equality.

    In order to create the following query:

    ["=", "environment", "production"]

    The following code can be used.

    EqualsOperator('environment', 'production')

    :param field: The PuppetDB endpoint query field. See endpoint
                  documentation for valid values.
    :type field: any
    :param value: The value of the field to match, or not match.
    :type value: any
    """

    def __init__(self, field, value):
        super().__init__("=", field, value)


class GreaterOperator(BinaryOperator):
    """
    Builds a greater-than filter based on the supplied field-value pair as
    described
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#greater-than.

    In order to create the following query:

    [">", "catalog_timestamp", "2016-06-01 00:00:00"]

    The following code can be used.

    GreaterOperator('catalog_timestamp', datetime.datetime(2016, 06, 01))

    :param field: The PuppetDB endpoint query field. See endpoint
                  documentation for valid values.
    :type field: any
    :param value: Matches if the field is greater than this value.
    :type value: Number, timestamp or array
    """

    def __init__(self, field, value):
        super().__init__(">", field, value)


class LessOperator(BinaryOperator):
    """
    Builds a less-than filter based on the supplied field-value pair as
    described
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#less-than.

    In order to create the following query:

    ["<", "catalog_timestamp", "2016-06-01 00:00:00"]

    The following code can be used.

    LessOperator('catalog_timestamp', datetime.datetime(2016, 06, 01))

    :param field: The PuppetDB endpoint query field. See endpoint
                  documentation for valid values.
    :type field: any
    :param value: Matches if the field is less than this value.
    :type value: Number, timestamp or array
    """

    def __init__(self, field, value):
        super().__init__("<", field, value)


class GreaterEqualOperator(BinaryOperator):
    """
    Builds a greater-than or equal-to filter based on the supplied
    field-value pair as described
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#greater-than-or-equal-to.

    In order to create the following query:

    [">=", "facts_timestamp", "2016-06-01 00:00:00"]

    The following code can be used.

    GreaterEqualOperator('facts_timestamp', datetime.datetime(2016, 06, 01))

    :param field: The PuppetDB endpoint query field. See endpoint
                  documentation for valid values.
    :type field: any
    :param value: Matches if the field is greater than or equal to\
        this value.
    :type value: Number, timestamp or array
    """

    def __init__(self, field, value):
        super().__init__(">=", field, value)


class LessEqualOperator(BinaryOperator):
    """
    Builds a less-than or equal-to filter based on the supplied
    field-value pair as described
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#less-than-or-equal-to.

    In order to create the following query:

    ["<=", "facts_timestamp", "2016-06-01 00:00:00"]

    The following code can be used.

    LessEqualOperator('facts_timestamp', datetime.datetime(2016, 06, 01))

    :param field: The PuppetDB endpoint query field. See endpoint
                  documentation for valid values.
    :type field: any
    :param value: Matches if the field is less than or equal to\
        this value.
    :type value: Number, timestamp or array
    """

    def __init__(self, field, value):
        super().__init__("<=", field, value)


class RegexOperator(BinaryOperator):
    """
    Builds a regular expression filter based on the supplied field-value
    pair as described
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#regexp-match.

    In order to create the following query:

    ["~", "certname", "www\\d+\\.example\\.com"]

    The following code can be used.

    RegexOperator('certname', 'www\\d+\\.example\\.com')

    :param field: The PuppetDB endpoint query field. See endpoint
                  documentation for valid values.
    :type field: any
    :param value: Matches if the field matches this regular expression.
    :type value: :obj:`string`
    """

    def __init__(self, field, value):
        super().__init__("~", field, value)


class RegexArrayOperator(BinaryOperator):
    """
    Builds a regular expression array filter based on the supplied
    field-value pair. This query only works on fields with paths as
    described
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#regexp-array-match.

    In order to create the following query:

    ["~", "path", ["networking", "eth.*", "macaddress"]]

    The following code can be used.

    RegexArrayOperator('path', ["networking", "eth.*", "macaddress"])

    :param field: The PuppetDB endpoint query field. See endpoint
                  documentation for valid values.
    :type field: any
    :param value: Matches if the field matches this regular expression.
    :type value: :obj:`list`
    """

    def __init__(self, field, value):
        super().__init__("~>", field, value)


class NullOperator(BinaryOperator):
    """
    Builds a null filter based on the field and boolean value pair as
    described
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#null-is-null.
    This filter only works on field that may be null. Value may only
    be True or False.

    In order to create the following query:

    ["null?", "deactivated", true]

    The following code can be used.

    NullOperator('deactivated', True)

    :param field: The PuppetDB endpoint query field. See endpoint
                  documentation for valid values.
    :type field: any
    :param value: Matches if the field value is null (if True) or\
        not null (if False)
    :type value: :obj:`bool`
    """

    def __init__(self, field, value):
        if type(value) != bool:
            raise APIError("NullOperator value must be boolean")

        super().__init__("null?", field, value)


class AndOperator(BooleanOperator):
    """
    Builds an AND boolean filter. Only results that match ALL
    criteria from the included query strings will be returned
    from PuppetDB. Full documentation is available
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#and

    In order to create the following query:

    ["and",
      ["=", "catalog_environment", "production"],
      ["=", "facts_environment", "production"]]

    The following code can be used:

    op = AndOperator()
    op.add(EqualsOperator("catalog_environment", "production"))
    op.add(EqualsOperator("facts_environment", "production"))
    """

    def __init__(self):
        super().__init__("and")


class OrOperator(BooleanOperator):
    """
    Builds an OR boolean filter. Only results that match ANY
    criteria from the included query strings will be returned
    from PuppetDB. Full documentation is available
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#or.

    In order to create the following query:

    ["or", ["=", "name", "hostname"], ["=", "name", "architecture"]]

    The following code can be used:

    op = OrOperator()
    op.add(EqualsOperator("name", "hostname"))
    op.add(EqualsOperator("name", "architecture"))
    """

    def __init__(self):
        super().__init__("or")


class NotOperator(BooleanOperator):
    """
    Builds a NOT boolean filter. Only results that DO NOT match
    criteria from the included query strings will be returned
    from PuppetDB. Full documentation is available
    https://puppet.com/docs/puppetdb/7/api/query/v4/ast.html#not

    Unlike the other Boolean Operator objects this operator only
    accepts a single query string.

    In order to create the following query:

    ["not", ["=", "osfamily", "RedHat"]]

    The following code can be used.

    op = NotOperator()
    op.add(EqualsOperator("osfamily", "RedHat"))
    """

    def __init__(self):
        super().__init__("not")

    def add(self, query):
        if len(self.operations) > 0:
            raise APIError("This operator only accept one query string")
        elif isinstance(query, list) and len(query) > 1:
            raise APIError("This operator only accept one query string")
        super().add(query)
