"""Universal filter DSL.

Wire format (recursive):

    {"field": "name", "op": "icontains", "value": "ali"}
    {"and": [<node>, <node>, ...]}
    {"or":  [<node>, <node>, ...]}
    {"not": <node>}

Only fields listed in `EntitySchema.filterable_fields` are accepted, and
only ops in `ALLOWED_OPS` are honored. Anything else raises
`FilterError` — never a 500.
"""

from __future__ import annotations

from typing import Any

from django.db.models import Q

from simorgh.apps.schema.registry import EntitySchema, FieldSpec


class FilterError(ValueError):
    """Filter payload rejected by the parser."""


# op → Django ORM lookup suffix.
ALLOWED_OPS: dict[str, str] = {
    "eq": "exact",
    "ne": "exact",  # negated at the Q level
    "lt": "lt",
    "lte": "lte",
    "gt": "gt",
    "gte": "gte",
    "in": "in",
    "nin": "in",  # negated
    "contains": "contains",
    "icontains": "icontains",
    "startswith": "istartswith",
    "endswith": "iendswith",
    "isnull": "isnull",
}

# Per-field-type allow-list. Empty tuple means "no filtering allowed".
TYPE_OPS: dict[str, frozenset[str]] = {
    "string": frozenset(
        {"eq", "ne", "in", "nin", "contains", "icontains", "startswith", "endswith", "isnull"}
    ),
    "text": frozenset({"contains", "icontains", "isnull"}),
    "int": frozenset({"eq", "ne", "lt", "lte", "gt", "gte", "in", "nin", "isnull"}),
    "decimal": frozenset({"eq", "ne", "lt", "lte", "gt", "gte", "in", "nin", "isnull"}),
    "bool": frozenset({"eq", "ne", "isnull"}),
    "date": frozenset({"eq", "ne", "lt", "lte", "gt", "gte", "isnull"}),
    "datetime": frozenset({"eq", "ne", "lt", "lte", "gt", "gte", "isnull"}),
    "uuid": frozenset({"eq", "ne", "in", "nin", "isnull"}),
    "json": frozenset(),
    "enum": frozenset({"eq", "ne", "in", "nin", "isnull"}),
    "fk": frozenset({"eq", "ne", "in", "nin", "isnull"}),
    "m2m": frozenset({"eq", "in", "isnull"}),
}

_MAX_DEPTH = 8
_MAX_IN_VALUES = 200


def parse_filter(node: Any, *, entity: EntitySchema, depth: int = 0) -> Q:
    """Translate a filter node into a Django `Q` object.

    Raises `FilterError` for anything not on the allow-list. Callers must
    apply the result to a queryset that is already tenant-scoped — this
    function does NOT add tenant constraints.
    """

    if depth > _MAX_DEPTH:
        raise FilterError("Filter expression too deeply nested")
    if not isinstance(node, dict):
        raise FilterError("Filter node must be an object")

    if "and" in node:
        return _combine(node["and"], entity=entity, depth=depth, op=Q.AND)
    if "or" in node:
        return _combine(node["or"], entity=entity, depth=depth, op=Q.OR)
    if "not" in node:
        return ~parse_filter(node["not"], entity=entity, depth=depth + 1)

    field_name = node.get("field")
    op = node.get("op")
    if not isinstance(field_name, str) or not isinstance(op, str):
        raise FilterError("Filter node requires 'field' and 'op'")
    if field_name not in entity.filterable_fields:
        raise FilterError(f"Field '{field_name}' is not filterable on '{entity.name}'")
    spec = entity.field(field_name)
    if spec is None:
        raise FilterError(f"Unknown field '{field_name}' on '{entity.name}'")
    if op not in ALLOWED_OPS:
        raise FilterError(f"Unsupported op '{op}'")
    if op not in TYPE_OPS.get(spec.type, frozenset()):
        raise FilterError(f"Op '{op}' not allowed for field type '{spec.type}'")

    value = _coerce(spec, op, node.get("value"))
    lookup = f"{field_name}__{ALLOWED_OPS[op]}"
    q = Q(**{lookup: value})
    if op in {"ne", "nin"}:
        q = ~q
    return q


def _combine(items: Any, *, entity: EntitySchema, depth: int, op: str) -> Q:
    if not isinstance(items, list) or not items:
        raise FilterError("'and'/'or' require a non-empty list")
    parts = [parse_filter(it, entity=entity, depth=depth + 1) for it in items]
    combined = parts[0]
    for extra in parts[1:]:
        combined = combined._combine(extra, op)  # type: ignore[attr-defined]
    return combined


def _coerce(spec: FieldSpec, op: str, value: Any) -> Any:
    if op == "isnull":
        if not isinstance(value, bool):
            raise FilterError("'isnull' value must be boolean")
        return value
    if op in {"in", "nin"}:
        if not isinstance(value, list):
            raise FilterError("'in'/'nin' value must be a list")
        if len(value) > _MAX_IN_VALUES:
            raise FilterError(f"'in' list exceeds {_MAX_IN_VALUES} items")
        return [_coerce_scalar(spec, v) for v in value]
    return _coerce_scalar(spec, value)


def _coerce_scalar(spec: FieldSpec, value: Any) -> Any:
    if value is None:
        return None
    t = spec.type
    try:
        if t in {"string", "text", "uuid"}:
            if not isinstance(value, str):
                raise FilterError(f"Field '{spec.name}' expects string")
            return value
        if t == "int" or t == "fk" or t == "m2m":
            if isinstance(value, bool):
                raise FilterError(f"Field '{spec.name}' expects integer")
            return int(value)
        if t == "decimal":
            return float(value)
        if t == "bool":
            if not isinstance(value, bool):
                raise FilterError(f"Field '{spec.name}' expects boolean")
            return value
        if t in {"date", "datetime"}:
            if not isinstance(value, str):
                raise FilterError(f"Field '{spec.name}' expects ISO date string")
            return value  # Django parses ISO strings on lookup
        if t == "enum":
            if value not in spec.enum:
                raise FilterError(f"Value '{value}' not in enum for '{spec.name}'")
            return value
    except (TypeError, ValueError) as exc:
        raise FilterError(f"Invalid value for field '{spec.name}': {exc}") from exc
    raise FilterError(f"Cannot coerce value for field type '{t}'")
