"""These classes map to the FES 2.0 specification for operators.
The class names and attributes are identical to those in the FES spec.
"""
from __future__ import annotations

import operator
from dataclasses import dataclass, field
from decimal import Decimal
from enum import Enum
from functools import reduce
from itertools import groupby
from typing import Any, Union
from xml.etree.ElementTree import Element, QName

from django.conf import settings
from django.contrib.gis import measure
from django.db.models import Q
from django.utils.functional import cached_property

from gisserver.exceptions import ExternalParsingError, OperationProcessingFailed
from gisserver.parsers import gml
from gisserver.parsers.base import BaseNode, TagNameEnum, tag_registry
from gisserver.parsers.tags import expect_children, expect_tag, get_attribute, get_child
from gisserver.types import FES20

from .expressions import Expression, Literal, RhsTypes, ValueReference
from .identifiers import Id
from .query import CompiledQuery

SpatialDescription = Union[gml.GM_Object, gml.GM_Envelope, ValueReference]
TemporalOperand = Union[gml.TM_Object, ValueReference]


# Define interface for any class that has "build_rhs()"
try:
    from typing import Protocol
except ImportError:
    HasBuildRhs = Any  # Python 3.7 and below
else:

    class HasBuildRhs(Protocol):
        def build_rhs(self, compiler) -> RhsTypes:
            ...


if "django.contrib.postgres" in settings.INSTALLED_APPS:
    # Comparisons with array fields go through a separate ORM lookup expression,
    # so these can check whether ANY element matches in the array.
    # This gives consistency between other repeated elements (e.g. M2M, reverse FK)
    # where the whole object is returned when one of the sub-objects match.
    ARRAY_LOOKUPS = {
        "exact": "fes_anyexact",
        "fes_notequal": "fes_anynotequal",
        "lt": "fes_anylt",
        "lte": "fes_anylte",
        "gt": "fes_anygt",
        "gte": "fes_anygte",
    }
else:
    ARRAY_LOOKUPS = None


class MatchAction(Enum):
    """Values for the 'matchAction' attribute of the BinaryComparisonOperator."""

    All = "All"
    Any = "Any"
    One = "One"

    def __repr__(self):
        # Make repr(filter) easier to copy-paste
        return f"{self.__class__.__name__}.{self.name}"


class BinaryComparisonName(TagNameEnum):
    """XML tag names for value comparisons.
    Values are the used field lookup.
    """

    PropertyIsEqualTo = "exact"
    PropertyIsNotEqualTo = "fes_notequal"  # custom FesNotEqualTo lookup
    PropertyIsLessThan = "lt"
    PropertyIsGreaterThan = "gt"
    PropertyIsLessThanOrEqualTo = "lte"
    PropertyIsGreaterThanOrEqualTo = "gte"


class DistanceOperatorName(TagNameEnum):
    """XML tag names for distance operators"""

    Beyond = "fes_beyond"  # using __distance_gt=.. would be slower.
    DWithin = "dwithin"  # ST_DWithin uses indices, distance_lte does not.


class SpatialOperatorName(TagNameEnum):
    """XML tag names for geometry operators"""

    # (A Within B) implies that (B Contains A)

    # BBOX can either be implemented using bboverlaps (more efficient), or the
    # more correct "intersects" option (e.g. a line near the box would match otherwise).
    BBOX = "intersects"  # ISO version: "NOT DISJOINT"
    Equals = "equals"  # Test whether t geometries are topologically equal
    Disjoint = "disjoint"  # Tests whether two geometries are disjoint (do not interact)
    Intersects = "intersects"  # Tests whether two geometries intersect
    Touches = "touches"  # Tests whether two geometries touch
    Crosses = "crosses"  # Tests whether two geometries cross
    Within = "within"  # Tests whether a geometry is within another one
    Contains = "contains"  # Tests whether a geometry contains another one
    Overlaps = "overlaps"  # Test whether two geometries overlap


class TemporalOperatorName(TagNameEnum):
    """XML tag names for datetime operators.

    Explanation here: http://old.geotools.org/Temporal-Filters_211091519.html
    and: https://github.com/geotools/geotools/wiki/temporal-filters
    """

    After = "after"
    Before = "before"
    Begins = "begins"
    BegunBy = "begunby"
    TContains = "tcontains"
    TEquals = "tequals"
    TOverlaps = "toverlaps"
    During = "during"
    Meets = "meets"
    OverlappedBy = "overlappedby"
    MetBy = "metby"
    EndedBy = "endedby"
    AnyInteracts = "anyinteracts"


class BinaryLogicType(TagNameEnum):
    """XML tag names for the BinaryLogicOperator."""

    And = operator.and_
    Or = operator.or_


class UnaryLogicType(TagNameEnum):
    """XML tag names for the UnaryLogicOperator."""

    Not = operator.inv


@dataclass
class Measure(BaseNode):
    """The <fes:Distance uom="...> element."""

    xml_ns = FES20

    value: Decimal
    uom: str  # Unit of measurement, fes20:UomSymbol | fes20:UomURI

    @classmethod
    @expect_tag(FES20, "Distance")
    def from_xml(cls, element: Element):
        return cls(value=Decimal(element.text), uom=get_attribute(element, "uom"))

    def build_rhs(self, compiler) -> measure.Distance:
        return measure.Distance(default_unit=self.uom, **{self.uom: self.value})


class Operator(BaseNode):
    """Abstract base class, as defined by FES spec."""

    xml_ns = FES20

    def build_query(self, compiler: CompiledQuery) -> Q | None:
        raise NotImplementedError(
            f"Using {self.__class__.__name__} is not supported yet."
        )


@dataclass
class IdOperator(Operator):
    """List of ResourceId objects"""

    id: list[Id]

    @property
    def type_names(self) -> list[str]:
        """Provide a list of all type names accessed by this operator"""
        return [
            type_name for type_name in self.grouped_ids.keys() if type_name is not None
        ]

    @cached_property
    def grouped_ids(self) -> dict[str, list[Id]]:
        # For itertools.groupby(), items have to be sorted first.
        ids = sorted(self.id, key=operator.attrgetter("rid"))
        return {
            type_name: list(items)
            for type_name, items in groupby(ids, key=operator.attrgetter("type_name"))
        }

    def build_query(self, compiler):
        """Generate the ID lookup query.

        As these identifiers also reference the type name, no Q-object is
        returned. The lookups are directly added to the fes query object.
        """
        # When ResourceId has no type_name associated, this means that the
        # parsing was invalid. Instead of raising an error, empty results are returned.
        if len(self.grouped_ids) == 1 and self.grouped_ids.get(None):
            compiler.mark_empty()
            return

        for type_name, items in self.grouped_ids.items():
            ids_subset = reduce(
                operator.or_, [id.build_query(compiler=None) for id in items]
            )
            compiler.add_lookups(ids_subset, type_name=type_name)


class NonIdOperator(Operator):
    """Abstract base class, as defined by FES spec.

    This is used for nearly all operators,
    except those that have <fes:ResourceId> elements as children.
    """

    _source = None
    allow_geometries = False

    def build_compare(
        self,
        compiler: CompiledQuery,
        lhs: Expression,
        lookup: str,
        rhs: Expression | RhsTypes,
    ) -> Q:
        """Use the value in comparison with some other expression.

        This calls build_lhs() and build_rhs() on the expressions.
        """
        # lhs and rhs are allowed to be reversed. However, the SQL compiler
        # works much simpler when Django can predict the actual data type.
        if isinstance(lhs, Literal) and isinstance(rhs, ValueReference):
            lhs, rhs = rhs, lhs

        if compiler.feature_type is not None:
            lookup = self.validate_comparison(compiler, lhs, lookup, rhs)

        # Build Django Q-object
        lhs = lhs.build_lhs(compiler)

        if isinstance(rhs, (Expression, gml.GM_Object)):
            rhs = rhs.build_rhs(compiler)

        comparison = Q(**{f"{lhs}__{lookup}": rhs})
        return compiler.apply_extra_lookups(comparison)

    def validate_comparison(
        self,
        compiler: CompiledQuery,
        lhs: Expression,
        lookup: str,
        rhs: Expression | RhsTypes,
    ):
        """Validate whether a given comparison is even possible.
        Where needed, the lhs/rhs are already ordered in a logical sequence.
        """
        if isinstance(lhs, ValueReference):
            xsd_element = compiler.feature_type.resolve_element(lhs.xpath).child
            tag = self._source if self._source is not None else None

            # e.g. deny <PropertyIsLessThanOrEqualTo> against <gml:boundedBy>
            if xsd_element.is_geometry and not self.allow_geometries:
                raise OperationProcessingFailed(
                    "filter",
                    f"Operator '{tag}' does not support comparing"
                    f" geometry properties: '{xsd_element.xml_name}'.",
                    status_code=400,  # not HTTP 500 here. Spec allows both.
                )

            if isinstance(rhs, Literal):
                # Since the element is resolved, inform the Literal how to parse the value.
                # This avoids various validation errors along the path.
                rhs.bind_type(xsd_element.type)

                # When a common case of value comparison is done, the inputs
                # can be validated before the ORM query is constructed.
                xsd_element.validate_comparison(rhs.raw_value, lookup=lookup, tag=tag)

            # Checking scalar values against array fields will fail.
            # However, to make the queries consistent with other unbounded types (i.e. M2M fields),
            # it makes sense to return an object when *one* entry in the array matches.
            if xsd_element.is_array:
                try:
                    return ARRAY_LOOKUPS[lookup]
                except KeyError:
                    raise OperationProcessingFailed(
                        "filter",
                        f"Operator '{tag}' is not supported for "
                        f"the '{xsd_element.name}' property.",
                        status_code=400,  # not HTTP 500 here. Spec allows both.
                    ) from None

        return lookup

    def build_compare_between(
        self,
        compiler: CompiledQuery,
        lhs: Expression,
        lookup: str,
        rhs: tuple[HasBuildRhs, HasBuildRhs],
    ) -> Q:
        """Use the value in comparison with 2 other values (e.g. between query)"""
        if compiler.feature_type is not None:
            self.validate_comparison(compiler, lhs, lookup, rhs[0])
            self.validate_comparison(compiler, lhs, lookup, rhs[1])

        field_name = lhs.build_lhs(compiler)
        comparison = Q(
            **{
                f"{field_name}__{lookup}": (
                    rhs[0].build_rhs(compiler),
                    rhs[1].build_rhs(compiler),
                )
            }
        )
        return compiler.apply_extra_lookups(comparison)


class SpatialOperator(NonIdOperator):
    """Abstract base class, as defined by FES spec."""


@dataclass
@tag_registry.register_names(DistanceOperatorName)  # <Beyond>, <DWithin>
class DistanceOperator(SpatialOperator):
    """Comparing the distance to a geometry."""

    allow_geometries = True  # override static attribute

    valueReference: ValueReference
    operatorType: DistanceOperatorName
    geometry: gml.GM_Object
    distance: Measure
    _source: str | None = field(compare=False, default=None)

    @classmethod
    @expect_children(3, ValueReference, gml.GM_Object, Measure)
    def from_xml(cls, element: Element):
        geometries = gml.find_gml_nodes(element)
        if not geometries:
            raise ExternalParsingError(f"Missing gml element in <{element.tag}>")
        elif len(geometries) > 1:
            raise ExternalParsingError(
                f"Multiple gml elements found in <{element.tag}>"
            )

        return cls(
            valueReference=ValueReference.from_xml(
                get_child(element, FES20, "ValueReference")
            ),
            operatorType=DistanceOperatorName.from_xml(element),
            geometry=gml.parse_gml_node(geometries[0]),
            distance=Measure.from_xml(get_child(element, FES20, "Distance")),
            _source=element.tag,
        )

    def build_query(self, compiler: CompiledQuery) -> Q:
        return self.build_compare_between(
            compiler,
            lhs=self.valueReference,
            lookup=self.operatorType.value,
            rhs=(self.geometry, self.distance),
        )


@dataclass
@tag_registry.register_names(SpatialOperatorName)  # <BBOX>, <Equals>, ...
class BinarySpatialOperator(SpatialOperator):
    """A comparison of geometries using 2 values, e.g. A Within B."""

    allow_geometries = True  # override static attribute

    operatorType: SpatialOperatorName
    operand1: ValueReference | None
    operand2: SpatialDescription
    _source: str | None = field(compare=False, default=None)

    @classmethod
    def from_xml(cls, element: Element):
        operator_type = SpatialOperatorName.from_xml(element)
        if operator_type is SpatialOperatorName.BBOX and len(element) == 1:
            # For BBOX, the geometry operator is optional
            ref = None
            geo = element[0]
        else:
            if len(element) != 2:
                raise ExternalParsingError(f"{element.tag} should have 2 operators")
            ref, geo = list(element)

        return cls(
            operatorType=operator_type,
            operand1=ValueReference.from_xml(ref) if ref is not None else None,
            operand2=tag_registry.from_child_xml(
                geo, allowed_types=SpatialDescription.__args__  # get_args() in 3.8
            ),
            _source=element.tag,
        )

    def build_query(self, compiler: CompiledQuery) -> Q:
        operant1 = self.operand1
        if operant1 is None:
            operant1 = ValueReference(xpath=compiler.feature_type.geometry_field.name)

        return self.build_compare(
            compiler,
            lhs=operant1,
            lookup=self.operatorType.value,
            rhs=self.operand2,
        )


@dataclass
@tag_registry.register_names(TemporalOperatorName)  # <After>, <Before>, ...
class TemporalOperator(NonIdOperator):
    """Comparisons with dates"""

    operatorType: TemporalOperatorName
    operand1: ValueReference
    operand2: TemporalOperand
    _source: str | None = field(compare=False, default=None)

    @classmethod
    @expect_children(2, ValueReference, TemporalOperand)
    def from_xml(cls, element: Element):
        return cls(
            operatorType=TemporalOperatorName.from_xml(element),
            operand1=ValueReference.from_xml(element[0]),
            operand2=tag_registry.from_child_xml(
                element[1], allowed_types=TemporalOperand.__args__  # get_args() in 3.8
            ),
            _source=element.tag,
        )


class ComparisonOperator(NonIdOperator):
    """Base class for comparisons"""

    # Start counting fresh here, to collect the capabilities
    # that are listed in the <fes20:ComparisonOperators> node:
    xml_tags = []


@dataclass
@tag_registry.register_names(BinaryComparisonName)  # <PropertyIs...>
class BinaryComparisonOperator(ComparisonOperator):
    """A comparison between 2 values, e.g. A == B."""

    operatorType: BinaryComparisonName
    expression: tuple[Expression, Expression]
    matchCase: bool = True
    matchAction: MatchAction = MatchAction.Any
    _source: str | None = field(compare=False, default=None)

    @classmethod
    @expect_children(2, Expression, Expression)
    def from_xml(cls, element: Element):
        return cls(
            operatorType=BinaryComparisonName.from_xml(element),
            expression=(
                Expression.from_child_xml(element[0]),
                Expression.from_child_xml(element[1]),
            ),
            matchCase=element.get("matchCase", True),
            matchAction=MatchAction(
                element.get("matchAction", default=MatchAction.Any)
            ),
            _source=element.tag,
        )

    def build_query(self, compiler: CompiledQuery) -> Q:
        lhs, rhs = self.expression
        return self.build_compare(
            compiler, lhs=lhs, lookup=self.operatorType.value, rhs=rhs
        )


@dataclass
@tag_registry.register("PropertyIsBetween")
class BetweenComparisonOperator(ComparisonOperator):
    """Check whether a value is between two elements."""

    expression: Expression
    lowerBoundary: Expression
    upperBoundary: Expression
    _source: str | None = field(compare=False, default=None)

    @classmethod
    @expect_children(3, Expression, "LowerBoundary", "UpperBoundary")
    def from_xml(cls, element: Element):
        if element[1].tag != QName(FES20, "LowerBoundary") or element[2].tag != QName(
            FES20, "UpperBoundary"
        ):
            raise ExternalParsingError(
                f"{element.tag} should have 3 child nodes: "
                f"(expression), <LowerBoundary>, <UpperBoundary>"
            )

        lower = get_child(element, FES20, "LowerBoundary")
        upper = get_child(element, FES20, "UpperBoundary")

        if len(lower) != 1:
            raise ExternalParsingError(
                f"{lower.tag} should have 1 expression child node"
            )
        if len(upper) != 1:
            raise ExternalParsingError(
                f"{upper.tag} should have 1 expression child node"
            )

        return cls(
            expression=Expression.from_child_xml(element[0]),
            lowerBoundary=Expression.from_child_xml(lower[0]),
            upperBoundary=Expression.from_child_xml(upper[0]),
            _source=element.tag,
        )

    def build_query(self, compiler: CompiledQuery) -> Q:
        return self.build_compare_between(
            compiler,
            lhs=self.expression,
            lookup="range",
            rhs=(self.lowerBoundary, self.upperBoundary),
        )


@dataclass
@tag_registry.register("PropertyIsLike")
class LikeOperator(ComparisonOperator):
    """Perform wildcard matching."""

    expression: tuple[Expression, Expression]
    wildCard: str
    singleChar: str
    escapeChar: str
    _source: str | None = field(compare=False, default=None)

    @classmethod
    @expect_children(2, Expression, Expression)
    def from_xml(cls, element: Element):
        return cls(
            expression=(
                Expression.from_child_xml(element[0]),
                Expression.from_child_xml(element[1]),
            ),
            # These attributes are required by the WFS spec:
            wildCard=get_attribute(element, "wildCard"),
            singleChar=get_attribute(element, "singleChar"),
            escapeChar=get_attribute(element, "escapeChar"),
            _source=element.tag,
        )

    def build_query(self, compiler: CompiledQuery) -> Q:
        lhs, rhs = self.expression
        if isinstance(rhs, Literal):
            value = str(rhs.value)  # value could be auto-casted to int.

            # Not using r"\" here as that is a syntax error.
            if self.escapeChar != "\\":
                value = value.replace("\\", "\\\\").replace(self.escapeChar, "\\")
            if self.wildCard != "%":
                value = value.replace("%", r"\%").replace(self.wildCard, "%")
            if self.singleChar != "_":
                value = value.replace("_", r"\_").replace(self.singleChar, "_")

            rhs = Literal(raw_value=value)
        else:
            raise ExternalParsingError(
                f"Expected a literal value for the {self.tag} operator."
            )

        # Use the FesLike lookup
        return self.build_compare(compiler, lhs=lhs, lookup="fes_like", rhs=rhs)


@dataclass
@tag_registry.register("PropertyIsNil")
class NilOperator(ComparisonOperator):
    """Check whether the value evaluates to null/None.
    If the WFS returned a property element with <tns:p xsi:nil='true'>, this returns true.
    """

    expression: Expression | None
    nilReason: str
    _source: str | None = field(compare=False, default=None)

    @classmethod
    @expect_children(1, Expression)
    def from_xml(cls, element: Element):
        return cls(
            expression=Expression.from_child_xml(element[0]) if element else None,
            nilReason=element.get("nilReason"),
            _source=element.tag,
        )

    def build_query(self, compiler: CompiledQuery) -> Q:
        # Any value that evaluates to None is returned as 'xs:nil' in our output.
        return self.build_compare(
            compiler, lhs=self.expression, lookup="isnull", rhs=True
        )


@dataclass
@tag_registry.register("PropertyIsNull")
class NullOperator(ComparisonOperator):
    """Check whether the property exists.
    If the WFS would not return the property element <tns:p>, this returns true.
    """

    expression: Expression
    _source: str | None = field(compare=False, default=None)

    @classmethod
    @expect_children(1, Expression)
    def from_xml(cls, element: Element):
        return cls(
            expression=Expression.from_child_xml(element[0]), _source=element.tag
        )

    def build_query(self, compiler: CompiledQuery) -> Q:
        # For now, the implementation is identical to PropertyIsNil.
        # According to the WFS spec, this should only be true when the element
        # is not returned at all (minOccurs=0).
        # TODO: this happens for maxOccurs=unbounded with a null value.
        return self.build_compare(
            compiler, lhs=self.expression, lookup="isnull", rhs=True
        )


class LogicalOperator(NonIdOperator):
    """Base class for AND, OR, NOT comparisons"""


@dataclass
@tag_registry.register("And")
@tag_registry.register("Or")
class BinaryLogicOperator(LogicalOperator):
    """Apply an AND or OR operator"""

    operands: list[NonIdOperator]
    operatorType: BinaryLogicType
    _source: str | None = field(compare=False, default=None)

    @classmethod
    @expect_children(2, NonIdOperator, NonIdOperator)
    def from_xml(cls, element: Element):
        return cls(
            operands=[NonIdOperator.from_child_xml(child) for child in element],
            operatorType=BinaryLogicType.from_xml(element),
            _source=element.tag,
        )

    def build_query(self, compiler: CompiledQuery) -> Q:
        """Apply the AND/OR operation to the Q-object"""
        values = [q.build_query(compiler) for q in self.operands]
        return reduce(self.operatorType.value, values)


@dataclass
@tag_registry.register("Not")
class UnaryLogicOperator(LogicalOperator):
    """Apply a NOT operator"""

    operands: NonIdOperator
    operatorType: UnaryLogicType
    _source: str | None = field(compare=False, default=None)

    @classmethod
    @expect_children(1, NonIdOperator)
    def from_xml(cls, element: Element):
        return cls(
            operands=NonIdOperator.from_child_xml(element[0]),
            operatorType=UnaryLogicType.from_xml(element),
            _source=element.tag,
        )

    def build_query(self, compiler: CompiledQuery) -> Q:
        """Apply the NOT operation to the Q-object"""
        return self.operatorType.value(self.operands.build_query(compiler))


class ExtensionOperator(NonIdOperator):
    """Base class for extensions to FES20"""
