"""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), plus the honest shrug.

    ``UNKNOWN`` is not a third family — it is what the SDK reports when
    the broker names a family this SDK does not know (SDK-11). The raw
    wire string is preserved on ``ResourceDetail.family_raw`` so a
    consumer can render "unknown (``<raw>``)" rather than a confident
    lie.
    """

    CLAIMABLE = "claimable"
    APPORTIONABLE = "apportionable"
    UNKNOWN = "unknown"


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

    ``UNKNOWN`` exists BECAUSE the registry is open (SDK-11): a kind the
    broker knows and this SDK does not must arrive as "I don't know
    this", never as whichever concrete member happened to be the
    default. The raw wire string is preserved on
    ``ResourceDetail.kind_raw``. Follows ``FieldFidelity``'s stated
    philosophy — "``unknown`` is the system refusing to lie"."""

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


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.

    **Honest enums (SDK-11).** ``kind`` and ``family`` fall back to
    ``Kind.UNKNOWN`` / ``Family.UNKNOWN`` — never to a concrete member —
    when the broker sends a value this SDK does not know, and the raw
    wire string is preserved in ``kind_raw`` / ``family_raw``.

    ``*_raw`` is ``None`` in TWO cases, and they are different facts:
    the enum matched (including a wire value of literally ``"unknown"``,
    which IS a member), or the key was absent from the wire row
    altogether — an older broker that never sent it, leaving no string to
    preserve. What ``*_raw is not None`` means is therefore precise and
    one-directional: the broker named a value this SDK does not know.
    The consumer-facing render is ``"unknown (<raw>)"``.
    """

    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
    # SDK-11 — the wire string behind an UNKNOWN enum. `None` means the
    # enum matched (including a wire value of literally "unknown", which
    # IS a member and therefore not a fallback). Appended last so every
    # positional construction written against 0.1.9 still compiles.
    kind_raw: str | None = None
    family_raw: 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).

        SDK-11: the fallback is now literally ``unknown`` for ``kind``
        and ``family``, matching what this docstring has always
        promised. Until 0.1.10 the code said ``Kind.PCIE_FUNCTION`` and
        ``Family.CLAIMABLE`` — a specific lie where the docstring
        promised an honest shrug, and one that every consumer inherited
        identically because the coercion lives in the shared projection.
        """
        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()))
        family, family_raw = _enum_or_unknown(Family, row.get("family"), Family.UNKNOWN)
        kind, kind_raw = _enum_or_unknown(Kind, row.get("kind"), Kind.UNKNOWN)
        return cls(
            urn=str(row.get("urn", "")),
            family=family,
            kind=kind,
            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")),
            kind_raw=kind_raw,
            family_raw=family_raw,
        )


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


def _enum_or_unknown(enum_cls: type[_E], value: object, unknown: _E) -> tuple[_E, str | None]:
    """Coerce a wire string into ``enum_cls``, returning
    ``(member, raw_or_none)``.

    SDK-11's honest half. ``raw`` is the wire string when — and only
    when — coercion FELL BACK, so ``raw is not None`` is exactly "this
    SDK does not know this value". Three cases:

      * key absent (``value is None``) → ``(unknown, None)``: there is
        no raw string to preserve, and the broker did not claim
        anything.
      * value matches a member (including the literal ``"unknown"``,
        which IS a member) → ``(member, None)``.
      * value is a stranger → ``(unknown, str(value))``: the consumer
        can render ``"unknown (<raw>)"`` and an operator learns the SDK
        is behind the broker, instead of seeing a confident wrong icon.
    """
    if value is None:
        return unknown, None
    text = str(value)
    try:
        return enum_cls(text), None
    except (ValueError, KeyError):
        logger.debug(
            "wire enum coercion fell back to UNKNOWN: cls=%s value=%r "
            "(broker knows a variant this SDK does not; raw preserved)",
            enum_cls.__name__,
            value,
        )
        return unknown, text


@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)
