"""Typed builders for the broker's subscription-predicate grammar (v1).

``Client.subscribe(..., predicate=...)`` takes a JSON expression tree
that the broker parses into an AST (``hrb-code/src/subscription.zig``).
Before M-B B3 the only way to build one was to hand-write the dict, and
the grammar has three traps a hand-written tree walks straight into:

1. **Every node is a SINGLE-key dict.** ``{"kind_in": [...], "urn_glob":
   "..."}`` looks like "both of these" and is refused outright
   (``obj.count() != 1 → BadRequest``). The intent needs
   :py:func:`all_of`.
2. **Empty arrays are refused** — broker law N2. ``{"all_of": []}`` is
   vacuously TRUE and ``{"any_of": []}`` / ``{"kind_in": []}`` are
   vacuously FALSE, so accepting them hands the consumer a subscription
   that fires always or never. The broker refuses at parse time and so
   does every builder here, before a byte reaches the wire.
3. **``kind_in`` is a CLOSED enum.** A typo (``"add"`` for ``"added"``)
   is a ``BadRequest`` at subscribe time, i.e. at runtime, in whatever
   code path first tried to watch something.

Every builder returns a plain ``dict`` — the wire shape, nothing
wrapped — so a consumer can still print it, diff it, store it in a
config file, or hand-write a node this module does not know about and
nest it inside :py:func:`all_of`. The builders are a correctness
convenience, not a new type system.

Grammar coverage (v1 = :py:data:`PREDICATE_GRAMMAR_VERSION`):

===============  ==============================================
Operators        :py:func:`all_of`, :py:func:`any_of`,
                 :py:func:`not_`
Terms            :py:func:`kind_in`, :py:func:`urn_glob`,
                 :py:func:`attr_eq`, :py:func:`attr_in`
===============  ==============================================

Ask the broker what IT speaks with
:py:meth:`Client.predicate_grammar_version`; compare against
:py:data:`PREDICATE_GRAMMAR_VERSION` before sending a non-trivial tree.

**Bounds this module does NOT mirror.** The broker also caps predicate
size — 64 nodes per tree, 256 bytes per string literal. Those are
capacity bounds rather than grammar laws, they are versioned with the
broker rather than with this SDK, and exceeding one arrives as a typed
``BadRequestError`` that names the problem. Refusing them here would
mean an older SDK refusing a tree a newer broker accepts, so the
builders let them through and let the broker answer.

**Gap events ignore predicates.** ``{"kind":"gap"}`` carries no URN, so
a ``urn_glob`` term evaluates FALSE for it — a predicate is the wrong
place to express "only this subtree" if you also want to hear about
dropped events. Use ``subscribe(scope=...)`` for subtree filtering: the
broker applies scope as a gap-honest pre-filter (D-022 — we never
silently lose). See :py:meth:`Client.monitor` for how the SDK's own
monitor path uses that rule.
"""

from __future__ import annotations

from typing import Any

__all__ = [
    "EVENT_KINDS",
    "PREDICATE_GRAMMAR_VERSION",
    "all_of",
    "any_of",
    "attr_eq",
    "attr_in",
    "kind_in",
    "not_",
    "urn_glob",
]

# The grammar version these builders emit. Compare against
# `Client.predicate_grammar_version()` (what the BROKER parses) before
# sending a non-trivial tree; a broker reporting "0.0.0" predates the
# report and speaks v1 anyway.
PREDICATE_GRAMMAR_VERSION = "1.0.0"

# The closed set `kind_in` accepts, mirroring `subscription.EventKind`
# in the broker. Ordered as a frozenset because membership is the only
# question anyone asks of it; the error message sorts for readability.
#
# `device_ready` is spec'd (Part 13 §5.4.1) and NOT implemented broker-
# side; it is deliberately absent here, because a `kind_in` carrying it
# is refused by the broker today and this module's job is to say so
# before the wire, not after.
EVENT_KINDS: frozenset[str] = frozenset(
    {
        "added",
        "removed",
        "state_changed",
        "ownership_changed",
        "faulted",
        "gap",
        "driver_bound",
        "driver_unbound",
    }
)

# One predicate node. Deliberately the wire shape (`dict`) rather than a
# frozen dataclass: consumers store predicates in config files and logs,
# and a JSON-shaped value round-trips through both without a codec.
Predicate = dict[str, Any]


def _check_node(node: Any, *, where: str) -> Predicate:
    """Refuse anything that is not a single-key dict.

    The broker's parser starts with `obj.count() != 1 → BadRequest`, and
    the shape it refuses is the one a reader is most likely to write:
    two terms in one dict, meaning "both", which the grammar spells
    `all_of`. Caught here, the traceback points at the call site that
    built it rather than at the subscribe that sent it."""
    if not isinstance(node, dict):
        raise ValueError(
            f"{where} takes predicate nodes (dicts built by mgf.hrb's "
            f"predicate builders); got {type(node).__name__}"
        )
    if len(node) != 1:
        keys = sorted(node)
        raise ValueError(
            f"{where} received a predicate node with {len(node)} keys ({keys}); "
            "every node in the v1 grammar carries exactly one operator or "
            "term. Two terms in one dict is the grammar's way of writing "
            "nothing — use all_of(...) to mean 'both'."
        )
    return node


def _check_nonempty(items: tuple[Any, ...], *, builder: str, noun: str) -> None:
    """Broker law N2, mirrored client-side.

    An empty array parses to a predicate that can never do what its
    author meant: `all_of([])` is vacuously true (fires on EVERY event),
    `any_of([])` / `kind_in([])` / `attr_in(values=[])` are vacuously
    false (fire on NONE, forever). The broker refuses all four at parse
    time with `BadRequest`; refusing here turns a runtime wire error
    into an error at the line that wrote the bug.

    "Match everything" is spelled by omitting the predicate entirely —
    `subscribe(..., predicate=None)` — which is the shape the §5.4.2
    contract defines for it."""
    if not items:
        raise ValueError(
            f"{builder}() needs at least one {noun}: an empty one matches "
            f"{'every event' if builder == 'all_of' else 'nothing, forever'}, "
            "which is never what the caller meant (broker law N2 — the "
            "broker refuses it too). To match everything, omit the "
            "predicate entirely: subscribe(..., predicate=None)."
        )


def kind_in(*kinds: str) -> Predicate:
    """Match events whose kind is one of ``kinds``.

    >>> kind_in("added", "removed")
    {'kind_in': ['added', 'removed']}

    Every value must be in :py:data:`EVENT_KINDS` — the grammar is
    closed, and an unrecognised tag is a ``BadRequest`` from the broker.
    Raises ``ValueError`` with the full valid set instead.

    Watching for another operator's claim/release is
    ``kind_in("ownership_changed")``.
    """
    _check_nonempty(kinds, builder="kind_in", noun="event kind")
    unknown = [k for k in kinds if k not in EVENT_KINDS]
    if unknown:
        raise ValueError(
            f"kind_in() got unknown event kind(s) {unknown!r}; the v1 grammar "
            f"(predicate_grammar_version {PREDICATE_GRAMMAR_VERSION}) is a "
            f"CLOSED set: {sorted(EVENT_KINDS)}"
        )
    return {"kind_in": list(kinds)}


def urn_glob(glob: str) -> Predicate:
    """Match events whose URN matches ``glob`` (trailing-``**`` model,
    the same one :py:meth:`Client.list` uses).

    >>> urn_glob("hw:/usb/**")
    {'urn_glob': 'hw:/usb/**'}

    **Gap events do not match this term** — they carry no URN, so a
    subscription whose predicate is `urn_glob(...)` never hears "you
    missed things". Prefer ``subscribe(scope=...)`` for subtree
    filtering, which the broker applies gap-honestly; reach for this
    term when the subtree is one branch of a larger boolean tree.
    """
    return {"urn_glob": glob}


def attr_eq(key: str, value: str) -> Predicate:
    """Match events for which attribute ``key`` equals ``value``.

    >>> attr_eq("driver", "usb-storage")
    {'attr_eq': {'key': 'driver', 'value': 'usb-storage'}}

    Event-intrinsic keys (``kind``, ``urn``, ``driver``) are answered
    from the event itself; anything else costs the broker a device-attr
    lookup at fan-out, so keep those terms rare.
    """
    return {"attr_eq": {"key": key, "value": value}}


def attr_in(key: str, *values: str) -> Predicate:
    """Match events for which attribute ``key`` is one of ``values``.

    >>> attr_in("driver", "usb-storage", "uas")
    {'attr_in': {'key': 'driver', 'values': ['usb-storage', 'uas']}}

    Empty ``values`` is refused (broker law N2 — see
    :py:func:`kind_in`).
    """
    _check_nonempty(values, builder="attr_in", noun="value")
    return {"attr_in": {"key": key, "values": list(values)}}


def all_of(*terms: Predicate) -> Predicate:
    """Match events satisfying EVERY term.

    >>> all_of(kind_in("added"), urn_glob("hw:/usb/**"))
    {'all_of': [{'kind_in': ['added']}, {'urn_glob': 'hw:/usb/**'}]}
    """
    _check_nonempty(terms, builder="all_of", noun="term")
    return {"all_of": [_check_node(t, where="all_of()") for t in terms]}


def any_of(*terms: Predicate) -> Predicate:
    """Match events satisfying AT LEAST ONE term.

    >>> any_of(kind_in("added"), kind_in("removed"))
    {'any_of': [{'kind_in': ['added']}, {'kind_in': ['removed']}]}

    For a set of event kinds prefer one :py:func:`kind_in` with several
    values — same meaning, one node instead of N+1 against the broker's
    64-node budget.
    """
    _check_nonempty(terms, builder="any_of", noun="term")
    return {"any_of": [_check_node(t, where="any_of()") for t in terms]}


def not_(term: Predicate) -> Predicate:
    """Match events that do NOT satisfy ``term``.

    >>> not_(kind_in("gap"))
    {'not': {'kind_in': ['gap']}}

    Trailing underscore because ``not`` is a Python keyword; the wire
    key is the bare ``"not"``.

    Note the interaction with gap-honesty: ``not_(urn_glob("hw:/usb/**"))``
    matches every gap event, because a gap has no URN for the inner term
    to match. That is usually what a consumer wants — but it is a
    consequence of the grammar, not of the negation.
    """
    return {"not": _check_node(term, where="not_()")}
