"""Contract: the broker's ``subscribe`` / ``unsubscribe`` wire ops
+ extended ``drain_events`` semantics (Part 13 §5.4.2).

Pinned here:

  * **subscribe op shape.**
    Request:  ``{op:'subscribe', id:'...', scope:'...',
                 predicate:{...}?}``
    Response: ``{ok:true, subscription_id:'...', queue_max:N}``.

  * **unsubscribe op shape.**
    Request:  ``{op:'unsubscribe', id:'...'}``
    Response: ``{ok:true}``. Unknown id → ``ok:false, err:'NotFound'``.

  * **Predicate grammar** (v1, full set): ``kind_in`` (closed enum;
    unknown values reject), ``urn_glob``, ``attr_eq``, ``attr_in``,
    ``all_of``, ``any_of``, ``not``. Each node is a single-key dict;
    multi-key dicts reject. Missing/null predicate matches everything.

  * **drain_events extension** (Part 13 §5.4.2):
      - Without ``subscription_id``: legacy behaviour — returns every
        backend event whose worker scope intersects the request scope.
      - With ``subscription_id``: returns events from THAT subscription's
        queue (already filtered by the subscription's predicate);
        ``scope`` is ignored. Unknown id → ``NotFound``.

  * **Lifecycle.** Subscriptions are bound to the connection. When
    the connection closes, all its subscriptions are reaped — a new
    connection from the same client can re-use the same ``id``.

Sub-phase 2A of Part 13.
"""

from __future__ import annotations

import socket
import time
from typing import TYPE_CHECKING, Any

import pytest

from tests.contract.conftest import _SOCK_PATH, rt

if TYPE_CHECKING:
    from mgf.hrb import Client

pytestmark = pytest.mark.contract


# `socket` + `time` survive the migration because
# `test_subscription_dies_with_connection` needs to open a SECOND
# raw socket to the same broker mid-test (to verify per-connection
# subscription reaping). The shared `raw_broker` fixture gives the
# FIRST socket; the second is built by hand against the canonical
# `/tmp/mgf-hrb.sock` path.


# ---------------------------------------------------------------------------
# subscribe shape pins.
# ---------------------------------------------------------------------------


def test_subscribe_success_shape(raw_broker: socket.socket) -> None:
    """Success response carries the documented keys exactly."""
    r = rt(raw_broker, {"op": "subscribe", "id": "s1", "scope": "hw:/**"})
    assert r.get("ok") is True
    assert r.get("subscription_id") == "s1"
    assert isinstance(r.get("queue_max"), int)
    assert r["queue_max"] > 0


def test_subscribe_no_predicate_matches_all(raw_broker: socket.socket) -> None:
    """A subscribe without a predicate field MUST be accepted; the
    subscription matches every event in scope."""
    r = rt(raw_broker, {"op": "subscribe", "id": "s-all", "scope": "hw:/**"})
    assert r.get("ok") is True


def test_subscribe_empty_id_rejects(raw_broker: socket.socket) -> None:
    r = rt(raw_broker, {"op": "subscribe", "id": "", "scope": "hw:/**"})
    assert r.get("ok") is False
    assert r.get("err") == "BadRequest"


def test_subscribe_missing_fields_reject(raw_broker: socket.socket) -> None:
    for req in (
        {"op": "subscribe"},
        {"op": "subscribe", "id": "x"},
        {"op": "subscribe", "scope": "hw:/**"},
    ):
        r = rt(raw_broker, req)
        assert r.get("ok") is False
        assert r.get("err") == "BadRequest", f"req={req!r}: {r}"


def test_subscribe_duplicate_id_rejects(raw_broker: socket.socket) -> None:
    """Per-peer (connection) IDs are unique. Re-subscribing the same
    id without unsubscribing first → BadRequest."""
    a = rt(raw_broker, {"op": "subscribe", "id": "dup", "scope": "hw:/**"})
    assert a.get("ok") is True
    b = rt(raw_broker, {"op": "subscribe", "id": "dup", "scope": "hw:/**"})
    assert b.get("ok") is False
    assert b.get("err") == "BadRequest"


# ---------------------------------------------------------------------------
# predicate grammar pins.
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "predicate",
    [
        {"kind_in": ["added"]},
        {"kind_in": ["added", "driver_bound"]},
        {"urn_glob": "hw:/usb/**"},
        {"attr_eq": {"key": "kind", "value": "added"}},
        {"attr_in": {"key": "kind", "values": ["added", "removed"]}},
        {"all_of": [{"kind_in": ["added"]}, {"urn_glob": "hw:/usb/**"}]},
        {"any_of": [{"kind_in": ["added"]}, {"kind_in": ["removed"]}]},
        {"not": {"kind_in": ["gap"]}},
        # nested
        {
            "all_of": [
                {"not": {"kind_in": ["gap"]}},
                {"any_of": [{"urn_glob": "hw:/usb/**"}, {"urn_glob": "hw:/pci/**"}]},
            ]
        },
    ],
)
def test_predicate_grammar_accepts(
    raw_broker: socket.socket,
    predicate: dict[str, Any],
) -> None:
    """Every v1 grammar shape parses + registers cleanly."""
    r = rt(raw_broker, {"op": "subscribe", "id": "g", "scope": "hw:/**", "predicate": predicate})
    assert r.get("ok") is True, f"predicate={predicate!r}: {r}"
    rt(raw_broker, {"op": "unsubscribe", "id": "g"})


@pytest.mark.parametrize(
    "predicate",
    [
        # unknown operator
        {"weird_op": []},
        # multi-key dict (not single-tagged)
        {"kind_in": ["added"], "urn_glob": "hw:/x"},
        # unknown event kind
        {"kind_in": ["not_a_kind"]},
        # wrong types
        {"kind_in": "added"},
        {"urn_glob": 42},
        {"attr_eq": {"key": "kind"}},  # missing value
        {"attr_eq": {"value": "added"}},  # missing key
        {"attr_in": {"key": "k", "values": "not_an_array"}},
        # null predicate (must be object, not null)
        None,
    ],
)
def test_predicate_grammar_rejects(
    raw_broker: socket.socket,
    predicate: Any,
) -> None:
    """Every malformed shape rejects with BadRequest at the gate."""
    r = rt(raw_broker, {"op": "subscribe", "id": "bad", "scope": "hw:/**", "predicate": predicate})
    assert r.get("ok") is False, f"predicate={predicate!r}: {r}"
    assert r.get("err") == "BadRequest"


# ---------------------------------------------------------------------------
# unsubscribe pins.
# ---------------------------------------------------------------------------


def test_unsubscribe_success(raw_broker: socket.socket) -> None:
    rt(raw_broker, {"op": "subscribe", "id": "u1", "scope": "hw:/**"})
    r = rt(raw_broker, {"op": "unsubscribe", "id": "u1"})
    assert r.get("ok") is True


def test_unsubscribe_unknown_id_is_not_found(raw_broker: socket.socket) -> None:
    r = rt(raw_broker, {"op": "unsubscribe", "id": "never-existed"})
    assert r.get("ok") is False
    assert r.get("err") == "NotFound"


def test_unsubscribe_missing_id_is_bad_request(raw_broker: socket.socket) -> None:
    r = rt(raw_broker, {"op": "unsubscribe"})
    assert r.get("ok") is False
    assert r.get("err") == "BadRequest"


def test_unsubscribe_then_resubscribe_same_id_ok(
    raw_broker: socket.socket,
) -> None:
    """After unsubscribing, the same id can be claimed again — the
    cap is on LIVE subscriptions."""
    rt(raw_broker, {"op": "subscribe", "id": "x", "scope": "hw:/**"})
    rt(raw_broker, {"op": "unsubscribe", "id": "x"})
    r = rt(raw_broker, {"op": "subscribe", "id": "x", "scope": "hw:/**"})
    assert r.get("ok") is True


# ---------------------------------------------------------------------------
# drain_events extension pins.
# ---------------------------------------------------------------------------


def test_drain_events_legacy_mode_still_works(raw_broker: socket.socket) -> None:
    """drain_events without ``subscription_id`` keeps the existing
    semantics — wire-compat for every existing consumer."""
    r = rt(raw_broker, {"op": "drain_events", "scope": "hw:/**"})
    assert r.get("ok") is True
    assert isinstance(r.get("events"), list)


def test_drain_events_subscription_id_drains_filtered_queue(
    raw_broker: socket.socket,
) -> None:
    """drain_events with a subscription_id MUST succeed and return an
    events array (empty when no events have arrived). The events
    field is always a list — never null."""
    rt(
        raw_broker,
        {
            "op": "subscribe",
            "id": "drainable",
            "scope": "hw:/usb/**",
            "predicate": {"kind_in": ["driver_bound"]},
        },
    )
    r = rt(raw_broker, {"op": "drain_events", "scope": "hw:/**", "subscription_id": "drainable"})
    assert r.get("ok") is True
    assert isinstance(r.get("events"), list)


def test_drain_events_unknown_subscription_is_not_found(
    raw_broker: socket.socket,
) -> None:
    r = rt(
        raw_broker, {"op": "drain_events", "scope": "hw:/**", "subscription_id": "never-existed"}
    )
    assert r.get("ok") is False
    assert r.get("err") == "NotFound"


def test_drain_events_empty_subscription_id_is_bad_request(
    raw_broker: socket.socket,
) -> None:
    """The grammar disallows the empty string (matches the
    MAX_SUBSCRIPTION_ID_BYTES > 0 cap)."""
    r = rt(raw_broker, {"op": "drain_events", "scope": "hw:/**", "subscription_id": ""})
    assert r.get("ok") is False
    assert r.get("err") == "BadRequest"


# ---------------------------------------------------------------------------
# Lifecycle: subscriptions die with the connection.
# ---------------------------------------------------------------------------


def test_subscription_dies_with_connection(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    """A subscription registered on connection A MUST NOT be reachable
    from a new connection B. (Subscriptions are per-connection; the
    teardown defer in handleClient reaps them on disconnect.)"""
    sock_a, _cap = raw_broker_with_cap

    # Connection A (from the fixture): subscribe + close.
    r_sub = rt(sock_a, {"op": "subscribe", "id": "x", "scope": "hw:/**"})
    assert r_sub.get("ok") is True
    sock_a.close()

    # Give the server a moment to run its disconnect-cleanup defer.
    time.sleep(0.05)

    # Connection B: open a fresh socket against the same broker
    # (`raw_broker_with_cap` keeps the broker process alive via the
    # fixture; `_SOCK_PATH` is the per-xdist-worker socket it bound).
    sock_b = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock_b.connect(_SOCK_PATH)
    buf = b""
    while b"\n" not in buf:
        buf += sock_b.recv(4096)
    try:
        # The old id should NOT be drainable from this new connection.
        r_drain = rt(sock_b, {"op": "drain_events", "scope": "hw:/**", "subscription_id": "x"})
        assert r_drain.get("ok") is False
        assert r_drain.get("err") == "NotFound"
        # The old id is free to re-claim.
        r_resub = rt(sock_b, {"op": "subscribe", "id": "x", "scope": "hw:/**"})
        assert r_resub.get("ok") is True
    finally:
        sock_b.close()


# ---------------------------------------------------------------------------
# Predicate grammar version pin (Part 13 §7.3 — sub-phase 2B).
# ---------------------------------------------------------------------------


def test_capabilities_carries_predicate_grammar_version(
    raw_broker: socket.socket,
) -> None:
    """The broker's ``capabilities`` response MUST carry a top-level
    ``predicate_grammar_version`` string (Part 13 §7.3). v1 ships
    ``"1.0.0"`` — every grammar growth bumps this additively so
    consumers can downgrade unknown features before sending."""
    r = rt(raw_broker, {"op": "capabilities"})
    assert r.get("ok") is True
    assert "predicate_grammar_version" in r
    assert r["predicate_grammar_version"] == "1.0.0"


# ---------------------------------------------------------------------------
# SDK projection pin.
# ---------------------------------------------------------------------------


def test_sdk_projection_matches_wire(broker: Client) -> None:
    """``Client.subscribe()`` returns a typed
    :class:`Subscription` whose fields mirror the wire response 1:1.
    ``Client.unsubscribe()`` succeeds silently."""
    handle = broker.subscribe(
        "sdk-sub",
        scope="hw:/usb/**",
        predicate={"kind_in": ["driver_bound"]},
    )
    assert handle.id == "sdk-sub"
    assert handle.queue_max > 0
    # drain via the SDK
    evs = broker.drain_events(subscription_id=handle.id)
    assert isinstance(evs, list)
    # tear down — no exception means success
    broker.unsubscribe(handle.id)
    # second unsubscribe raises NotFoundError
    with pytest.raises(Exception, match="NotFound"):
        broker.unsubscribe(handle.id)


# ---------------------------------------------------------------------------
# M-B B2 (court fold F-B2-02 / F-B2-05) — legacy scope-mode drain semantics.
# ---------------------------------------------------------------------------


def test_legacy_drain_repeat_same_scope_is_fine(raw_broker: socket.socket) -> None:
    """The shape every real consumer uses — one scope, drained repeatedly
    for the life of the connection — is untouched by B2."""
    for _ in range(3):
        r = rt(raw_broker, {"op": "drain_events", "scope": "hw:/usb/**"})
        assert r.get("ok") is True, r
        assert isinstance(r.get("events"), list)


def test_legacy_drain_with_a_different_scope_is_refused_with_a_reason(
    raw_broker: socket.socket,
) -> None:
    """F-B2-05 — one scope per connection, and the refusal says so.

    Since B2 a scope-mode drain is served from a hidden per-connection
    subscription whose scope is fixed by the first such call. Re-scoping
    used to be "handled" by draining the ring and discarding the events
    that fell outside the new scope — but popping is destructive, so those
    events were gone while the response said ``ok``. A silent loss (D-022)
    is worse than a refusal, so it refuses, and the ``detail`` names the
    rule rather than leaving the consumer hunting for a syntax error.
    """
    first = rt(raw_broker, {"op": "drain_events", "scope": "hw:/**"})
    assert first.get("ok") is True

    r = rt(raw_broker, {"op": "drain_events", "scope": "hw:/usb/**"})
    assert r.get("ok") is False, r
    assert r.get("err") == "BadRequest"
    assert "one scope per connection" in r.get("detail", ""), r

    # The connection is still usable on its original scope — the refusal
    # is about this request, not a poisoned connection.
    again = rt(raw_broker, {"op": "drain_events", "scope": "hw:/**"})
    assert again.get("ok") is True, again


def test_legacy_drain_survives_a_saturated_subscription_table(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    """F-B2-02 — the court's live scenario, at the wire.

    Hidden legacy-drain subscriptions used to occupy the same 256-slot
    table as ``subscribe``, so enough subscribers could make a NEW
    connection's first ``drain_events`` — a call that could not fail at
    all before B2 — answer ``Overloaded``. They now have their own table,
    bounded by the connection cap, so the two resources cannot starve each
    other.
    """
    sock_a, _cap = raw_broker_with_cap

    # Fill this connection's whole subscribe budget.
    for i in range(8):
        r = rt(sock_a, {"op": "subscribe", "id": f"s{i}", "scope": "hw:/**"})
        assert r.get("ok") is True, r
    # The 9th is refused — the per-peer cap still bites, unchanged.
    assert rt(sock_a, {"op": "subscribe", "id": "s8", "scope": "hw:/**"}).get("ok") is False

    # ...and this connection's own legacy drain is unaffected by having
    # spent every visible slot it is allowed.
    drained = rt(sock_a, {"op": "drain_events", "scope": "hw:/**"})
    assert drained.get("ok") is True, drained
    assert drained.get("events") == []

    # A SECOND connection's first legacy drain likewise answers.
    sock_b = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock_b.connect(_SOCK_PATH)
    try:
        buf = b""
        while b"\n" not in buf:
            buf += sock_b.recv(4096)
        r_b = rt(sock_b, {"op": "drain_events", "scope": "hw:/**"})
        assert r_b.get("ok") is True, r_b
        assert r_b.get("events") == []
    finally:
        sock_b.close()


def test_a_legacy_drain_creates_no_phantom_subscription(
    raw_broker_with_cap: tuple[socket.socket, int],
) -> None:
    """F-B2-03 — ``state_snapshot.active_subscriptions`` counts what an
    operator can point at.

    The court watched this go 0 → 1 after one scope-mode drain, which made
    ``mgf-hrb doctor`` report a subscription nobody had made. A hidden
    bookkeeping row is not a subscription.
    """
    sock, cap_id = raw_broker_with_cap
    before = rt(sock, {"op": "state_snapshot", "cap_id": cap_id})
    assert before.get("ok") is True, before
    base = before["active_subscriptions"]

    assert rt(sock, {"op": "drain_events", "scope": "hw:/**"}).get("ok") is True

    after = rt(sock, {"op": "state_snapshot", "cap_id": cap_id})
    assert after["active_subscriptions"] == base, after

    # A REAL subscribe does move it, so the counter is not simply dead.
    assert rt(sock, {"op": "subscribe", "id": "visible", "scope": "hw:/**"}).get("ok") is True
    moved = rt(sock, {"op": "state_snapshot", "cap_id": cap_id})
    assert moved["active_subscriptions"] == base + 1, moved
