"""Condition evaluator for automation rules.

``evaluate_conditions(conditions, payload)`` is the single public entry
point.  It returns ``True`` iff **all** top-level condition objects in
*conditions* evaluate truthy against *payload* (implicit AND at the top
level).

Condition object schema
-----------------------
Each element in *conditions* is a plain dict that must have a ``"field"``
key identifying the payload path to inspect, an ``"op"`` key naming the
comparison operator, and (for most operators) a ``"value"`` key carrying
the right-hand side.  Compound logical nodes are also supported::

    # Simple comparison
    {"field": "lead.status",  "op": "eq",  "value": "new"}
    {"field": "lead.score",   "op": "gte", "value": 50}
    {"field": "user.email",   "op": "startswith", "value": "admin"}
    {"field": "tags",         "op": "in",  "value": ["vip", "priority"]}
    {"field": "closed_at",    "op": "is_null"}
    {"field": "assigned_to",  "op": "is_not_null"}

    # Compound logical nodes
    {"all": [{"field": "status", "op": "eq", "value": "open"},
             {"field": "score",  "op": "gte", "value": 80}]}
    {"any": [{"field": "tier", "op": "eq", "value": "gold"},
             {"field": "tier", "op": "eq", "value": "platinum"}]}
    {"not": {"field": "is_deleted", "op": "eq", "value": true}}

Path resolution
---------------
Dotted paths are resolved against the event payload dict.  The optional
``"payload."`` prefix is stripped so ``"lead.status"`` and
``"payload.lead.status"`` are equivalent::

    payload = {"lead": {"status": "new", "score": 90}}
    # Both forms work:
    {"field": "lead.status",         "op": "eq", "value": "new"}
    {"field": "payload.lead.status", "op": "eq", "value": "new"}

Operators
---------
``eq``         — strict equality (``==``)
``neq``        — not equal (``!=``)
``gt``         — greater than (``>``)
``lt``         — less than (``<``)
``gte``        — greater than or equal (``>=``)
``lte``        — less than or equal (``<=``)
``contains``   — sequence/string containment (``value in actual``)
``startswith`` — string prefix (``str(actual).startswith(str(value))``)
``in``         — membership (``actual in value``)
``not_in``     — non-membership (``actual not in value``)
``is_null``    — ``actual is None``
``is_not_null``— ``actual is not None``
"""

from __future__ import annotations

import operator as _op
from collections.abc import Callable, Mapping
from typing import Any

_MAX_DEPTH = 10  # guard against pathological nesting


class EvaluatorError(ValueError):
    """Raised for malformed condition objects or unknown operators."""


# ---------------------------------------------------------------------------
# Operators registry
# ---------------------------------------------------------------------------

def _contains(actual: Any, value: Any) -> bool:
    """True if *value* is contained in *actual* (substring or element)."""
    try:
        return value in actual
    except TypeError:
        return False


def _startswith(actual: Any, value: Any) -> bool:
    try:
        return str(actual).startswith(str(value))
    except (TypeError, AttributeError):
        return False


def _in(actual: Any, value: Any) -> bool:
    """True if *actual* is a member of the iterable *value*."""
    try:
        return actual in value
    except TypeError:
        return False


def _not_in(actual: Any, value: Any) -> bool:
    try:
        return actual not in value
    except TypeError:
        return True  # type mismatch → treat as not-in


_OPERATORS: dict[str, Callable[[Any, Any], bool]] = {
    "eq":          _op.eq,
    "neq":         _op.ne,
    "gt":          _op.gt,
    "lt":          _op.lt,
    "gte":         _op.ge,
    "lte":         _op.le,
    "contains":    _contains,
    "startswith":  _startswith,
    "in":          _in,
    "not_in":      _not_in,
    "is_null":     lambda actual, _v: actual is None,
    "is_not_null": lambda actual, _v: actual is not None,
}


# ---------------------------------------------------------------------------
# Path resolver
# ---------------------------------------------------------------------------

def _resolve_path(path: str, payload: dict[str, Any]) -> Any:
    """Return the value at *path* within *payload*.

    The optional ``"payload."`` root prefix is stripped.  Missing keys or
    attributes at any step return ``None``.
    """
    if not isinstance(path, str) or not path:
        raise EvaluatorError("condition 'field' must be a non-empty string")

    parts = path.split(".")
    # Strip optional "payload." root so both forms are equivalent.
    if parts and parts[0] == "payload":
        parts = parts[1:]

    current: Any = payload
    for part in parts:
        if current is None:
            return None
        if isinstance(current, Mapping):
            current = current.get(part)
        else:
            current = getattr(current, part, None)
    return current


# ---------------------------------------------------------------------------
# Core evaluator
# ---------------------------------------------------------------------------

def _eval_node(node: Any, payload: dict[str, Any], depth: int) -> bool:
    """Recursively evaluate a single condition node."""
    if depth > _MAX_DEPTH:
        raise EvaluatorError(
            f"condition expression exceeds maximum nesting depth ({_MAX_DEPTH})"
        )
    if not isinstance(node, Mapping):
        raise EvaluatorError(
            f"condition node must be a dict/mapping, got {type(node).__name__!r}"
        )

    # --- Compound logical operators ---
    if "all" in node:
        children = node["all"]
        if not isinstance(children, list):
            raise EvaluatorError("'all' value must be a list of condition nodes")
        return all(_eval_node(c, payload, depth + 1) for c in children)

    if "any" in node:
        children = node["any"]
        if not isinstance(children, list):
            raise EvaluatorError("'any' value must be a list of condition nodes")
        return any(_eval_node(c, payload, depth + 1) for c in children)

    if "not" in node:
        return not _eval_node(node["not"], payload, depth + 1)

    # --- Simple field comparison ---
    if "field" not in node:
        raise EvaluatorError(
            f"condition node must have a 'field' key or a logical operator "
            f"(all/any/not).  Got keys: {sorted(node.keys())}"
        )

    field: str = node["field"]
    op_name: str = node.get("op", "eq")
    value: Any = node.get("value")

    comparator = _OPERATORS.get(op_name)
    if comparator is None:
        raise EvaluatorError(
            f"unknown operator {op_name!r}. "
            f"Valid operators: {sorted(_OPERATORS)}"
        )

    actual = _resolve_path(field, payload)

    try:
        return comparator(actual, value)
    except TypeError:
        # Incomparable types (e.g. int vs None for gt/lt) → condition is False.
        return False


def evaluate_conditions(
    conditions: list[dict[str, Any]],
    payload: dict[str, Any],
) -> bool:
    """Return ``True`` iff *all* conditions in *conditions* evaluate truthy.

    Parameters
    ----------
    conditions:
        List of condition dicts (see module docstring for schema).
        An empty list always returns ``True`` (unconditional match).
    payload:
        Event payload dict.  Dotted field paths are resolved against this.

    Raises
    ------
    EvaluatorError
        If a condition object is malformed (wrong type, unknown operator,
        missing ``field`` key) or nesting exceeds ``_MAX_DEPTH``.
    """
    if not conditions:
        return True
    return all(_eval_node(cond, payload, 0) for cond in conditions)
