"""Unit pins for the typed predicate builders (M-B chunk B3, item 4).

The builders exist so consumers never hand-write the subscription AST.
That promise has two halves, and this file pins both:

  * **Shape equality with grammar v1.** Every builder emits exactly the
    dict the broker's parser accepts — asserted against literal wire
    shapes transcribed from `hrb-code/src/subscription.zig`'s
    `parsePredicate`, not against the builders' own output. A pin that
    asked the builder what it emits and then agreed with it would pass
    on any spelling.
  * **The refusals are the point.** The grammar has traps whose cost is
    paid at runtime, in the subscribe, in whatever code path first tried
    to watch something: an empty array (broker law N2), a two-key node,
    a typo'd event kind. Every one is refused HERE, by ValueError, at
    the line that wrote it.

The broker-side half of the same contract — that these exact dicts are
accepted, and that the refused ones would have been refused — lives in
`tests/contract/test_wire_subscribe.py` (unmodified: it is the compat
oracle) and `tests/contract/test_sdk_longpoll_monitor.py`.
"""

from __future__ import annotations

import pytest

from mgf.hrb import (
    EVENT_KINDS,
    PREDICATE_GRAMMAR_VERSION,
    all_of,
    any_of,
    attr_eq,
    attr_in,
    kind_in,
    not_,
    urn_glob,
)

# ---------------------------------------------------------------------------
# Shape equality — the wire dicts, written out by hand.
# ---------------------------------------------------------------------------


def test_kind_in_shape() -> None:
    assert kind_in("added", "removed") == {"kind_in": ["added", "removed"]}


def test_kind_in_preserves_caller_order() -> None:
    """Not alphabetical, not de-duplicated: what the caller wrote is
    what the broker sees, so a predicate read back from a log or a
    config file is the one that was sent."""
    assert kind_in("removed", "added") == {"kind_in": ["removed", "added"]}


def test_urn_glob_shape() -> None:
    assert urn_glob("hw:/usb/**") == {"urn_glob": "hw:/usb/**"}


def test_attr_eq_shape() -> None:
    assert attr_eq("driver", "usb-storage") == {
        "attr_eq": {"key": "driver", "value": "usb-storage"}
    }


def test_attr_in_shape() -> None:
    assert attr_in("driver", "usb-storage", "uas") == {
        "attr_in": {"key": "driver", "values": ["usb-storage", "uas"]}
    }


def test_all_of_shape() -> None:
    assert all_of(kind_in("added"), urn_glob("hw:/usb/**")) == {
        "all_of": [{"kind_in": ["added"]}, {"urn_glob": "hw:/usb/**"}]
    }


def test_any_of_shape() -> None:
    assert any_of(kind_in("added"), kind_in("removed")) == {
        "any_of": [{"kind_in": ["added"]}, {"kind_in": ["removed"]}]
    }


def test_not_shape_uses_the_bare_wire_key() -> None:
    """The Python name carries a trailing underscore because `not` is a
    keyword; the WIRE key must not."""
    assert not_(kind_in("gap")) == {"not": {"kind_in": ["gap"]}}


def test_nesting_composes_to_one_tree() -> None:
    """The shape a GUI actually sends: everything in a subtree except
    the noise, expressed once."""
    tree = all_of(
        urn_glob("hw:/usb/**"),
        not_(kind_in("driver_bound", "driver_unbound")),
    )
    assert tree == {
        "all_of": [
            {"urn_glob": "hw:/usb/**"},
            {"not": {"kind_in": ["driver_bound", "driver_unbound"]}},
        ]
    }


def test_every_builder_emits_a_single_key_node() -> None:
    """The grammar's first rule (`obj.count() != 1 → BadRequest`) holds
    for every builder, checked as a family rather than one at a time."""
    for node in (
        kind_in("added"),
        urn_glob("hw:/**"),
        attr_eq("k", "v"),
        attr_in("k", "v"),
        all_of(kind_in("added")),
        any_of(kind_in("added")),
        not_(kind_in("added")),
    ):
        assert len(node) == 1, node


# ---------------------------------------------------------------------------
# Broker law N2 — empty arrays are refused, client-side, before the wire.
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("builder", "args"),
    [
        (kind_in, ()),
        (all_of, ()),
        (any_of, ()),
        (attr_in, ("driver",)),
    ],
    ids=["kind_in", "all_of", "any_of", "attr_in"],
)
def test_empty_arrays_are_refused(builder: object, args: tuple[str, ...]) -> None:
    """N2, mirrored. `{"all_of": []}` is vacuously TRUE and the other
    three are vacuously FALSE, so an empty array hands the caller a
    subscription that fires on everything or on nothing, forever. The
    broker refuses at parse time; refusing here moves the error from
    "at subscribe, at runtime" to "at the line that built it"."""
    with pytest.raises(ValueError, match="at least one"):
        builder(*args)  # type: ignore[operator]


def test_the_empty_refusal_names_the_alternative() -> None:
    """A refusal that doesn't say what to do instead is half a refusal.
    "Match everything" has a spelling — omit the predicate — and the
    message must carry it, because the caller who wrote `all_of()` was
    reaching for exactly that."""
    with pytest.raises(ValueError, match="predicate=None"):
        all_of()


# ---------------------------------------------------------------------------
# The closed enum, and the two-key node.
# ---------------------------------------------------------------------------


def test_kind_in_refuses_an_unknown_tag() -> None:
    with pytest.raises(ValueError, match="unknown event kind"):
        kind_in("added", "add")


def test_the_unknown_tag_refusal_lists_the_whole_closed_set() -> None:
    """The failure mode this closes is a typo, so the message has to
    contain the right spelling."""
    with pytest.raises(ValueError) as excinfo:
        kind_in("ownership_change")  # missing the trailing 'd'
    assert "ownership_changed" in str(excinfo.value)


def test_device_ready_is_refused_because_the_broker_refuses_it() -> None:
    """`device_ready` is SPEC'd (Part 13 §5.4.1) and not implemented
    broker-side, so a subscribe carrying it is a BadRequest today. The
    builders mirror what the broker parses, not what the design
    document promises — the alternative is an SDK that accepts a value
    the wire rejects."""
    assert "device_ready" not in EVENT_KINDS
    with pytest.raises(ValueError, match="unknown event kind"):
        kind_in("device_ready")


@pytest.mark.parametrize("builder", [all_of, any_of], ids=["all_of", "any_of"])
def test_operators_refuse_a_two_key_node(builder: object) -> None:
    """The mistake the grammar most invites: two terms in one dict,
    meaning "both", which is spelled `all_of`. The broker answers
    BadRequest; we answer at the call site."""
    with pytest.raises(ValueError, match="2 keys"):
        builder({"kind_in": ["added"], "urn_glob": "hw:/**"})  # type: ignore[operator]


def test_not_refuses_a_two_key_node() -> None:
    with pytest.raises(ValueError, match="2 keys"):
        not_({"kind_in": ["added"], "urn_glob": "hw:/**"})


@pytest.mark.parametrize("builder", [all_of, any_of, not_], ids=["all_of", "any_of", "not_"])
def test_operators_refuse_a_non_dict_child(builder: object) -> None:
    """`all_of("hw:/usb/**")` — reaching for a term and passing its
    argument instead. Caught before it becomes a wire error."""
    with pytest.raises(ValueError, match="predicate nodes"):
        builder("hw:/usb/**")  # type: ignore[operator]


# ---------------------------------------------------------------------------
# The grammar version + the closed set are facts about the BROKER.
# ---------------------------------------------------------------------------


def test_grammar_version_matches_what_the_broker_reports() -> None:
    """`PREDICATE_GRAMMAR_VERSION` is what these builders emit; the
    broker reports its own from `format.zig`. They are the same string
    today, and a consumer comparing them is the documented way to find
    out when they stop being."""
    assert PREDICATE_GRAMMAR_VERSION == "1.0.0"


def test_event_kinds_is_the_brokers_closed_set() -> None:
    """Transcribed from `subscription.EventKind` — the enum the broker's
    `kind_in` parser accepts. If a broker adds a kind, this set and that
    enum must move in one diff; the contract suite catches it if they
    don't."""
    assert EVENT_KINDS == {
        "added",
        "removed",
        "state_changed",
        "ownership_changed",
        "faulted",
        "gap",
        "driver_bound",
        "driver_unbound",
    }


def test_event_kinds_is_immutable() -> None:
    """A module-level mutable set would let one consumer's `add()`
    change what every other consumer is allowed to subscribe to."""
    assert isinstance(EVENT_KINDS, frozenset)
