from __future__ import annotations

import warnings
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, NoReturn, TypeAlias, cast, final

from typing_extensions import Self

from cognite.client.data_classes._base import EnumProperty
from cognite.client.data_classes.data_modeling.data_types import DirectRelationReference
from cognite.client.data_classes.labels import Label
from cognite.client.data_classes.shared import Geometry
from cognite.client.utils._auxiliary import all_concrete_subclasses
from cognite.client.utils._identifier import InstanceId
from cognite.client.utils._text import convert_all_keys_to_camel_case, to_camel_case
from cognite.client.utils.useful_types import SequenceNotStr, is_sequence_not_str

if TYPE_CHECKING:
    from cognite.client.data_classes.data_modeling.ids import ContainerId, ViewId


PropertyReference: TypeAlias = str | SequenceNotStr[str] | EnumProperty


@dataclass
class PropertyReferenceValue:
    property: PropertyReference


@dataclass
class ParameterValue:
    parameter: str


RawValue: TypeAlias = str | float | bool | Sequence | Mapping[str, Any] | Label | InstanceId | DirectRelationReference
FilterValue: TypeAlias = RawValue | PropertyReferenceValue | ParameterValue
FilterValueList: TypeAlias = Sequence[RawValue] | PropertyReferenceValue | ParameterValue


def _dump_filter_value(value: FilterValueList | FilterValue) -> Any:
    match value:
        case PropertyReferenceValue():
            if isinstance(value.property, EnumProperty):
                return {"property": value.property.as_reference()}
            return {"property": value.property}
        case ParameterValue():
            return {"parameter": value.parameter}
        case InstanceId():
            return value.dump(include_instance_type=False)

    if is_sequence_not_str(value):
        return list(map(_dump_filter_value, value))
    try:
        return value.dump()  # type: ignore [union-attr]
    except AttributeError:
        return value


def _load_filter_value(value: Any) -> FilterValue | FilterValueList:
    match value:
        case {"property": to_load}:
            return PropertyReferenceValue(to_load)
        case {"parameter": to_load}:
            return ParameterValue(to_load)
        case _:
            return value


def _dump_property(property_: PropertyReference, camel_case: bool) -> list[str] | tuple[str, ...]:
    if isinstance(property_, EnumProperty):
        return property_.as_reference()
    elif isinstance(property_, str):
        return [to_camel_case(property_) if camel_case else property_]
    elif isinstance(property_, (list, tuple)):
        if len(property_) == 1:
            return [to_camel_case(property_[0])] if camel_case else property_
        else:
            return property_
    else:
        raise ValueError(f"Invalid property format {property_}")


class Filter(ABC):
    _filter_name: str

    def __bool__(self) -> bool:
        warnings.warn(
            "You may be trying to combine two (or more) filters using 'and' or 'or' - this does not work! "
            "To combine filters, use one of [And, Or] from 'cognite.client.data_classes.filters'. "
            "You can also use the logical operators directly: 'flt1 & flt2' or 'flt1 | flt2'. "
            "If you are evaluating the filter in a boolean context, e.g. 'if flt:', please use 'if flt is not None:' "
            "to silence this warning.",
            UserWarning,
            stacklevel=2,
        )
        return True

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Filter):
            return NotImplemented
        return type(self) is type(other) and self.dump() == other.dump()

    def dump(self, camel_case_property: bool = False) -> dict[str, Any]:
        """
        Dump the filter to a dictionary.

        Args:
            camel_case_property (bool): Whether to camel case the property names. Defaults to False. Typically,
                when the filter is used in data modeling, the property names should not be changed,
                while when used with Assets, Events, Sequences, or Files, the property names should be camel cased.

        Returns:
            dict[str, Any]: The filter as a dictionary.

        """
        return {self._filter_name: self._filter_body(camel_case_property=camel_case_property)}

    @classmethod
    def load(cls, flt: dict[str, Any]) -> Filter:
        match len(flt):
            case 1:
                ((filter_name, body),) = flt.items()
                try:
                    return _CONCRETE_FILTER_MAPPING[filter_name]._load(body)
                except KeyError:
                    return cast(Self, UnknownFilter(filter_name, body))
            case 0:
                raise ValueError("Filter dictionary is empty, missing required filter name and body")
            case _:
                # A lot of trouble to support 'test_handle_unknown_arguments_when_loading' test:
                maybe_keys = _CONCRETE_FILTER_MAPPING.keys() & flt
                match len(maybe_keys):
                    case 0:
                        return cls.load({})  # Will raise the "empty" ValueError above
                    case 1:
                        flt_name = maybe_keys.pop()
                        return cls.load({flt_name: flt[flt_name]})
                raise ValueError(
                    f"Filter dictionary has more than one valid filter key: {sorted(maybe_keys)}. "
                    "Expected exactly one key representing the filter name."
                )

    @classmethod
    def _load_if(cls, flt: dict[str, Any] | None) -> Filter | None:
        if flt is None:
            return None
        return cls.load(flt)

    load_if = _load_if  # Filter has no private load method, so these are the same

    @classmethod
    @abstractmethod
    def _load(cls, resource: Any) -> Self: ...

    @abstractmethod
    def _filter_body(self, camel_case_property: bool) -> list | dict: ...

    def _involved_filter_types(self) -> set[type[Filter]]:
        output = {type(self)}
        if isinstance(self, CompoundFilter):
            for flt in self._filters:
                output.update(flt._involved_filter_types())
        return output

    def _list_filters_without_nesting(self, other: Filter, operator: type[CompoundFilter]) -> list[Filter]:
        filters: list[Filter] = []
        filters.extend(self._filters) if isinstance(self, operator) else filters.append(self)
        filters.extend(other._filters) if isinstance(other, operator) else filters.append(other)
        return filters

    def __and__(self, other: Filter) -> And:
        return And(*self._list_filters_without_nesting(other, And))

    def __or__(self, other: Filter) -> Or:
        return Or(*self._list_filters_without_nesting(other, Or))

    def __invert__(self) -> Not:
        return Not(self)


class UnknownFilter(Filter):
    def __init__(self, filter_name: str, resource: dict[str, Any]) -> None:
        self.__actual_filter_name = filter_name
        self.__actual_filter_body = resource

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        if camel_case_property:
            return convert_all_keys_to_camel_case(self.__actual_filter_body)
        else:
            return self.__actual_filter_body

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> NoReturn:
        raise NotImplementedError("UnknownFilter cannot be loaded (is a fallback for unknown filter types)")

    def dump(self, camel_case_property: bool = False) -> dict[str, Any]:
        return {self.__actual_filter_name: self._filter_body(camel_case_property)}


class InvalidFilter(Filter):
    _filter_name = "invalid"

    def __init__(self, previously_referenced_properties: list[list[str]], filter_type: str) -> None:
        self.__previously_reference_properties = previously_referenced_properties
        self.__filter_type = filter_type

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls(
            previously_referenced_properties=resource["previouslyReferencedProperties"],
            filter_type=resource["filterType"],
        )

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        body = {
            "previously_referenced_properties": self.__previously_reference_properties,
            "filter_type": self.__filter_type,
        }
        if camel_case_property:
            return convert_all_keys_to_camel_case(body)
        else:
            return body

    def dump(self, camel_case_property: bool = False) -> dict[str, Any]:
        return {self._filter_name: self._filter_body(camel_case_property)}


def _validate_filter(
    filter: Filter | dict[str, Any] | None, supported_filters: frozenset[type[Filter]], api_name: str
) -> None:
    if filter is None or isinstance(filter, dict):
        return
    elif not isinstance(filter, Filter):
        raise TypeError(f"Invalid filter type: {type(filter)}. Must be Filter or dict")

    if not_supported := (filter._involved_filter_types() - supported_filters):
        names = [f.__name__ for f in not_supported]
        raise ValueError(f"The filters {names} are not supported for {api_name}")


class CompoundFilter(Filter, ABC):
    _filter_name: str

    def __init__(self, *filters: Filter) -> None:
        if not_flt := [flt for flt in filters if not isinstance(flt, Filter)]:
            raise TypeError(f"One or more invalid filters, expected Filter, got: {not_flt}")
        if not filters:
            raise TypeError("At least one filter must be provided")
        self._filters = filters

    def _filter_body(self, camel_case_property: bool) -> list | dict:
        return [flt.dump(camel_case_property) for flt in self._filters]


class FilterWithProperty(Filter, ABC):
    _filter_name: str

    def __init__(self, property: PropertyReference) -> None:
        self._property = property

    def _dump_property(self, camel_case: bool) -> list[str] | tuple[str, ...]:
        return _dump_property(self._property, camel_case)

    def _filter_body(self, camel_case_property: bool) -> dict:
        return {"property": self._dump_property(camel_case_property)}


class FilterWithPropertyAndValue(FilterWithProperty, ABC):
    _filter_name: str

    def __init__(self, property: PropertyReference, value: FilterValue) -> None:
        super().__init__(property)
        self._value = value

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls(property=resource["property"], value=_load_filter_value(resource["value"]))

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        return {"property": self._dump_property(camel_case_property), "value": _dump_filter_value(self._value)}


class FilterWithPropertyAndValueList(FilterWithProperty, ABC):
    _filter_name: str

    def __init__(self, property: PropertyReference, values: FilterValueList) -> None:
        super().__init__(property)
        self._values = values

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls(
            property=resource["property"],
            values=cast(FilterValueList, _load_filter_value(resource["values"])),
        )

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        return {"property": self._dump_property(camel_case_property), "values": _dump_filter_value(self._values)}


@final
class And(CompoundFilter):
    """A filter that combines multiple filters with a logical AND.

    Args:
        *filters (Filter): The filters to combine.

    Example:
        A filter that combines an Equals and an In filter:

        - Using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import And, Equals, In
            >>> flt = And(
            ...     Equals(("space", "view_xid/version", "some_property"), 42),
            ...     In(("space", "view_xid/version", "another_property"), ["a", "b", "c"]),
            ... )

        - Using the ``View.as_property_ref`` method to reference the property:

            >>> from cognite.client.data_classes.filters import And, Equals, In
            >>> flt = And(
            ...     Equals(my_view.as_property_ref("some_property"), 42),
            ...     In(my_view.as_property_ref("another_property"), ["a", "b", "c"]),
            ... )

        Using the "&" operator:

            >>> flt = Equals("age", 42) & Equals("name", "Alice")
    """

    _filter_name = "and"

    @classmethod
    def _load(cls, resource: list[dict[str, Any]]) -> Self:
        return cls(*map(Filter.load, resource))


@final
class Or(CompoundFilter):
    """A filter that combines multiple filters with a logical OR.

    Args:
        *filters (Filter): The filters to combine.

    Example:
        A filter that combines an Equals and an In filter:

        - Using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import Or, Equals, In
            >>> flt = Or(
            ...     Equals(("space", "view_xid/version", "some_property"), 42),
            ...     In(("space", "view_xid/version", "another_property"), ["a", "b", "c"]),
            ... )

        - Using the ``View.as_property_ref`` method to reference the property:

            >>> flt = Or(
            ...     Equals(my_view.as_property_ref("some_property"), 42),
            ...     In(my_view.as_property_ref("another_property"), ["a", "b", "c"]),
            ... )

        Using the "|" operator:

            >>> flt = Equals("name", "Bob") | Equals("name", "Alice")
    """

    _filter_name = "or"

    @classmethod
    def _load(cls, resource: list[dict[str, Any]]) -> Self:
        return cls(*map(Filter.load, resource))


class Not(CompoundFilter):
    """A filter that negates another filter.

    Args:
        filter (Filter): The filter to negate.

    Example:
        A filter that negates an Equals filter:

        - Using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import Equals, Not
            >>> is_42 = Equals(("space", "view_xid/view_version", "some_property"), 42)
            >>> flt = Not(is_42)

        - Using the ``View.as_property_ref`` method to reference the property:

            >>> is_42 = Equals(my_view.as_property_ref("some_property"), 42)
            >>> flt = Not(is_42)

        Using the "~" operator:

            >>> flt = ~Equals("name", "Bob")
    """

    _filter_name = "not"

    def __init__(self, filter: Filter) -> None:
        super().__init__(filter)

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls(Filter.load(resource))

    def _filter_body(self, camel_case_property: bool) -> dict:
        return self._filters[0].dump(camel_case_property)


@final
class Nested(Filter):
    """A filter to apply to the node at the other side of a direct relation.

    Args:
        scope (PropertyReference): The direct relation property to traverse.
        filter (Filter): The filter to apply.

    Example:
        Assume you have two Views, viewA and viewB. viewA has a direct relation to viewB called "viewB-ID",
        and we want to filter the nodes on viewA based on the property "viewB-Property" on viewB.

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import Nested, Equals
            >>> flt = Nested(
            ...     scope=("space", "viewA_xid/view_version", "viewB-ID"),
            ...     filter=Equals(("space", "viewB_xid/view_version", "viewB-Property"), 42),
            ... )

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> flt = Nested(
            ...     scope=viewA.as_property_ref("viewB-ID"),
            ...     filter=Equals(viewB.as_property_ref("viewB-Property"), 42),
            ... )
    """

    _filter_name = "nested"

    def __init__(self, scope: PropertyReference, filter: Filter) -> None:
        self._scope = scope
        self._filter = filter

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls(scope=resource["scope"], filter=Filter.load(resource["filter"]))

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        return {"scope": self._scope, "filter": self._filter.dump(camel_case_property)}


@final
class MatchAll(Filter):
    """A filter that matches all instances.

    Example:
        A filter that matches all instances:

            >>> from cognite.client.data_classes.filters import MatchAll
            >>> flt = MatchAll()
    """

    _filter_name = "matchAll"

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls()

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        return {}


@final
class HasData(Filter):
    """Return only instances that have data in the provided containers/views.

    Args:
        containers (Sequence[tuple[str, str] | ContainerId] | None): Containers to check for data.
        views (Sequence[tuple[str, str, str] | ViewId] | None): Views to check for data.

    Example:

        - Filter on having data in a specific container, by using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import HasData
            >>> flt = HasData(containers=[("some_space", "container_xid")])

        - Filter on having data in a specific view by using a ``ViewId``:

            >>> my_view = ViewId(space="some_space", external_id="view_xid", version="view_version")
            >>> flt = HasData(views=[my_view])
    """

    _filter_name = "hasData"

    def __init__(
        self,
        containers: Sequence[tuple[str, str] | ContainerId] | None = None,
        views: Sequence[tuple[str, str, str] | ViewId] | None = None,
    ) -> None:
        from cognite.client.data_classes.data_modeling.ids import ContainerId, ViewId

        self.__containers: list[ContainerId] = [ContainerId.load(container) for container in (containers or [])]
        self.__views: list[ViewId] = [ViewId.load(view) for view in (views or [])]

    @classmethod
    def _load(cls, resource: list[dict[str, Any]]) -> Self:
        containers, views = [], []
        for item in resource:
            match item:
                case {"type": "container", "space": space, "externalId": xid}:
                    containers.append((space, xid))
                case {"type": "view", "space": space, "externalId": xid, "version": version}:
                    views.append((space, xid, version))
        return cls(containers=containers, views=views)

    def _filter_body(self, camel_case_property: bool) -> list:
        views = [v.as_tuple() for v in self.__views]
        filter_body_views = [
            {"type": "view", "space": space, "externalId": xid, "version": version} for space, xid, version in views
        ]
        containers = [c.as_tuple() for c in self.__containers]
        filter_body_containers = [{"type": "container", "space": space, "externalId": xid} for space, xid in containers]
        return filter_body_views + filter_body_containers


@final
class Range(FilterWithProperty):
    """Filters results based on a range of values.

    Args:
        property (PropertyReference): The property to filter on.
        gt (FilterValue | None): Greater than.
        gte (FilterValue | None): Greater than or equal to.
        lt (FilterValue | None): Less than.
        lte (FilterValue | None): Less than or equal to.

    Example:
        Filter that can be used to retrieve all instances with a property value greater than 42:

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import Range
            >>> flt = Range(("space", "view_xid/version", "some_property"), gt=42)

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> flt = Range(my_view.as_property_ref("some_property"), gt=42)
    """

    _filter_name = "range"

    def __init__(
        self,
        property: PropertyReference,
        gt: FilterValue | None = None,
        gte: FilterValue | None = None,
        lt: FilterValue | None = None,
        lte: FilterValue | None = None,
    ) -> None:
        super().__init__(property)
        self._gt = gt
        self._gte = gte
        self._lt = lt
        self._lte = lte

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls(
            property=resource["property"],
            gt=_load_filter_value(resource.get("gt")),
            gte=_load_filter_value(resource.get("gte")),
            lt=_load_filter_value(resource.get("lt")),
            lte=_load_filter_value(resource.get("lte")),
        )

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        body = {"property": self._dump_property(camel_case_property)}
        if self._gt is not None:
            body["gt"] = _dump_filter_value(self._gt)
        if self._gte is not None:
            body["gte"] = _dump_filter_value(self._gte)
        if self._lt is not None:
            body["lt"] = _dump_filter_value(self._lt)
        if self._lte is not None:
            body["lte"] = _dump_filter_value(self._lte)
        return body


@final
class Overlaps(Filter):
    """Filters results based whether or not the provided range overlaps with the range given by the start and end
    properties.

    Args:
        start_property (PropertyReference): The property to filter on.
        end_property (PropertyReference): The property to filter on.
        gt (FilterValue | None): Greater than.
        gte (FilterValue | None): Greater than or equal to.
        lt (FilterValue | None): Less than.
        lte (FilterValue | None): Less than or equal to.


    Example:
        Filter that can be used to retrieve all instances with a range overlapping with (42, 100):

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import Overlaps
            >>> flt = Overlaps(
            ...     ("space", "view_xid/version", "some_start_property"),
            ...     ("space", "view_xid/version", "some_end_property"),
            ...     gt=42,
            ...     lt=100,
            ... )

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> flt = Overlaps(
            ...     my_view.as_property_ref("some_start_property"),
            ...     my_view.as_property_ref("some_end_property"),
            ...     gt=42,
            ...     lt=100,
            ... )
    """

    _filter_name = "overlaps"

    def __init__(
        self,
        start_property: PropertyReference,
        end_property: PropertyReference,
        gt: FilterValue | None = None,
        gte: FilterValue | None = None,
        lt: FilterValue | None = None,
        lte: FilterValue | None = None,
    ) -> None:
        self._start_property = start_property
        self._end_property = end_property
        self._gt = gt
        self._gte = gte
        self._lt = lt
        self._lte = lte

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls(
            start_property=resource["startProperty"],
            end_property=resource["endProperty"],
            gt=_load_filter_value(resource.get("gt")),
            gte=_load_filter_value(resource.get("gte")),
            lt=_load_filter_value(resource.get("lt")),
            lte=_load_filter_value(resource.get("lte")),
        )

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        body = {
            "startProperty": _dump_property(self._start_property, camel_case_property),
            "endProperty": _dump_property(self._end_property, camel_case_property),
        }

        if self._gt is not None:
            body["gt"] = _dump_filter_value(self._gt)
        if self._gte is not None:
            body["gte"] = _dump_filter_value(self._gte)
        if self._lt is not None:
            body["lt"] = _dump_filter_value(self._lt)
        if self._lte is not None:
            body["lte"] = _dump_filter_value(self._lte)
        return body


@final
class Equals(FilterWithPropertyAndValue):
    """Filters results based on whether the property equals the provided value.

    Args:
        property (PropertyReference): The property to filter on.
        value (FilterValue): The value to filter on.

    Example:
        Filter than can be used to retrieve items where the property value equals 42:

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import Equals
            >>> flt = Equals(("space", "view_xid/version", "some_property"), 42)

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> flt = Equals(my_view.as_property_ref("some_property"), 42)

        Filter on special node properties like space (these are properties on the node itself, not in a view):

            >>> # Filter nodes in a specific space
            >>> flt = Equals(("node", "space"), "my_space")
            >>> # Other special node properties: "externalId", "createdTime", "lastUpdatedTime"
    """

    _filter_name = "equals"


@final
class In(FilterWithPropertyAndValueList):
    """Filters results based on whether the property equals one of the provided values.

    When comparing two arrays, ``In`` and ``ContainsAny`` behave the same way. But ``In`` also allows you
    filter on a primitive property. i.e:

            >>> [1,2,3] in [3,4,5] => true
            >>> 1 in [1,2,3] => true

    Args:
        property (PropertyReference): The property to filter on.
        values (FilterValueList): The value(s) to filter on.

    Example:
        Filter than can be used to retrieve items where the property value equals 42 or 43 (or both):

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import In
            >>> filter = In(("space", "view_xid/version", "some_property"), [42, 43])

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> filter = In(my_view.as_property_ref("some_property"), [42, 43])

        Filter on special node properties like externalId (these are properties on the node itself, not in a view):

            >>> # Filter nodes with specific externalIds
            >>> filter = In(("node", "externalId"), ["sensor_1", "sensor_2", "sensor_3"])
            >>> # Other special node properties: "space", "createdTime", "lastUpdatedTime"
    """

    _filter_name = "in"


@final
class Exists(FilterWithProperty):
    """Filters results based on whether the property is set or not.

    Args:
        property (PropertyReference): The property to filter on.

    Example:
        Filter than can be used to retrieve items where the property value is set:

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import Exists
            >>> flt = Exists(("space", "view_xid/version", "some_property"))

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> flt = Exists(my_view.as_property_ref("some_property"))
    """

    _filter_name = "exists"

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls(resource["property"])


@final
class Prefix(FilterWithPropertyAndValue):
    """Prefix filter results based on whether the property starts with the provided value. When the property
    is a list, the list starts with the provided values.

    Args:
        property (PropertyReference): The property to filter on.
        value (FilterValue): The value to filter on.

    Example:
        Filter than can be used to retrieve items where the property value starts with "somePrefix":

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import Prefix
            >>> flt = Prefix(("space", "view_xid/version", "some_property"), "somePrefix")

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> flt = Prefix(my_view.as_property_ref("some_property"), "somePrefix")

        Filter that can be used to retrieve items where the property is a list of e.g. integers that starts with [1, 2, 3]:

            >>> flt = Prefix(my_view.as_property_ref("some_list_property"), [1, 2, 3])

        Filter on special node properties like externalId (these are properties on the node itself, not in a view):

            >>> # Filter nodes where externalId starts with "SomeCustomer"
            >>> flt = Prefix(("node", "externalId"), "SomeCustomer")
            >>> # Other special node properties: "space", "createdTime", "lastUpdatedTime"
    """

    _filter_name = "prefix"


@final
class ContainsAny(FilterWithPropertyAndValueList):
    """Returns results where the referenced property contains _any_ of the provided values.

    When comparing two arrays, ``In`` and ``ContainsAny`` behave the same way. But ``ContainsAny`` does
    not allow you filter on a primitive property. i.e in sudo code:

        >>> [1,2,3] in [3,4,5] => true
        >>> 1 in [1,2,3] => ERROR

    Args:
        property (PropertyReference): The property to filter on.
        values (FilterValueList): The value(s) to filter on.

    Example:
        Filter than can be used to retrieve items where the property value contains either 42 or 43:

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import ContainsAny
            >>> flt = ContainsAny(("space", "view_xid/version", "some_property"), [42, 43])

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> flt = ContainsAny(my_view.as_property_ref("some_property"), [42, 43])
    """

    _filter_name = "containsAny"


@final
class ContainsAll(FilterWithPropertyAndValueList):
    """Returns results where the referenced property contains _all_ of the provided values.

    Args:
        property (PropertyReference): The property to filter on.
        values (FilterValueList): The value to filter on.

    Example:
        Filter than can be used to retrieve items where the property value contains both 42 and 43:

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import ContainsAll
            >>> flt = ContainsAll(("space", "view_xid/version", "some_property"), [42, 43])

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> flt = ContainsAll(my_view.as_property_ref("some_property"), [42, 43])
    """

    _filter_name = "containsAll"


class GeoJSON(FilterWithProperty, ABC):
    _filter_name = "geojson"

    def __init__(self, property: PropertyReference, geometry: Geometry):
        super().__init__(property)
        self._geometry = geometry

    @classmethod
    def _load(cls, resource: dict[str, Any]) -> Self:
        return cls(resource["property"], Geometry.load(resource["geometry"]))

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        return {"property": self._dump_property(camel_case_property), "geometry": self._geometry.dump(camel_case=True)}


@final
class GeoJSONIntersects(GeoJSON):
    _filter_name = "geojsonIntersects"


@final
class GeoJSONDisjoint(GeoJSON):
    _filter_name = "geojsonDisjoint"


@final
class GeoJSONWithin(GeoJSON):
    _filter_name = "geojsonWithin"


@final
class InAssetSubtree(FilterWithPropertyAndValueList):
    """Filters results based on whether item/resource is connected to an asset with an ID (or external ID)
    that is in the subtree rooted at any of the provided IDs.

    Args:
        property (PropertyReference): The property to filter on, e.g. 'assetId' or 'assetExternalId'.
        values (FilterValueList): The value(s) to filter on.

    Example:

        Count the number of documents with a related asset in a subtree rooted at any of
        the specified external IDs, e.g. 'Plant_1' and 'Plant_2':

            >>> client.documents.aggregate_count(
            ...     filter=filters.InAssetSubtree(
            ...         property=DocumentProperty.asset_external_ids,
            ...         values=["Plant_1", "Plant_2"],
            ...     )
            ... )
    """

    _filter_name = "inAssetSubtree"


@final
class Search(FilterWithPropertyAndValue):
    _filter_name = "search"


class InstanceReferences(Filter):
    """Data modeling filter which matches instances with these fully qualified references.

    Args:
        references (Sequence[InstanceId] | Sequence[tuple[str, str]] | Sequence[dict[str, str]]): The instance references.

    Example:
        Filter than can be used to retrieve instances where their space/externalId matches any of the provided values:

        - A filter with two references, passed using tuples:

            >>> from cognite.client.data_classes.filters import InstanceReferences, InstanceId
            >>> flt = InstanceReferences(
            ...     [("someSpace", "someExternalId"), ("anotherSpace", "anotherExternalId")]
            ... )

        - Composing the instance references using the InstanceId class:

            >>> flt = InstanceReferences([InstanceId("someSpace", "someExternalId")])
    """

    _filter_name = "instanceReferences"

    def __init__(self, references: Sequence[InstanceId] | Sequence[tuple[str, str]] | Sequence[dict[str, str]]) -> None:
        self.__references = [InstanceId.load(ref) for ref in references]

    @classmethod
    def _load(cls, resource: list[dict[str, Any]]) -> Self:
        return cls(resource)

    def _filter_body(self, camel_case_property: bool) -> list:
        return [ref.dump(camel_case=True, include_instance_type=False) for ref in self.__references]


_BASIC_FILTERS: frozenset[type[Filter]] = frozenset(
    {And, Or, Not, In, Equals, Exists, Range, Prefix, ContainsAny, ContainsAll}
)


# ######################################################### #
# Custom filters below (custom meaning 'no API equivalent') #
# ######################################################### #


class SpaceFilter(FilterWithProperty):
    """Filters instances based on the space.

    Args:
        space (str | SequenceNotStr[str]): The space (or spaces) to filter on.
        instance_type (Literal['node', 'edge']): Type of instance to filter on. Defaults to "node".

    Example:
        Filter than can be used to retrieve nodes from space "space1" or "space2":

            >>> from cognite.client.data_classes.filters import SpaceFilter
            >>> flt = SpaceFilter(["space1", "space2"])

        Filter than can be used to retrieve edges only from "space3":

            >>> flt = SpaceFilter("space3", instance_type="edge")
    """

    _filter_name = "__space"

    def __init__(self, space: str | SequenceNotStr[str], instance_type: Literal["node", "edge"] = "node") -> None:
        super().__init__(property=[instance_type, "space"])
        space = [space] if isinstance(space, str) else list(space)
        single = len(space) == 1
        self._value = space[0] if single else space
        self._value_key = "value" if single else "values"
        self._filter_name = Equals._filter_name if single else In._filter_name
        self._involved_filter: set[type[Filter]] = {Equals if single else In}

    @classmethod
    def load(cls, resource: Any) -> NoReturn:
        raise NotImplementedError(f"Custom filter '{cls.__name__}' cannot be loaded")

    @classmethod
    def _load(cls, resource: Any) -> NoReturn:
        raise NotImplementedError(f"Custom filter '{cls.__name__}' cannot be loaded")

    def _filter_body(self, camel_case_property: bool) -> dict[str, Any]:
        return {
            "property": self._dump_property(camel_case_property),
            self._value_key: _dump_filter_value(self._value),
        }

    def _involved_filter_types(self) -> set[type[Filter]]:
        return self._involved_filter


class IsNull(Not):
    """Data modeling filter for instances whose property is null, effectively a negated Exists-filter.

    Args:
        property (SequenceNotStr[str]): The property to filter on.

    Example:
        Filter than can be used to retrieve instances where the property value is not set:

        - A filter using a tuple to reference the property:

            >>> from cognite.client.data_classes.filters import IsNull
            >>> flt = IsNull(("space", "view_xid/version", "some_property"))

        - Composing the property reference using the ``View.as_property_ref`` method:

            >>> flt = IsNull(my_view.as_property_ref("some_property"))

    """

    _filter_name = "__is_null"

    def __init__(self, property: SequenceNotStr[str]) -> None:
        if not is_sequence_not_str(property):
            raise TypeError(
                "The IsNull filter is a Data Modeling filter and expected a sequence of str to describe the property, "
                f"like ['node', 'space'] or ['my-space', 'my-view/version', 'my-property'], got: {property}"
            )
        super().__init__(Exists(property))
        self._filter_name = Not._filter_name

    @classmethod
    def load(cls, resource: Any) -> NoReturn:
        raise NotImplementedError(f"Custom filter '{cls.__name__}' cannot be loaded")

    @classmethod
    def _load(cls, resource: Any) -> NoReturn:
        raise NotImplementedError(f"Custom filter '{cls.__name__}' cannot be loaded")

    def _involved_filter_types(self) -> set[type[Filter]]:
        return {Not, Exists}


_CONCRETE_FILTER_MAPPING: dict[str, type[Filter]] = {
    flt_cls._filter_name: flt_cls
    for flt_cls in all_concrete_subclasses(
        Filter,  # type: ignore [type-abstract]
        exclude={UnknownFilter, InvalidFilter, SpaceFilter, IsNull},
    )
}
