from typing import TYPE_CHECKING
from unittest import TestCase

from django.conf import settings
from django.db.models import Q
from django.db.models.constants import LOOKUP_SEP

if TYPE_CHECKING:
    TestCaseBase = TestCase
else:
    TestCaseBase = object


# ------------------------------------------------------------------------------------
class QTestMixin(TestCaseBase):
    def assertQEqual(self, left: Q, right: Q) -> None:
        """
        Assert `Q` objects are equal by ensuring that their
        unicode outputs are equal (crappy but good enough)
        """
        self.assertIsInstance(left, Q)
        self.assertIsInstance(right, Q)
        left_u = str(left)
        right_u = str(right)
        self.assertEqual(left_u, right_u)


operator_mapping = {
    'not_in': 'in',
    'not_exact': 'exact',
    'equal': 'exact',
    'not_equal': 'exact',
    'between': 'range',
    'not_between': 'range',
    'greater': 'gt',
    'greater_or_equal': 'gte',
    'less': 'lt',
    'less_or_equal': 'lte',
    'contains': 'contains',
    'not_contains': 'contains',
    'ends_with': 'endswith',
    'not_ends_with': 'endswith',
    'begins_with': 'startswith',
    'not_begins_with': 'startswith',
    'not_null': 'isnull',
    'is_null': 'isnull',
    'is_not_null': 'isnull',
}


# ---------------------------------------------------------------------------
def get_query_params(data: dict) -> Q:
    """
    django-filters dict will have `exclude` values by `<fieldname>_exclude`,
    so negate fields with `DRF_EXCLUDE_SUFFIX`.
    """
    qs = Q()
    if not data:
        return qs

    strip_suffix = len(settings.DRF_EXCLUDE_SUFFIX) - 2

    for k, v in data.items():
        negated = k.endswith(settings.DRF_EXCLUDE_SUFFIX)

        try:
            if ',' in v:
                v = v.split(',')
        except (AttributeError, TypeError):
            pass

        if negated:
            k = k[:strip_suffix]

        if isinstance(v, (list, tuple)):
            k = f'{k}__in'

        if negated:
            qs &= ~Q((k, v))
        else:
            qs &= Q((k, v))

    return qs


# ------------------------------------------------------------------------------------
def jquery_builder(spec: dict, field_mapping: dict[str, str] | None = None) -> Q:
    """
    Assemble a django "Q" query filter object from JSON output from the
    jQuery QueryBuilder library.

    Each filter description is a dict with "condition" and "rules" keys.

    {
      "condition": "AND",
      "rules": [
        {
          "id": "price",
          "field": "price",
          "type": "double",
          "input": "text",
          "operator": "lt",
          "value": "10.25"
        }
      ]
    }

    "condition" is a string like "AND" / "OR" / "NOT".

    "rules" is a list of rule dicts (including some extra detail
    for constructing the QueryBuilder object on a web page)

    "operator" is a string name like "in", "range", "icontains", etc.
    "fieldname" is the django field being queried.  Any name that django
    accepts is allowed, including references to fields in foreign keys
    using the "__" syntax described in the django API reference.
    "query_arg" is the argument you'd pass to the `filter()` method in
    the Django database API.


    The "rules" list may contain sub-filters as well, supporting nested queries.

    {
      "condition": "AND",
      "rules": [
        {
          "id": "price",
          "field": "price",
          "type": "double",
          "input": "text",
          "operator": "less",
          "value": "10.25"
        },
        {
          "condition": "OR",
          "rules": [
            {
              "id": "category",
              "field": "category",
              "type": "integer",
              "input": "select",
              "operator": "exact",
              "value": "2"
            },
            {
              "id": "category",
              "field": "category",
              "type": "integer",
              "input": "select",
              "operator": "exact",
              "value": "1"
            }
          ]
        }
      ]
    }

    As a special case, the empty list "[]" or None return all elements.

    If field_mapping is specified, the field name provided in the spec
    is looked up in the field_mapping dictionary.  If there's a match,
    the result is substituted. Otherwise, the field name is used unchanged
    to form the query. This feature allows client-side programs to use
    "nice" names that can be mapped to more complex django names. If
    you decide to use this feature, you'll probably want to do a similar
    mapping on the field names being returned to the client.

    This function returns a Q object that can be used anywhere you'd like
    in the django query machinery.

    This function raises ValueError in case the query is malformed, or
    perhaps other errors from the underlying DB code.

    :param spec: Dict of params like so:
        {"condition": "AND", "rules":
            [
                {
                 "id": "price", "field": "price", "type": "double",
                 "input": "text", "operator": "lt", "value": "10.25"
                 }
            ]
        }

    :param field_mapping: Dict mapping of human-readable field names to
        db field names
    """

    if not spec or len(spec) == 0:
        return Q()
    """
    {
      "condition": "OR",
      "rules": [
        {
          "id": "date",
          "field": "date",
          "type": "date",
          "input": "text",
          "operator": "exact",
          "value": "1995/11/17"
        }
    """

    if 'condition' in spec:
        condition = spec['condition'].upper()

        result_q = Q()
        q_op = result_q.OR if condition == 'OR' else result_q.AND

        if condition == 'NOT':
            result_q.negate()

        for arg in spec.get('rules', []):
            q = jquery_builder(arg)
            if q:
                if not result_q:
                    result_q = q
                else:
                    result_q = result_q._combine(q, q_op)  # type: ignore

    else:
        # some other query, will be validated in the query machinery
        # {"id": "coord", "field": "coord", "type": "string", "operator": "exact", "value": "B.3"}
        field_name = spec['field']
        old_operator = spec['operator'].lower()
        value = spec['value']
        if spec['type'] == 'integer':
            if isinstance(value, (list, tuple)):
                value = [float(val) for val in value]
            else:
                value = float(value)

        # normalize to Django's Q operators
        operator = operator_mapping.get(old_operator, old_operator)

        # Q objects don't support negation, so convert !=, etc to opposites
        # and set "negate" for the resulting Q
        negate = 'not_' in old_operator

        # Handle NULL values correctly
        if str(value).lower() in {'null', 'none', ''}:
            value = True

        # Create comma-separated list for INs
        value_is_list = isinstance(value, (list, tuple))
        if operator == 'in' and not value_is_list:
            value = value.split(',')

        elif (
            value_is_list
            and len(value) == 1
            and operator in ('exact', 'contains', 'endswith', 'startswith')
        ):
            value = value[0]

        # see if the mapping contains an entry for the field_name
        # (for example, if you're mapping an external database name
        # to an internal django one).  If not, use the existing name.
        if field_mapping:
            field_name = field_mapping.get(field_name, field_name)

        result_q = Q((f'{field_name}{LOOKUP_SEP}{operator}', value))

        if negate:
            result_q.negate()

    return result_q


__all__ = (
    'get_query_params',
    'jquery_builder',
    'QTestMixin',
)
