"""Domain types — the Python mirror of ``mgf-hrb-model`` (Zig
``hrb-code/src/model.zig``).

Frozen dataclasses across module boundaries (DP-03); UTC at every boundary
(DP-12 — though ``Resource`` doesn't yet carry timestamps in the skeleton);
``from __future__ import annotations`` (AP-08). The full federation
discipline applies.

PER D-035: these types are eventually GENERATED from the canonical Zig
definitions so the two projections cannot drift. For the skeleton they're
hand-maintained and DELIBERATELY kept minimal — the Zig side has the
authoritative vocabulary. The shape mirrors ``hrb-code/src/model.zig``;
when the codegen lands, this file is regenerated and the hand-maintained
version is the spec the generator must match.
"""

from __future__ import annotations

import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any, ClassVar

# I1 (review wave 2) — module-scope logger. Used by `_enum_or_default`
# and `_optional_int` to surface silent wire-shape coercions: when a
# future broker emits an enum variant the SDK doesn't know about, the
# coercion fallback returns `default` honestly (G2) but ALSO logs a
# DEBUG line so a `-v` operator sees the skew. Without this, a new
# device kind silently degrades to the default with no breadcrumb.
logger = logging.getLogger(__name__)

__all__ = [
    "AuditOutcomeSummary",
    "AuditRecord",
    "AuditTailResult",
    "BackendCapability",
    "ChainEntry",
    "ClaimMode",
    "ControlOutcome",
    "ControlVerb",
    "DriverInfo",
    "DumpAllResult",
    "DumpChainResult",
    "Family",
    "FieldFidelity",
    "IdentityClass",
    "InspectFullResult",
    "InspectNode",
    "InspectTree",
    "Kind",
    "LeaseRecord",
    "Resource",
    "ResourceDetail",
    "StateSnapshot",
    "Subscription",
    "SupersededBy",
    "WorkerSnapshot",
]


class Family(StrEnum):
    """The two resource families (Part 2.1)."""

    CLAIMABLE = "claimable"
    APPORTIONABLE = "apportionable"


class Kind(StrEnum):
    """Open registry of hardware kinds (Part 2.5). New kinds are additive;
    consumers should NOT assume this list is exhaustive."""

    PCIE_FUNCTION = "pcie_function"
    USB_INTERFACE = "usb_interface"
    CPU_SET = "cpu_set"
    MEMORY_REGION = "memory_region"


class ClaimMode(StrEnum):
    """Claim-mode lattice for Family A (Part 3.3). Family B uses the
    reservation-strength axis instead — see ``identity.py`` companion when
    that lands."""

    OBSERVE = "observe"
    SHARED_RW = "shared_rw"
    EXCLUSIVE = "exclusive"


class ControlVerb(StrEnum):
    """Operator-visible device-state verbs (USB-worker §5.10). Each
    maps to a single sysfs write at the kabi seam — see
    ``hrb-code/src/model.zig`` ``ControlVerb`` for the canonical list.
    These mutate hardware state without acquiring it, so they are
    cap-gated rather than lease-gated."""

    SET_AUTHORIZED = "set_authorized"
    SET_AUTOSUSPEND = "set_autosuspend"
    SET_PORT_POWER = "set_port_power"
    BIND_DRIVER = "bind_driver"
    UNBIND_DRIVER = "unbind_driver"


class FieldFidelity(StrEnum):
    """Per-field trustworthiness (Part 2.8 / G2). ``unknown`` is the system
    refusing to lie; ``cached`` is bound to an invalidation event."""

    LIVE = "live"
    CACHED = "cached"
    INFERRED = "inferred"
    UNKNOWN = "unknown"


class IdentityClass(StrEnum):
    """URN stability ladder (Part 2.4 / D-010). Recorded so a consumer knows
    whether "the same URN tomorrow" means "the same hub port" or "the same
    physical device".

    K1 (review wave 2): fixed a typo here — the previous docstring ended
    with a stray empty-string literal after the closing triple-quote,
    which valid-parses but is misleading to readers. The IdentityClass
    enum's variants:

      * ``TOPOLOGICAL`` — URN identifies the physical PORT. A device
        unplugged + replugged at the same port gets the same URN; a
        different device at that port also gets the same URN.
      * ``INTRINSIC`` — URN identifies the DEVICE by intrinsic
        descriptor (serial number). Survives port-change; doesn't
        survive a different physical device claiming the same serial.
      * ``SYNTHETIC_PINNED`` — operator-supplied alias pinned to an
        intrinsic descriptor (e.g. "the lab's third iFig"). Survives
        bus renumbering AND host migration. Not in v1 (D-010 follow-up).
    """

    TOPOLOGICAL = "topological"
    INTRINSIC = "intrinsic"
    SYNTHETIC_PINNED = "synthetic_pinned"


@dataclass(frozen=True, slots=True)
class ResourceDetail:
    """Per-URN typed detail returned by ``Client.inspect`` (FEEDBACK
    SDK-02 typed half — pairs with the verb shipped in USB #2).

    Frozen / hashable across the boundary (DP-03), so a GUI can cache
    the dataclass as a Qt model row or include it in a frozenset of
    "seen URNs". Backend-specific attributes live in ``attrs`` as a
    tuple of (key, value) pairs — string-valued on the wire by
    construction (the broker's ``AttrJsonWriter`` emits
    ``"key":"value"``). Use ``detail.attr("idVendor")`` for the
    common single-attr lookup; ``dict(detail.attrs)`` if you want
    full dict semantics.

    Forward-compatible: ``parent``, ``state``, and typed
    vid/pid/serial are absent today and will land as optional fields
    when the broker grows them. Older consumers see ``None``/empty;
    newer brokers don't break the contract.
    """

    urn: str
    family: Family
    kind: Kind
    fidelity: FieldFidelity
    # Backend-specific attributes — string-valued on the wire.
    # Sorted (key, value) pairs so the dataclass stays hashable and
    # the rendering is deterministic.
    attrs: tuple[tuple[str, str], ...] = ()
    # Forward-compatible — broker may grow these in a future MINOR.
    parent: str | None = None
    state: str | None = None

    def attr(self, key: str) -> str | None:
        """Look up one attribute by key. Returns ``None`` for an
        unknown key — the broker emits attrs honestly (G2): a USB
        device with no iSerialNumber simply doesn't have a ``serial``
        attr; consumers shouldn't conflate "key absent" with "value
        empty string"."""
        for k, v in self.attrs:
            if k == key:
                return v
        return None

    @classmethod
    def from_wire(cls, row: dict[str, object]) -> ResourceDetail:
        """Build from the broker's wire shape.

        Defensive at the seam: unknown enum values fall back to
        ``unknown`` rather than raising; missing keys default
        sensibly. Same defensive philosophy as
        :py:meth:`BackendCapability.from_wire` (DP-03 / EH-02).
        """
        attrs_raw = row.get("attrs", {})
        attrs_tup: tuple[tuple[str, str], ...] = ()
        if isinstance(attrs_raw, dict):
            attrs_tup = tuple(sorted((str(k), str(v)) for k, v in attrs_raw.items()))
        return cls(
            urn=str(row.get("urn", "")),
            family=_enum_or_default(Family, row.get("family"), Family.CLAIMABLE),
            kind=_enum_or_default(Kind, row.get("kind"), Kind.PCIE_FUNCTION),
            fidelity=_enum_or_default(FieldFidelity, row.get("fidelity"), FieldFidelity.UNKNOWN),
            attrs=attrs_tup,
            parent=_optional_str(row.get("parent")),
            state=_optional_str(row.get("state")),
        )


from typing import TypeVar  # noqa: E402

_E = TypeVar("_E", bound=StrEnum)


def _enum_or_default(enum_cls: type[_E], value: object, default: _E) -> _E:
    """Coerce a wire string into the enum class, falling back to
    ``default`` for unknown values rather than raising. The G2
    discipline at the seam: an unrecognised wire field is honestly
    reported as ``unknown`` (or the per-field default), not a
    KeyError that crashes the consumer.

    I1 (review wave 2): unknown values now log at DEBUG so a `-v`
    operator sees broker/SDK version skew — previously the silent
    degradation hid a new device variant behind the default.
    """
    if value is None:
        return default
    try:
        return enum_cls(str(value))
    except (ValueError, KeyError):
        logger.debug(
            "wire enum coercion fell back: cls=%s value=%r → default=%r "
            "(broker may have introduced a new variant the SDK doesn't know)",
            enum_cls.__name__,
            value,
            default,
        )
        return default


@dataclass(frozen=True, slots=True)
class DumpAllResult:
    """The result of a bulk-inspect (Part 13 §5.5.3).

    Returned by ``Client.dump_all``. Carries every URN's
    :class:`ResourceDetail` in scope-order, plus an honest
    ``truncated`` flag that the broker sets when its response
    buffer filled before every match could be emitted (consumer
    should re-issue against a narrower scope in that case).

    Frozen / hashable so a GUI can cache it as a Qt model snapshot.
    """

    scope: str
    items: tuple[ResourceDetail, ...]
    count: int
    truncated: bool


InspectTree = Mapping[str, "InspectNode"]
"""One sysfs directory: attribute-name → value (str / nested-dict /
symlink-sentinel / binary-sentinel). See :class:`InspectFullResult`."""


# `InspectNode` is what a tree key may map to: a str attr value, a
# nested directory dict, the ``{"_symlink": "..."}`` sentinel, or the
# ``{"_binary": true}`` sentinel. Modelled as Any so consumers don't
# need a runtime union check at every key; the broker contract pins
# the four shapes (Part 13 §5.5.1).
InspectNode = Any


@dataclass(frozen=True, slots=True)
class ChainEntry:
    """One rung of a `dump_chain` walk (Part 13 §5.5.2).

    Carries the rung's URN, its topology `level` tag (one of
    ``"interface"``, ``"device"``, ``"hub"``, ``"root_hub"``,
    ``"controller"``, ``"bridge"``, ``"root_complex"``), and a
    short human-readable `summary` the consumer can render
    without a follow-up `inspect_full` round-trip.

    The `level` value is wire-pinned (closed grammar above). The
    `summary` string's CONTENTS are NOT wire-pinned — a backend
    may evolve the per-level summary shape over time; treat it as
    diagnostic, not as a structured field.

    Frozen / hashable so a GUI can cache the chain as a Qt model
    snapshot or compare two chains by equality.
    """

    urn: str
    level: str
    summary: str


@dataclass(frozen=True, slots=True)
class DumpChainResult:
    """The result of a parent-chain walk (Part 13 §5.5.2).

    Returned by ``Client.dump_chain``. Carries the queried URN,
    the broker-reported fidelity tag, the broker's monotonic
    ``captured_at_ms``, the ordered tuple of :class:`ChainEntry`
    rungs (leaf-first; the queried URN at index 0, the substrate
    root at the end), and an honest ``truncated`` flag.

    For a USB interface the chain is typically 4-5 rungs:
    interface → device → (zero or more hubs) → root_hub →
    controller. For a PCI function it's 1 + N + 1 rungs:
    device → (zero or more bridges) → root_complex.

    The intermediate rungs (device / hub / bridge / root_hub /
    root_complex) carry SYNTHETIC URNs in the URN scheme — they
    are topology labels, not first-class enumerable resources.
    Only the leaf rung's URN matches an enumerable URN returned
    by ``Client.list``; the consumer that wants per-rung detail
    calls ``inspect_full`` on enumerable rungs only.

    Frozen / hashable so a GUI can use the dataclass as a Qt
    model row.
    """

    urn: str
    fidelity: str
    captured_at_ms: int
    chain: tuple[ChainEntry, ...]
    truncated: bool


@dataclass(frozen=True, slots=True)
class InspectFullResult:
    """The result of a full-subtree inspect (Part 13 §5.5.1).

    Returned by ``Client.inspect_full``. Carries the resolved URN, the
    fidelity tag the backend reports, the broker's monotonic
    ``captured_at_ms`` (useful for "since when" rendering), the nested
    ``tree`` (sysfs subtree of the resource), and an honest
    ``truncated`` flag that the broker sets when its response buffer
    filled or any walker bound (MAX_DEPTH / MAX_ENTRIES /
    MAX_FILE_BYTES) tripped mid-walk.

    The ``tree`` keys are attribute / sub-directory names. Values:
      - ``str`` — a regular text attribute (e.g. ``"0x05ac"``).
      - ``dict[str, ...]`` — a nested sub-directory (recurses).
      - ``{"_symlink": "<target>"}`` — a symlink (never followed).
      - ``{"_binary": True}`` — a binary attribute (bytes not in JSON).

    Frozen / immutable (no field reassignment). NOT hashable when
    ``tree`` is non-empty (the nested dict is unhashable);
    declared ``frozen=True`` for the immutability semantics, not
    for set-membership. Consumers needing to cache or de-dup
    should key on ``(urn, captured_at_ms)`` instead — those are
    sufficient for a "did this result change since last poll?"
    check. See review C1 / 2026-05-26.
    """

    urn: str
    fidelity: str
    captured_at_ms: int
    tree: InspectTree
    truncated: bool

    # Suppress dataclass's auto-generated __hash__: the `tree`
    # field is a dict (unhashable), so the auto-generated hash
    # would raise TypeError at runtime when called on a populated
    # result. Marking it None makes the class explicitly
    # unhashable so the error surfaces at hash() call time as a
    # clean TypeError("unhashable type") with no confusion about
    # what shape was supposed to work.
    __hash__ = None  # type: ignore[assignment]


@dataclass(frozen=True, slots=True)
class AuditRecord:
    """One row from the broker's audit ring (Part 13 §5.6.1).

    Every wire op the broker handles records one of these. The
    ring is fixed-size (4096 records); consumers query it via
    :py:meth:`Client.audit_tail` for retrospective diagnostic
    queries — "what happened during the last 30 seconds while
    my claim was failing?"

    Fields:
      * ``seq``         — broker-monotonic counter; ++ per record.
      * ``ts_ms``       — broker-monotonic timestamp.
      * ``duration_us`` — wall-clock from request-parse to
                          response-emit, in microseconds.
      * ``op``          — the wire op name.
      * ``outcome``     — closed set: ``"ok"`` / ``"err"`` /
                          ``"timeout"`` / ``"conflict"``.
      * ``peer_uid`` / ``peer_pid`` — SO_PEERCRED at accept-time.
      * ``cap_id``      — the capability id the request carried,
                          when the op extracts one. 0 means
                          "not extracted" (most ops).
      * ``urn``         — present for ops that carry a URN
                          (claim / release / raw / inspect /
                          control / inspect_full / dump_chain).
      * ``err``         — populated when ``outcome != "ok"``.
      * ``correlation_id`` — echoed when the request carried
                          one (Part 13 §5.6.3 / §7.1).

    Frozen / hashable so a GUI can use the record as a Qt
    model row.
    """

    seq: int
    ts_ms: int
    duration_us: int
    op: str
    outcome: str
    peer_uid: int
    peer_pid: int
    cap_id: int
    urn: str | None
    err: str | None
    correlation_id: str | None

    @property
    def duration_human(self) -> str:
        """A2 (presentation-layer plan) — formatted duration string.

        Convenience property that delegates to ``mgf.hrb.fmt.format_duration``
        so every UI consumer (CLI, Qt, future TUI) gets identical
        rendering without re-implementing the unit-cascade. The fmt
        helper handles the µs/ms/s/m bands and the defensive
        coercion for unexpected types. See `fmt.format_duration` for
        the canonical examples.
        """
        # Late import avoids a circular-import risk if a future fmt
        # helper ever needs to import domain types — keeping the
        # dependency edge SDK→fmt one-way.
        from mgf.hrb.fmt import format_duration

        return format_duration(self.duration_us)


@dataclass(frozen=True, slots=True)
class AuditTailResult:
    """The result of a ``Client.audit_tail()`` call (Part 13 §5.6.4).

    Carries up to N most-recent records (newest first), plus the
    ring's total capacity and the timestamp of the oldest record
    in the returned slice."""

    records: tuple[AuditRecord, ...]
    ring_capacity: int
    earliest_ts_ms: int

    def outcome_summary(self) -> AuditOutcomeSummary:
        """A2 (presentation-layer plan) — tally records by outcome.

        Walks ``self.records`` and returns a typed summary every UI
        wants: ``(ok, err, other, newest_error)``. Replaces the inline
        loop previously embedded in ``cli/_doctor.py:_render_broker_health``
        (lines 263-275 pre-extraction).

        Ordering note: ``records`` is broker-emitted "newest first"
        (per Part 13 §5.6.4 ordering contract). The "newest error"
        therefore lands on the FIRST record whose ``outcome == "err"`` —
        no full-list sort needed.

        Returns the summary even when ``records`` is empty:
        ``AuditOutcomeSummary(0, 0, 0, None)``.
        """
        ok = 0
        err = 0
        other = 0
        newest_error: AuditRecord | None = None
        for rec in self.records:
            if rec.outcome == "ok":
                ok += 1
            elif rec.outcome == "err":
                err += 1
                if newest_error is None:
                    # Records are newest-first; the first err we see IS
                    # the most recent one.
                    newest_error = rec
            else:
                # "timeout" / "conflict" / any future broker variant
                # — counted under "other" rather than misclassified.
                # When a new outcome bucket becomes operator-meaningful
                # it can be promoted out of "other" without breaking
                # consumers that already group by this summary.
                other += 1
        return AuditOutcomeSummary(ok=ok, err=err, other=other, newest_error=newest_error)


@dataclass(frozen=True, slots=True)
class AuditOutcomeSummary:
    """A2 (presentation-layer plan) — typed return shape for
    :py:meth:`AuditTailResult.outcome_summary`.

    A named tuple-shape would be sufficient but a dataclass is
    typed-cleaner (mypy can name the fields, IDEs autocomplete) and
    matches the rest of the SDK's frozen-slots convention.

    Fields:
      * ``ok``            — count of records with ``outcome == "ok"``.
      * ``err``           — count with ``outcome == "err"``.
      * ``other``         — count of any other outcome bucket
                            (today: "timeout" / "conflict"; future
                            broker variants land here too).
      * ``newest_error``  — the most-recent ``err`` record in the
                            tail, or ``None`` if no errors. Useful
                            for the operator-facing "what was the
                            last bad thing?" callout.

    Frozen / hashable; safe as a Qt model row or a dict key."""

    ok: int
    err: int
    other: int
    newest_error: AuditRecord | None


@dataclass(frozen=True, slots=True)
class WorkerSnapshot:
    """One per-worker entry in a :class:`StateSnapshot`'s ``workers``
    tuple (Part 13 §5.6.2). Reports the worker's scope prefix, the
    backend name, its operating mode, and how many URLs that worker
    enumerates today.

    ``scope_prefix`` was previously the dict key in StateSnapshot's
    ``workers`` field; folding it into the value lets the parent be
    a tuple-of-WorkerSnapshot (genuinely hashable) instead of a
    dict (declared-but-unhashable frozen-dataclass field). See
    review C1 / 2026-05-26.
    """

    scope_prefix: str
    backend: str
    mode: str
    url_count: int


@dataclass(frozen=True, slots=True)
class StateSnapshot:
    """The result of a ``Client.state_snapshot()`` call (Part 13
    §5.6.2). Aggregates the broker's process-wide counters into
    one diagnostic payload — for ``mgf-hrb doctor``, dashboards,
    or the GUI's "broker health" pane.

    Fields:
      * ``ts_ms``               — current broker-monotonic time.
      * ``uptime_ms``           — broker uptime in ms.
      * ``broker_version``      — semver string (matches hello).
      * ``ops_total``           — cumulative wire ops handled.
      * ``ops_per_second_1m``   — float — ops over the last minute,
                                  divided by 60. Computed from the
                                  audit ring's timestamps; when
                                  uptime < 60s, reports ops-so-far
                                  / 60 (consumer compares against
                                  ``uptime_ms`` for representativeness).
      * ``active_connections``  — open client connections.
      * ``active_leases``       — live leases across all workers.
      * ``active_subscriptions`` — registered subscriptions.
      * ``cap_table_size``      — live capabilities in the broker's
                                  CapTable.
      * ``audit_ring_used``     — current ring fill (0..capacity).
      * ``audit_ring_capacity`` — fixed at 4096 today.
      * ``audit_dropped_total`` — lifetime count of audit records
                                  evicted by ring overflow. A
                                  non-zero delta between two
                                  snapshots tells the operator the
                                  ring is too small for the current
                                  op rate and security history is
                                  being silently overwritten.
      * ``memory_rss_bytes``    — broker's resident-set size in
                                  bytes (best-effort; 0 when the
                                  /proc read fails or on non-Linux).
      * ``workers``             — tuple of WorkerSnapshot (each
                                  carries its scope_prefix). Look
                                  up by scope with
                                  ``next(w for w in snap.workers
                                  if w.scope_prefix == "hw:/usb/")``,
                                  or build a dict on the fly with
                                  ``{w.scope_prefix: w for w in
                                  snap.workers}``.
      * ``fidelity``            — "live" — every field is read
                                  from process memory, no caching.

    Frozen / hashable so a GUI can cache it as a Qt model
    snapshot. (Previously ``workers`` was a ``dict`` which made
    the class declared-but-unhashable; the tuple shape closes
    that fragility — see review C1 / 2026-05-26.)"""

    ts_ms: int
    uptime_ms: int
    broker_version: str
    ops_total: int
    ops_per_second_1m: float
    active_connections: int
    active_leases: int
    active_subscriptions: int
    cap_table_size: int
    audit_ring_used: int
    audit_ring_capacity: int
    audit_dropped_total: int
    memory_rss_bytes: int
    workers: tuple[WorkerSnapshot, ...]
    fidelity: str

    @property
    def uptime_human(self) -> str:
        """A2 (presentation-layer plan) — formatted uptime string.

        Convenience property that delegates to ``mgf.hrb.fmt.format_uptime``.
        Pre-A2 the formatting lived inline in ``cli/_doctor.py``
        (lines 226-230) so a future GUI would have re-invented the
        same logic — risking silent drift between CLI's "1h 2m 5s"
        and GUI's "1h 2m" for the same broker. Centralising
        eliminates the drift class.

        Examples:
            >>> # snap.uptime_ms == 3_725_000
            >>> snap.uptime_human
            '1h 2m 5s'
        """
        from mgf.hrb.fmt import format_uptime

        return format_uptime(self.uptime_ms)

    @property
    def memory_rss_human(self) -> str:
        """A2 — formatted RSS string using binary prefixes (KiB/MiB/GiB).

        Delegates to ``mgf.hrb.fmt.format_rss``. Binary prefixes match
        Linux memory-reporting convention; consumers that swap to
        decimal SI units would introduce the classic "42 MiB on
        screen, 44 MB on `free -h`" operator confusion (defended by
        a `test_rss_uses_binary_prefixes_not_decimal` pin).
        """
        from mgf.hrb.fmt import format_rss

        return format_rss(self.memory_rss_bytes)


@dataclass(frozen=True, slots=True)
class SupersededBy:
    """A `superseded_by` payload from a :class:`ControlOutcome`
    (Part 13 §5.1.2). Non-null only when another writer landed
    between our `control` op's unlock and the response's emit.

    `generation` is the post-supersede gen the other writer
    landed at; `cap_id` is their writer identity; `at_ms` is the
    broker's monotonic timestamp."""

    generation: int
    cap_id: int
    at_ms: int


@dataclass(frozen=True, slots=True)
class ControlOutcome:
    """The optimistic-concurrency disclosure the writer learns
    on every successful ``Client.control()`` call (Part 13
    §5.1.2, sub-phase 4B).

    Fields:
      * ``generation`` — monotonic per (urn, verb); ++ on every
        applied write.
      * ``applied_value`` — the value the broker actually wrote
        (echoes the request today; future verbs may normalise).
      * ``queue_depth`` — approximate count of writers FIFO-
        queued behind ours when our lock acquired.
      * ``superseded_by`` — `None` unless the broker detected
        another writer landed between our unlock and response
        emit. v1 always reports `None` (see implementation note
        in linux_usb.vControl); future tightening detects real
        interleaving by re-snapshotting state.generation after
        unlock.

    A consumer that wants "did MY write stick?" compares the
    returned `generation` against what it expected (pre-write
    `inspect`) — if they differ, supersede happened.
    """

    generation: int
    applied_value: str
    queue_depth: int
    superseded_by: SupersededBy | None


@dataclass(frozen=True, slots=True)
class DriverInfo:
    """The result of a ``Client.driver_info()`` call (Part 13 §5.2.2).

    Reports the broker-visible interface of a kernel driver:
      * ``name``           — the driver name as queried.
      * ``module``         — the loaded-module name (or `None` until
                              the curated registry ships in 3B).
      * ``module_loaded``  — true iff `/sys/module/<name>` exists.
      * ``bound_count``    — number of devices currently bound to
                              the driver across known buses (USB + PCI).
      * ``sample_bound``   — sysfs name of one bound device, or `None`
                              when nothing's bound. Useful as an
                              at-a-glance inspect target.
      * ``sysfs_attrs``    — list of (key, type, writable) tuples
                              describing the driver's attrs on bound
                              devices. Empty in v1; populated when
                              the device-attr walker is curated (3B).
      * ``ioctls``         — list from the curated registry (3B).
                              `None` in v1.
      * ``char_devices``   — `/dev` nodes the driver creates (3B).
      * ``uevent_actions`` — kernel uevent actions (3B).
      * ``registry_version`` — date the YAML entry was verified (3B).
      * ``fidelity``       — `"inferred"` in v1 (live state + missing
                              registry); becomes `"live"` for in-registry
                              drivers when 3B ships, `"unknown"` for
                              drivers absent from the registry.

    Honest emptiness: a driver that's not present on this host
    returns `module_loaded=False, bound_count=0` rather than raising
    — consumers learn "driver not here" structurally.

    Frozen / hashable so a GUI can cache the record as a Qt model row.
    """

    name: str
    module: str | None
    module_loaded: bool
    bound_count: int
    sample_bound: str | None
    sysfs_attrs: tuple[Any, ...]
    ioctls: tuple[Any, ...] | None
    char_devices: tuple[Any, ...] | None
    uevent_actions: tuple[str, ...] | None
    registry_version: str | None
    fidelity: str


@dataclass(frozen=True, slots=True)
class Subscription:
    """The result of a successful ``Client.subscribe()`` call
    (Part 13 §5.4.2).

    Carries the echoed subscription id and the broker-reported
    queue cap (the bounded ring's size — `64` in v1; the spec
    ceiling is `1024`, deferred to a later tightening).

    The id is the handle the consumer passes to
    :py:meth:`Client.drain_events` (as ``subscription_id``) to
    drain only this subscription's filtered queue.
    """

    id: str
    queue_max: int


@dataclass(frozen=True, slots=True)
class LeaseRecord:
    """One live lease as the broker reports it (FEEDBACK SDK-03).

    Returned by ``Client.list_leases``. Frozen / hashable across the
    boundary (DP-03), so a GUI can use the dataclass as a Qt model
    row directly. The ``granted_at_ms`` is the broker's monotonic
    clock at commit time — useful for "since when" rendering and for
    ordering across operators.
    """

    urn: str
    lease_id: int
    mode: ClaimMode
    holder: int  # client_id; matches `Client._id` minted at connect()
    granted_at_ms: int


@dataclass(frozen=True, slots=True)
class BackendCapability:
    """One worker's reported capabilities (FEEDBACK SDK-05).

    Returned by ``Client.capabilities`` (typed projection over the
    wire's dict shape). Forward-compatible: new fields land as
    ``Optional``/default-bearing so an older consumer talking to a
    newer broker doesn't break — and a newer consumer talking to an
    older broker sees ``None`` for fields that didn't exist yet.

    Designed to map cleanly onto a GUI capabilities pane: each row
    one ``BackendCapability``, fields render directly without
    per-consumer projection layers (the friction the paper named).
    """

    # A3 (presentation-layer plan) — canonical list of the kabi
    # vtable op names that the broker reports per backend. Pre-A3
    # the CLI's _doctor.py hard-coded this list inline (lines
    # 308-313 pre-extraction); every consumer that wanted to render
    # an ops grid had to either copy the list OR call ops_dict() and
    # accept that an iteration over keys would be non-deterministic
    # in display order. Surfacing it as a class-level constant means:
    #
    #   1. ONE place to update when a new op lands on the broker
    #      (cluster H of the wave-1 follow-ups will add a few more
    #      — drain_events, control, inspect_full, dump_chain, etc.;
    #      see the API-audit's finding #22).
    #   2. Display order is the canonical seam order — the same
    #      order operators see in `mgf-hrb doctor` today.
    #   3. Forward-compat discipline: `ops_dict()` returns WHATEVER
    #      the broker emitted; `KNOWN_OPS` is the OLDER stable set
    #      this SDK was built against. Consumers iterating
    #      `KNOWN_OPS` get a stable grid; consumers iterating
    #      `ops_dict().keys()` get the full live set including any
    #      broker-side additions the SDK hasn't been updated for.
    #
    # NB: this is NOT a Magogi-foundation-wide enum — kabi op names
    # are mgf-hrb-specific. A future mgf-cpu would have its own list.
    KNOWN_OPS: ClassVar[tuple[str, ...]] = (
        "enumerate",
        "query_topology",
        "watch",
        "acquire",
        "release",
        "reset",
        "map_region",
        "raw_channel",
        "enforce_cap",
    )

    scope_prefix: str
    backend_name: str
    resource_count: int
    # Forward-compatible — present in 0.39+ broker; absent on older.
    backend_version: str | None = None
    mode: str | None = None  # "full" / "read_only" / "degraded_*"
    mode_reason: str | None = None
    # Per-op support flags (key = op name, value = supported?).
    # Empty dict on older brokers; populated dict on current.
    ops: tuple[tuple[str, bool], ...] = ()

    @classmethod
    def from_wire(cls, row: dict[str, object]) -> BackendCapability:
        """Build from the raw wire dict the broker emits. Tolerates
        missing keys (older broker) and unexpected types (defensive
        coercion at the seam, same as the legacy hrb-app projection
        did)."""
        ops_raw = row.get("ops", {})
        ops_tup: tuple[tuple[str, bool], ...] = ()
        if isinstance(ops_raw, dict):
            ops_tup = tuple(sorted((str(k), bool(v)) for k, v in ops_raw.items()))
        return cls(
            scope_prefix=str(row.get("scope_prefix", "")),
            backend_name=str(row.get("backend_name", "")),
            resource_count=_optional_int(row.get("resource_count"), 0),
            backend_version=_optional_str(row.get("backend_version")),
            mode=_optional_str(row.get("mode")),
            mode_reason=_optional_str(row.get("mode_reason")),
            ops=ops_tup,
        )

    def ops_dict(self) -> dict[str, bool]:
        """A3 (presentation-layer plan) — convert the storage
        tuple-of-pairs to a flat dict, the shape every consumer
        actually wants when rendering.

        Pre-A3 every consumer (the CLI, the future hrb-app) had to
        do `dict(cap.ops)` inline. Centralising as a method gives
        a single discoverable entry point AND ensures the same key/
        value coercion happens everywhere (the from_wire factory
        already pinned key:str / value:bool; this method preserves
        those types).

        Returns a fresh dict per call (the underlying tuple is the
        canonical storage; callers free to mutate the returned
        dict). For grid-style display, prefer iterating
        :attr:`KNOWN_OPS` against this dict so display order stays
        stable as broker-side ops shift.

        Examples:
            >>> cap.ops_dict()
            {'enumerate': True, 'release': True, 'acquire': False, ...}
            >>> [cap.ops_dict().get(op, False) for op in cap.KNOWN_OPS]
            [True, True, False, True, True, False, False, False, False]
        """
        return dict(self.ops)


def _optional_str(v: object) -> str | None:
    """Coerce a wire field to ``str | None`` without lying. Empty
    string becomes ``None`` so the dataclass field "I have no value
    here" reads consistently."""
    if v is None:
        return None
    s = str(v)
    return s if s else None


def _optional_int(v: object, default: int) -> int:
    """Coerce a wire field to ``int`` with a fallback. Defensive at
    the seam: the broker should always send an int but we never trust
    the wire shape."""
    if isinstance(v, bool):
        # bool is an int subclass — exclude explicitly so a stray
        # ``True``/``False`` doesn't silently become 1/0.
        return default
    if isinstance(v, int):
        return v
    if isinstance(v, (str, bytes)):
        try:
            return int(v)
        except (TypeError, ValueError):
            # I1 — log the silent coercion so a `-v` operator sees
            # broker/SDK skew (the broker sent a malformed numeric
            # string the SDK couldn't parse).
            logger.debug(
                "wire int coercion fell back: value=%r → default=%d",
                v,
                default,
            )
            return default
    # Wire field was neither int, bool, str, nor bytes — broker bug
    # or an SDK consumer that built a fake response from a dict with
    # the wrong types. Log so it's findable, then degrade gracefully.
    logger.debug(
        "wire int coercion fell back: non-numeric type %s value=%r → default=%d",
        type(v).__name__,
        v,
        default,
    )
    return default


@dataclass(frozen=True, slots=True)
class Resource:
    """A hardware resource as the broker reports it (Part 2.2).

    Skeleton: the Zig side carries a richer descriptor (per-field fidelity
    tags, parent/children, attrs). The Python skeleton mirrors the minimum
    a consumer needs to act. Grows additively as the wire format matures.
    """

    urn: str
    family: Family = Family.CLAIMABLE
    kind: Kind = Kind.PCIE_FUNCTION
    identity_class: IdentityClass = IdentityClass.TOPOLOGICAL
    fidelity: FieldFidelity = FieldFidelity.LIVE
    parent: str | None = None
    state: str = "free"  # raw state-tag string; typed enum coming with the
    # `inspect` op wiring
    # USB-specific identifiers when known; absent / None for other kinds.
    vid: int | None = None
    pid: int | None = None
    serial_number: str | None = None
    extra: tuple[tuple[str, str], ...] = field(default_factory=tuple)
