#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ---------------------------------------------------------------------
# Copyright (c) Merchise Autrement [~º/~] and Contributors
# All rights reserved.
#
# This is free software; you can do what the LICENCE file allows you to.
#
"""Extensions to `odoo.osv.expression`.

This module reexport all symbols from the `odoo.osv.expression` so it can be
used a replacement.

Additions and changes:

- There's a new `Domain`:class: which allows to do some arithmetic with
  domains.

- The functions `AND`:func: and `OR`:func: don't need to you pass a domain in
  first normal form, they ensure that themselves.

- You can test some a weak form of implication, i.e 'Domain(X).implies(Y)' is
  True if can proof that whenever X is True, Y also is.  This method can have
  false negatives, but not false positives: there are cases for which we can't
  find the proof (we return False) which should be True; but if we return
  True, there's a proof.

"""
from __future__ import (
    division as _py3_division,
    print_function as _py3_print,
    absolute_import as _py3_abs_import,
)

import operator
from itertools import chain
from xoeuf.odoo.osv import expression as _odoo_expression
from xoeuf.eight import string_types

from . import ql


# TODO: `copy_members` will be deprecated in xoutil 1.8, use instead the same
# mechanisms as `xoutil.future`.
from xoutil.modules import copy_members as _copy_python_module_members

this = _copy_python_module_members(_odoo_expression)
del _copy_python_module_members
del _odoo_expression


try:
    from xoutil.objects import crossmethod  # TODO: migrate
except ImportError:

    class crossmethod(object):
        """Decorate a function as static or instance level.

        Example:

          >>> class Mule(object):
          ...     @crossmethod
          ...     def print_args(*args):
          ...         print(args)

          # Call it as a staticmethod
          >>> Mule.print_args()
          ()

          # Call it as an instance
          >>> Mule().print_args()   # doctest: +ELLIPSIS
          (<...Mule object at ...>,)

        .. note:: This is different from `hybridmethod`:func:.  Hybrid method
                  always receive the implicit argument (either `cls` or
                  `self`).

        """

        def __init__(self, func):
            self.func = func

        def __get__(self, instance, owner):
            if instance is None:
                return self.func
            else:
                return self.func.__get__(instance, owner)


UNARY_OPERATORS = [this.NOT_OPERATOR]
BINARY_OPERATORS = [this.AND_OPERATOR, this.OR_OPERATOR]
KIND_OPERATOR = "OPERATOR"
KIND_TERM = "TERM"


# Exports normalize_leaf so that we can replace 'from xoeuf.odoo.
def normalize_leaf(term):
    if this.is_leaf(term):
        left, operator, right = this.normalize_leaf(term)
        # Avoid no hashable values in domain terms
        if not getattr(right, "__hash__", False) and hasattr(right, "__iter__"):  # noqa
            right = tuple(right)
        return left, operator, right
    return term


class Domain(list):
    """A predicate expressed as an Odoo domain.

    .. note:: This is an *operational* wrapper around normal Odoo domains
              (lists) with methods to do logical manipulation of such values.

    It's a subtype of Odoo domains (i.e `list`:class:), which means that
    wherever an Odoo domain is expected you can use a Domain.  See the `Liskov
    substitution principle`__.

    __ https://en.wikipedia.org/wiki/Liskov_substitution_principle

    """

    def __init__(self, seq=None):
        # TODO: Can you do some sanity check to avoid common mistakes?  For
        # me, it's normal that I do ``Model.search(['field', '=', value])``
        # and forget the tuple...
        from xoeuf.odoo.tools.safe_eval import const_eval

        seq = seq or ()
        # some times the domains are saved in db in char or text fields.
        if isinstance(seq, string_types):
            seq = const_eval(seq)
        super(Domain, self).__init__(seq)

    def implies(self, other):
        """Check if a domain implies another.

        For any two domains `A` and `B`, the following rules are always true:

        - ``A.implies(A)``
        - ``(A & B).implies(A)``
        - ``A.implies(A | B)``.
        - ``not B.implies(A) == (A | B).implies(A)``

        """
        other = DomainTree(Domain(other).second_normal_form)
        return DomainTree(self.second_normal_form).implies(other)

    @property
    def first_normal_form(self):
        """The first normal form.

        The first normal form is the same domain with all `and` operators
        explicit.

        For instance, having ``domain`` value like::

            >>> domain = Domain(
            ...     [('field_y', 'not in', False), ('field_x', '!=', 'value')]
            ... )

        Then::

            >>> domain.first_normal_form
            ['&', ('field_y', 'not in', False), ('field_x', '!=', 'value')]

        """
        return Domain(this.normalize_domain(self))

    @property
    def second_normal_form(self):
        """The second normal form.

        After obtaining the `first_normal_from`:attr:, change terms to its
        canonical form, and distribute the `not` logical operator inside
        terms.

        .. seealso:: `normalize_leaf`:func: for the canonical form of terms.

        .. seealso:: `distribute_not`:meth:

        Example::

            >>> domain1 = Domain([
            ...     ('field_x', 'not in', False),
            ...     '!',
            ...     ('field_y', '>', 1)
            ... ])

            >>> domain2 = Domain([
            ...     '|',
            ...     ('field_x', 'not in', False),
            ...     ('field_y', '<>', 'value'),
            ...     ('field_z', '>', 1)
            ... ])

            >>> domain3 = Domain([
            ...     '|',
            ...     ('field_x', 'not in', False),
            ...     '!',
            ...     ('field_y', '<>', 'value'),
            ...     ('field_z', '>', 1)
            ... ])

            >>> domain1.first_normal_form
            ['&', ('field_x', '!=', False), ('field_y', '<=', 1)]

            >>> domain2.first_normal_form
            [
                '&',
                '|',
                ('field_x', '!=', False),
                ('field_y', '!=', 'value'),
                ('field_z', '>', 1)
            ]

            >>> domain3.first_normal_form
            [
                '&',
                '|',
                ('field_x', '!=', False),
                ('field_y', '=', 'value'),
                ('field_z', '>', 1)
            ]

        """
        res = self.first_normal_form
        res = Domain((normalize_leaf(item) for item in res))
        return res.distribute_not()

    @property
    def simplified(self):
        """A simplified second normal form of the domain.

        Examples:

            >>> domain1 = Domain([
            ...     ('field_x', '!=', False),
            ...     ('field_y', '=', 'value'),
            ...     ('field_z', 'in', (1, 2, 3)),
            ...     ('field_y', '=', 'value'),
            ... ])
            >>> domain1.simplified
            [
                '&',
                '&',
                ('field_x', '!=', False),
                ('field_y', '=', 'value'),
                ('field_z', 'in', (1, 2, 3))
            ]

            >>> domain2 = Domain([
            ...     '|',
            ...     ('field_x', '!=', False),
            ...     ('field_y', '=', 'value'),
            ...     ('field_z', 'in', (1, 2, 3)),
            ...     '|',
            ...     ('field_y', '=', 'value'),
            ...     ('field_x', '!=', False)
            ... ])
            >>> domain2.simplified
            [
                '&',
                '|',
                ('field_x', '!=', False),
                ('field_y', '=', 'value'),
                ('field_z', 'in', (1, 2, 3))
            ]

            >>> domain3 = Domain([
            ...     ('field_y', '!=', False),
            ...     ('field_x', 'in', (1,)),
            ...     '|',
            ...     ('field_y', '!=', False),
            ...     '|',
            ...     ('field_x', 'in', (1,)),
            ...     ('field_x', 'in', (2,))
            ... ])
            >>> domain3.simplified
            ['&', ('field_x', 'in', (1,)), ('field_y', '!=', False)]

        """
        return DomainTree(self.second_normal_form).get_simplified_domain()

    def distribute_not(self):
        """Return a new domain without `not` operators.

        For instance, having ``domain`` value like::

            >>> domain = Domain([
            ...     '!', ('field_x', '!=', False),
            ...     '!', ('field_y', '=', 'value'),
            ...     '!', ('field_z', 'in', (1, 2, 3)),
            ...     '!', ('field_w', '>', 1),
            ... ])

            >>> domain.distribute_not()
            [
                '&',
                '&',
                '&',
                ('field_x', '=', False),
                ('field_y', '!=', 'value'),
                ('field_z', 'not in', (1, 2, 3)),
                ('field_w', '<=', 1)
            ]

        """
        return Domain(this.distribute_not(self.first_normal_form))

    @crossmethod
    def AND(*domains):
        """Join given domains using `and` operator.

        :return: A domain if first normal form.

        """
        return Domain(
            this.AND([Domain(domain).second_normal_form for domain in domains])
        )

    __and__ = __rand__ = AND

    @crossmethod
    def OR(*domains):
        """Join given domains using `or` operator.

        :return: A domain if first normal form.

        """
        return Domain(
            this.OR([Domain(domain).second_normal_form for domain in domains])
        )

    __or__ = __ror__ = OR

    def __invert__(self):
        return Domain(["!"] + self.second_normal_form)

    def __eq__(self, other):
        """Two domains are equivalent if both have similar DomainTree.

        """
        # TODO: In logic, we can identify two predicates if: a implies
        # b and b implies a, although this has nothing to do with them being
        # the *same* predicate.  However, since this implementation only
        # yields True when both domains have the same hash, we can find a, b
        # such that a implies b and b implies a, but a != b.
        other = Domain(other)
        return hash(self) == hash(other)

    def __ne__(self, other):
        return not self == other

    def __hash__(self):
        return hash(DomainTree(self.second_normal_form))

    def asfilter(self, this="this"):
        """Return a callable which is equivalent to the domain.

        This method translates the domain to a `ast.Lambda <ast>`:mod: and
        compiles it.

        The main property is that is ``res`` is the result of a
        ``search(domain)``; then filtering by ``domain.asfilter()`` does not
        filter-out any record.

        The domain cannot use 'child_of', '=like' or '=ilike'.

        .. note:: In Python ``0 == False``, so Odoo treats 0 specially in the
                  context of 'not in' and 'in'.  See `PR 31408`__ for more
                  information.

        __ https://github.com/odoo/odoo/pull/31408

        :param this: The name of the argument in the lambda.  All attributes
            in the domain are get from this argument.

        The lambda created for::

            Domain([('state', 'in', ('draft', 'open'))]).asfilter()

        is equivalent to::

            lambda this: this.state in ('draft', 'open')

        .. rubric:: Traversing fields in domains

        If your domain uses field traversal (e.g ``('line_ids.state', ...)``)
        the generated lambda will use ``mapped()`` and ``filtered()`` instead
        of simple ``ast.Attribute`` nodes.  Thus the lamda for::

          Domain([('order_id.line_ids.state', '=', 'open')]).asfilter(this='t')

        is equivalent to::

          lambda t: (t.mapped('order_id.line_ids')
                      .filtered(lambda r: r.state == 'open'))

        .. versionadded:: 0.54.0

        .. versionchanged:: 0.55.0 Change the behavior of fields traversal, so
           that filters over x2many fields work.

        """
        return eval(compile(self._get_filter_ast(this), "<domain>", "eval"))

    def _get_filter_ast(self, this="this"):
        return DomainTree(self.second_normal_form)._get_filter_ast(this)


class DomainTerm(object):
    def __init__(self, term):
        if isinstance(term, DomainTerm):
            term = term.original
        self.original = term
        term = normalize_leaf(term)
        self.normalized = term
        if this.is_operator(term):
            self.is_operator = True
            self.operator = term
            self.is_leaf = self.left = self.right = False
        elif this.is_leaf(term):
            self.is_operator = False
            self.is_leaf = True
            self.left, self.operator, self.right = term
        else:
            # TODO: May be a # TypeError.
            raise ValueError("Invalid domain term %r" % term)

    def __getitem__(self, x):
        if self.is_leaf:
            return (self.left, self.operator, self.right)[x]
        else:
            return self.operator[x]

    def __eq__(self, other):
        if not isinstance(other, DomainTerm):
            other = DomainTerm(other)
        return hash(self) == hash(other)

    def __ne__(self, other):
        return not self == other

    def __repr__(self):
        if self.original == self.normalized:
            return repr(self.original)
        else:
            return "%r => %r" % (self.original, self.normalized)

    operators_implication = {
        "=?": lambda x, y: operator.eq(x, y) or y is False,
        ">": operator.ge,
        ">=": operator.ge,
        "<": operator.le,
        "<=": operator.le,
        "in": lambda x, y: all(i in y for i in x),
        "not in": lambda x, y: all(i in x for i in y),
        "like": lambda x, y: x.find(y) >= 0,
        # TODO: asd_g imply asdfg `_` == any character
        "=like": lambda x, y: x.find(y) >= 0,
        "not like": lambda x, y: y.find(x) >= 0,
        "ilike": lambda x, y: x.lower().find(y.lower()) >= 0,
        # TODO: asd_g imply asdfg `_` == any character
        "=ilike": lambda x, y: x.lower().find(y.lower()) >= 0,
        "not ilike": lambda x, y: y.lower().find(x.lower()) >= 0,
    }

    def implies(self, other):
        other = DomainTerm(other)
        # equals terms are implied.
        if self == other:
            return True
        # terms which different left operand can't be compared.
        elif self.left != other.left:
            return False
        elif self.is_operator:
            # & => &
            return self.operator == other.operator
        else:
            # (x = 1)  => (x = 1)
            # (x > 1) => (x > 0)
            # (x in (1,2,3)) => (x in (1,2,3,4))
            if self.operator == other.operator:
                compare = self.operators_implication.get(other.operator)
                return compare(self.right, other.right) if compare else False
            else:
                # TODO: x = 1  implies x != 2; x = 2 1 implies x > 1
                return False

    def __hash__(self):
        return hash(self.normalized)


class DomainTree(object):
    """Tree structure to express Odoo domains.

    .. warning:: This class in not a public API of this model.  Its attributes
       may change in incompatible ways from one release to the other.

    A domain like this::

      [
          '&',
          '&',
          ('field_y', '!=', False),
          ('field_x', '=', 'value'),
          '|',
          ('field_z', 'in', (1, 2, 3)),
          ('field_w', '>', 1)
      ]

    Is represented like this::

                                     +-----+
                                     | '&' |
                                     +--+--+
                                        |
                   +--------------------+------------------------+
                   |                    |                        |
      +------------+-----------+ +------+------------------+  +--+--+
      |('field_y', '!=', False)| |('field_x', '=', 'value')|  | '|' |
      +------------------------+ +-------------------------+  +--+--+
                                                                 |
                                  +------------------------------+--------+
                                  |                                       |
                     +------------+---------------+       +---------------+---+
                     |('field_z', 'in', (1, 2, 3))|       |('field_w', '>', 1)|
                     +----------------------------+       +-------------------+

    .. warning:: The domain must be in the second normal form.

    """

    def __init__(self, domain, parent=None):
        term = domain.pop(0)
        self.term = DomainTerm(term)
        self.parent = parent
        if term in this.DOMAIN_OPERATORS:
            count = 2  # minimum number of operand in an operation.
            children = set()
            while count:
                if domain[0] == term:
                    count += 1
                    domain.pop(0)
                else:
                    child = DomainTree(domain, self)
                    # A & ((B & C) | A) should be simplified as A & B & C
                    if child.term == self.term:
                        children |= child.children
                    else:
                        children.add(child)
                    count -= 1
            # if a tree node have only one child it be come into it child.
            if len(children) == 1:
                child = children.pop()
                self.term = child.term
                self.children = child.children
            else:
                self.children = children
        else:
            self.children = set()
        self._simplify()

    @property
    def is_operator(self):
        return self.term in this.DOMAIN_OPERATORS

    @property
    def is_leaf(self):
        return not self.is_operator

    def _simplify(self):
        """Remove redundant branches.

        """
        for child in set(self.children):
            if self.term == this.AND_OPERATOR:
                # If current `child` is implied by any other ignore it.
                func = lambda x, y: y.implies(x)
            else:
                # If current `child` implies any other ignore it.
                func = lambda x, y: x.implies(y)
            if any(func(child, y) for y in self.children - {child}):
                self.children.remove(child)
        if len(self.children) == 1:
            _self = self.children.pop()
            self.children = _self.children
            self.term = _self.term

    @property
    def sorted_children(self):
        # TODO: Sort by hash is weird.  What does it mean?
        return sorted(self.children, key=lambda item: hash(item))

    def get_simplified_domain(self):
        if self.parent:
            res = Domain(self.term.original for x in range(1, len(self.children) or 2))
        elif self.is_leaf:
            res = Domain([self.term.original])
        else:
            # Initials `&` aren't needed.
            res = Domain([self.term.original] if self.term == this.OR_OPERATOR else [])
        if not self.is_leaf:
            res.extend(
                chain(*(x.get_simplified_domain() for x in self.sorted_children))
            )
        return res

    def __repr__(self):
        if self.is_leaf:
            return repr(self.term)
        else:
            return "(%s)" % (" %r " % self.term).join(
                repr(child) for child in self.sorted_children
            )

    def __eq__(self, other):
        if self.is_leaf == other.is_leaf:
            if self.is_leaf:
                return self.term == other.term
            else:
                return (
                    self.term == other.term and not self.children ^ other.children
                )  # noqa
        return False

    def __ne__(self, other):
        return not self == other

    def implies(self, other):
        funct = all if other.term == this.AND_OPERATOR else any
        if self.is_leaf:
            # A => A
            if self.term.implies(other.term):
                return True
            # A => A | B
            if other.is_operator and funct(
                self.implies(child) for child in other.sorted_children
            ):
                return True
        elif self.is_operator:
            funct2 = any if self.term == this.AND_OPERATOR else all
            # A & B => A
            if funct2(child.implies(other) for child in self.sorted_children):
                return True
            # A & B => A | B
            if funct(
                funct2(child.implies(other_child) for child in self.sorted_children)
                for other_child in other.sorted_children
            ):
                return True
        return False

    def __hash__(self):
        return hash(tuple([self.term] + self.sorted_children))

    def _get_filter_ast(self, this="this"):
        "Get compilable AST of the lambda obtained by `get_filter`:func:."
        stack = []
        for kind, term in self.walk():
            if kind == KIND_TERM:
                fieldname, op, value = term
                constructor = _TERM_CONSTRUCTOR[op]
                stack.append(constructor(this, fieldname, value))
            else:
                assert kind == KIND_OPERATOR
                if term in BINARY_OPERATORS:
                    args = [stack.pop(), stack.pop()]
                else:
                    args = [stack.pop()]
                constructor = _TERM_CONSTRUCTOR[term]
                stack.append(constructor(*args))
        node = stack.pop()
        assert not stack, "Remaining nodes in the stack: {}".format(stack)
        fn = ql.ensure_compilable(
            ql.Expression(ql.Lambda(ql.make_arguments(this), node))
        )
        return fn

    def walk(self):
        """Performs a post-fix walk of the tree.

        Yields tuples of ``(kind, what)``.  `kind` can be either 'TERM' or
        'OPERATOR'.  `what` will be the term or operator.  For `term` it will
        the tuple ``(field, operator, arg)`` in Odoo domains.  For `operator`
        it will be the string identifying the operator.

        Despite that the tree allows AND (``&``) and OR (``|``) operator to
        have more than two children, we generate OPERATOR items for the
        equivalent binary expression tree::

           >>> from xoeuf.osv.expression import Domain, DomainTree
           >>> d = Domain([('a', '=', 1), ('b', '=', 2), ('c', '=', 3)])
           >>> tree = DomainTree(d.second_normal_form)
           >>> list(tree.walk())
           [('TERM', ('a', '=', 1)),
            ('TERM', ('c', '=', 3)),
            ('TERM', ('b', '=', 2)),
            ('OPERATOR', '&'),
            ('OPERATOR', '&')]

        .. note:: DomainTree always simplify its input.  The walk is not a
           syntactical walk of the input but of its simplified form.  Since
           all operators are commutative, the order of the children may differ
           widely from run to the other.

        """
        if self.is_leaf:
            yield (KIND_TERM, self.term.original)
        else:
            for which in self.children:
                for what in which.walk():
                    yield what
            yield (KIND_OPERATOR, self.term)
            # Since the tree groups more than two children under a single
            # instance of binary operator, we have to yield it once per pair.
            if self.term.original in BINARY_OPERATORS:
                for _ in range(len(self.children) - 2):
                    yield (KIND_OPERATOR, self.term)


# Exports AND and OR so that we can replace 'from xoeuf.odoo.
def AND(domains):
    return Domain.AND(*domains)


def OR(domains):
    return Domain.OR(*domains)


def _constructor_not(node):
    return ql.UnaryOp(ql.Not(), node)


def _constructor_and(*operands):
    return ql.BoolOp(ql.And(), list(operands))


def _constructor_or(*operands):
    return ql.BoolOp(ql.Or(), list(operands))


def _get_mapped(node, fieldname):
    attrs, field = fieldname.rsplit(".", 1)
    mapped_fn = ql.make_attr(node, "mapped")
    return (ql.make_call(mapped_fn, _constructor_from_value(attrs)), field)


def _constructor_getattr(node, fieldname):
    if isinstance(fieldname, string_types):
        if "." in fieldname:
            result = _get_mapped(node, fieldname)
        else:
            result = ql.make_attr(node, fieldname)
    else:
        result = _constructor_from_value(fieldname)
    return result


def _get_constructor(qst):
    """Return a constructor for AST for a term `(fielname, <op>, value)`.

    :param qst: Any of the operators AST classes available for comparisons.

    The result is a function that with signaure ``(this, fieldname, value)``.

    :param this: The AST node to which the operation is applied.

    :param fieldname: The name of the field being operated upon.

    :param value: The second operand of the operator.

    If the fieldname doesn't contain a dot '.'; return a simple AST.  A domain
    like::

        Domain(['state', '=', 'open']).asfilter()

    yields a callable equivalent to::

        lambda this: this.state == 'open'

    If the fieldname contains a dot '.'; return an AST using ``mapped()`` and
    ``filtered()`` as appropriate:

        Domain(['parent_id.children_ids.state', '=', 'open']).asfilter()

    yields a callable equivalent to::

        lambda this: this.mapped('parent_id.children_ids').filtered(
            lambda r: r.state == 'open'
        )

    """

    def result(this, fieldname, value):
        node = _constructor_getattr(ql.Name(this, ql.Load()), fieldname)
        if not isinstance(node, tuple):
            return ql.Compare(node, [qst()], [_constructor_from_value(value)])
        else:
            # node.filtered(lambda x: x.field <CMP> <value>)
            mapped, field = node
            lambda_ast = ql.Lambda(
                ql.make_arguments("x"),
                ql.Compare(
                    ql.make_attr(ql.Name("x", ql.Load()), field),
                    [qst()],
                    [_constructor_from_value(value)],
                ),
            )
            filtered_fn = ql.make_attr(mapped, "filtered")
            return ql.make_call(filtered_fn, lambda_ast)

    return result


_constructor_eq = _get_constructor(ql.Eq)
_constructor_ne = _get_constructor(ql.NotEq)
_constructor_le = _get_constructor(ql.LtE)
_constructor_lt = _get_constructor(ql.Lt)
_constructor_ge = _get_constructor(ql.GtE)
_constructor_gt = _get_constructor(ql.Gt)


def _constructor_in(this, fieldname, value, qst=ql.In):
    """Create the AST for a term `(fielname, '[not ]in', value)`.

    The difference with `standard <_get_constructor>`:func: constructors is
    that, to comply with Odoo's interpretation of 'in' and 'not in', this
    function removes any 0 or False in `value`.

    """
    # Filtering False is the same Odoo does; which causes 0 to be removed
    # also.  See https://github.com/odoo/odoo/pull/31408
    assert qst in (ql.In, ql.NotIn)
    value = [x for x in value if x != False]  # noqa
    return _get_constructor(qst)(this, fieldname, value)


def _constructor_not_in(this, fieldname, value):
    return _constructor_in(this, fieldname, value, qst=ql.NotIn)


def _constructor_like(this, fieldname, value, qst=ql.In):
    """Create the AST for a term `(fielname, '[not ]like', value)`.

    We use 'in' ('not in') Python operators; so the procedure AST are those
    matching syntax 'value in this.fieldname' when fieldname doesn't contain a
    dot.  If `fieldname` contains a dot the result changes to::

       headattrs, lastattr = fiedname.rsplit('.', 1)
       this.mapped(headattrs).filtered(lambda r: value in getattr(r, lastattr))

    """
    assert qst in (ql.In, ql.NotIn)
    node = _constructor_getattr(ql.Name(this, ql.Load()), fieldname)
    if isinstance(node, tuple):
        # this.mapped(attrs).filtered(lambda r: value in r.field)
        mapped, field = node
        lambda_arg = ql.Lambda(
            ql.make_arguments("r"),
            ql.Compare(
                _constructor_from_value(value),
                [qst()],
                [ql.make_attr(ql.Name("r", ql.Load()), field)],
            ),
        )
        fn = ql.make_attr(mapped, "filtered")
        return ql.make_call(fn, lambda_arg)
    else:
        # ``value in this.fieldname``
        return ql.Compare(_constructor_from_value(value), [qst()], [node])


def _constructor_not_like(this, fieldname, value):
    return _constructor_like(this, fieldname, value, qst=ql.NotIn)


def _constructor_ilike(this, fieldname, value, qst=ql.In):
    assert qst in (ql.In, ql.NotIn)
    node = _constructor_getattr(ql.Name(this, ql.Load()), fieldname)
    if isinstance(node, tuple):
        # this.mapped(attrs).filtered(lambda r: value.lower() in r.field.lower())``
        mapped, field = node
        lambda_arg = ql.Lambda(
            ql.make_arguments("r"),
            ql.Compare(
                ql.make_argless_call(
                    _constructor_getattr(_constructor_from_value(value), "lower")
                ),
                [qst()],
                [
                    ql.make_argless_call(
                        ql.make_attr(
                            ql.make_attr(ql.Name("r", ql.Load()), field), "lower"
                        )
                    )
                ],
            ),
        )
        fn = ql.make_attr(mapped, "filtered")
        return ql.make_call(fn, lambda_arg)
    else:
        # ``value.lower() in this.fieldname.lower()``
        node = _constructor_getattr(node, "lower")
        fn = ql.make_argless_call(node)
        return ql.Compare(
            ql.make_argless_call(
                _constructor_getattr(_constructor_from_value(value), "lower")
            ),
            [qst()],
            [fn],
        )


def _constructor_not_ilike(this, fieldname, value):
    return _constructor_ilike(this, fieldname, value, qst=ql.NotIn)


def _constructor_from_value(value):
    expr = ql.parse(repr(value))
    return expr.body


_TERM_CONSTRUCTOR = {
    "!": _constructor_not,
    "&": _constructor_and,
    "|": _constructor_or,
    "=": _constructor_eq,
    "==": _constructor_eq,
    "=?": _constructor_eq,
    "!=": _constructor_ne,
    "<>": _constructor_ne,
    "<=": _constructor_le,
    "<": _constructor_lt,
    ">=": _constructor_ge,
    ">": _constructor_gt,
    "in": _constructor_in,
    "not in": _constructor_not_in,
    "like": _constructor_like,
    "ilike": _constructor_ilike,
    "not like": _constructor_not_like,
    "not ilike": _constructor_not_ilike,
}
