"""Typed exception hierarchy for the mgf-hrb SDK.

EH-01 / EH-02 made concrete for this sibling. The Zig broker reports
failures as typed ``Err`` codes over the wire (see ``hrb-code/src/model.zig``
``Err`` enum); the SDK's transport translates each into the matching Python
exception. A Zig-side failure can never unwind into the interpreter — they
are SEPARATE processes (D-005).

When ``mgf-common`` is available we extend its base ``AppError`` so the
federation's top-level handlers (EH-04) and observability redaction
(``mgf.common.observability``) work uniformly. If ``mgf-common`` isn't
installed (which is unusual — it's a declared dependency in
``pyproject.toml``), we fall back to ``Exception`` so the SDK is still
useful in degraded environments. The shape is identical either way.
"""

from __future__ import annotations

import logging

# I1 (review wave 2) — module-scope logger. Needed by `from_wire` for
# I3's unknown-code warning, kept available for any future site that
# wants to surface an exception-conversion event.
logger = logging.getLogger(__name__)

try:
    from mgf.common.exceptions import AppError as _BaseError
except ImportError:  # pragma: no cover — mgf-common is declared in pyproject
    # The trailing type-ignore codes on the assignment below cover:
    # - [assignment]: rebinding `_BaseError` to a different class
    #   violates the original type binding mypy inferred from the
    #   try-branch import.
    # - [misc]: mypy refuses to use a dynamically-bound class as a
    #   base class for the `MgfHrbError` definition below.
    # Both are safe at runtime — by this branch we've accepted that
    # the federation's AppError is unreachable; the exception tree
    # still works (consumers' `except MgfHrbError` blocks still
    # match), just without mgf-common's redaction + structured-error
    # machinery. The fallback is unusual (mgf-common is a declared
    # dependency in pyproject.toml ≥ 0.40) — log loudly if it fires
    # so an operator sees the install issue.
    _BaseError = Exception  # type: ignore[assignment, misc]
    import warnings as _warnings

    _warnings.warn(
        "mgf-common not importable — falling back to bare Exception base. "
        "The SDK still works, but mgf-common's redaction / structured-error "
        "machinery is bypassed. Reinstall mgf-common to restore.",
        ImportWarning,
        stacklevel=2,
    )


__all__ = [
    "BadRequestError",
    "ConflictError",
    "HardwareFaultError",
    "InvalidTokenError",
    "MgfHrbError",
    "NotFoundError",
    "NotPermittedError",
    "OverloadedError",
    "RevokedError",
    "TimeoutError",
    # O2 (review wave 3) — `UnavailableError` was previously absent
    # from this module's `__all__` despite being a real wire-err
    # produced by the broker (model.zig::Err.Unavailable) and
    # consumed by `_WIRE_TO_EXC`. The top-level `mgf.hrb` re-exported
    # it via `from .exceptions import UnavailableError` so consumers
    # who did `from mgf.hrb import UnavailableError` worked — but
    # `from mgf.hrb.exceptions import UnavailableError` would issue
    # an AP-14 "private import" warning from any tooling that
    # respects the per-module __all__ contract. Promoting it here
    # makes the typed import path stable.
    "UnavailableError",
    "UnsupportedError",
    "from_wire",
]


class MgfHrbError(_BaseError):
    """Root of the mgf-hrb exception tree. Every broker-side failure surfaces
    as a subclass of this — consumers catch this if they want "anything went
    wrong inside the broker" semantics, or a specific subclass for
    fine-grained control (EH-02).

    **What to do at this level.** Catching the root is appropriate for:

    * **Last-resort error pages / operator dashboards** — display
      whatever this carries; the subclass tree below gives the operator
      enough context.
    * **Cross-cutting cleanup** — releasing a resource you held when
      ANY broker error happens.
    * **Logging / instrumentation** — every broker-originated failure
      has been categorised at this point; catching the root + dumping
      ``type(exc).__name__`` is a complete forensic signal.

    For consumer logic, prefer catching specific subclasses — the
    "what to do" sentence on each subclass tells you the action."""


class NotFoundError(MgfHrbError):
    """The named resource doesn't exist in the broker's view of the bus.

    **What to do.** Re-enumerate via :py:meth:`Client.list` to confirm
    the URN; check for a typo in the URN string; verify the device is
    physically present (`lsusb` / `lspci` outside the broker). On USB,
    a transient ``NotFound`` during hot-plug is normal — the device may
    have been removed between your enumerate and your claim. Don't
    retry blindly; the next ``Client.list`` is authoritative."""


class NotPermittedError(MgfHrbError):
    """The presented capability doesn't authorise this operation, or the
    caller isn't the lease holder (Part 4).

    **What to do.** Two distinct causes share this exception:

    1. **Cap doesn't carry the right.** A read-only cap can't ``claim``;
       a cap without ``raw`` can't ``raw()``; a cap without ``audit_read``
       can't ``audit_tail`` / ``state_snapshot``. Check the cap was
       minted with the right(s) you need.

    2. **You're not the lease holder.** ``release``/``raw`` enforce
       holder-only access; another client (or a stale connection from
       your own process) may hold the lease. ``list_leases`` shows
       who holds what."""


class ConflictError(MgfHrbError):
    """Claim-mode incompatibility (Part 3.3) — typically a second exclusive
    against an already-held device.

    **What to do.** Catch and decide:

    * **Downgrade the requested mode** — exclusive → shared_rw →
      observe — if your workflow allows.
    * **Wait + retry with backoff** — the conflicting holder may
      release shortly. Don't busy-loop.
    * **Surface to the operator** — they can ``revoke`` the existing
      holder if authorised (requires the ``steal`` cap right)."""


class UnsupportedError(MgfHrbError):
    """The backend doesn't implement this op on this host (honest "I can't,"
    G2). Carries an optional ``reason`` and ``suggested_fix`` when the broker
    populates the structured-error context (e.g. ``no_iommu``, missing udev
    rule).

    **What to do.** This is the broker telling you the truth, not a
    failure to investigate further on the broker side. The action lives
    OUTSIDE the broker: install VFIO + IOMMU drivers; add the missing
    udev rule; switch to a substrate that supports the op. Running
    ``mgf-hrb doctor`` will list every substrate gap with a
    copy-pastable remediation."""


class HardwareFaultError(MgfHrbError):
    """The hardware acquire / reset / op failed at the device level.

    **What to do.** This is a real device-level failure, not a
    permission or wire issue. Try once with a longer ``deadline_ms``
    in case the device is slow; if it persists, the device may need a
    physical power-cycle or a USB-port reset. If the broker emitted
    a ``Quarantined`` state (visible via ``inspect``), a verified
    reset is the recovery path. Repeated ``HardwareFault`` on the
    same URN over short windows = device is failing; surface to the
    operator."""


class InvalidTokenError(MgfHrbError):
    """The lease / capability / acquire token isn't recognised (expired,
    revoked, or never minted).

    **What to do.** Don't retry — the token is gone. Distinct from
    ``RevokedError`` (which means "your access was specifically
    preempted"): ``InvalidToken`` means "the broker has no record of
    this id." Common causes: passing a stale lease across a
    reconnect, a typo in a hand-rolled id, or a lease that already
    completed its ``with``-block. Re-claim if you still need the
    resource."""


class BadRequestError(MgfHrbError):
    """The trust gate rejected the request at the wire (Part 12 §3.2):
    malformed, type-confused, oversized, out-of-range. The broker stayed up;
    your request didn't.

    **What to do.** This is almost always a CLIENT-SIDE bug. The SDK
    serializes correctly by construction, so seeing this in
    production code typically means a low-level caller bypassed the
    SDK and built a malformed wire request directly. Check: field
    types, field lengths (URN ≤ 128, value ≤ 64), deadline ≤ 1hr.
    The broker's audit ring (``audit_tail``) records the offending
    bytes — useful for reproducing."""


class TimeoutError(MgfHrbError):
    """A downward op exceeded its caller-supplied deadline (S2 / D-029). The
    resource is left untouched (atomicity holds — D-012); the broker may
    have quarantined it internally if its post-condition couldn't be
    verified.

    **What to do.** The resource is in a known-safe state (the
    atomicity contract guarantees this — no half-acquired ghost).
    Retry with a longer deadline if the device is just slow; if it
    persists, check ``inspect`` for a ``Quarantined`` state — the
    device is bad and needs a verified reset. The Tier-0 #3
    watchdog has already SIGKILL'd any wedged helper on the broker
    side; subsequent calls go through a fresh helper."""


class OverloadedError(MgfHrbError):
    """A per-tenant or aggregate bound was hit (S5). Back-pressure, not
    unbounded growth. Retry with backoff (EH-11) once the bound clears.

    **What to do.** Back off + retry — the broker will start
    accepting again once load drops. Don't busy-retry (you'll keep
    the broker pinned at its cap). Sources of ``Overloaded`` today:
    the per-tenant / aggregate live-lease ceiling (Tier-1 #9 —
    ``MAX_LEASES`` / ``MAX_LEASES_PER_UID``), the connection-budget
    cap (surfaced as the wire code ``TooBusy`` — aggregate
    ``MAX_ACTIVE_CLIENTS`` or per-tenant ``MAX_CONN_PER_UID``), and
    OOM on lease-table / revoked-id / predicate-AST growth. None are
    persistent: shed load and retry."""


class RevokedError(MgfHrbError):
    """The lease was revoked (policy, preemption, holder-death cleanup).
    Any further access on the associated raw channel is going to fault —
    revocation reaches below the fd (Part 12 §3.4).

    **What to do.** Your access was specifically preempted (distinct
    from ``InvalidTokenError`` — the lease existed, the broker took
    it). Decide:

    * **Voluntary release** — close the raw fd, drop the lease
      handle. The broker has already torn down the kernel side.
    * **Re-claim** — if you have authority + the cap still works,
      a fresh ``claim`` issues a NEW lease (the old one stays
      revoked forever). Honest re-acquisition, not "transparent
      retry" — the gap in access is visible by design (G2)."""


class UnavailableError(MgfHrbError):
    """Something exists but cannot serve you right now, and clearing it
    takes an explicit act. Two senses, from two different places:

    1. **Quarantined URN** (Part 2.7.1, raised by the BROKER) — a prior
       op timed out, a reset post-condition couldn't be verified, or the
       resource otherwise can't be safely granted to a new claimant.
    2. **Desynchronised transport** (SDK-10 / F-A1-01, raised CLIENT-side
       by the SDK, since 0.1.10) — a call on this ``Client`` blew its
       ``deadline_ms`` mid-exchange. The request was sent; the reply may
       still arrive; the wire carries no request ids, so the SDK cannot
       tell a late reply from the next call's. Rather than risk handing
       you the previous call's answer, every subsequent call on that
       connection is refused. The message names the desync.

    Read the message to tell them apart — the quarantine sense names a
    URN, the desync sense says "transport desynchronised".

    **What to do — the two senses need OPPOSITE actions.**

    *Quarantined URN.* Resolves only after a verified clean reset clears
    the quarantine; the same ``Client`` stays perfectly usable. The
    recovery path is the holder's or operator's policy choice — the
    broker just reports state honestly (G2). Options:

    * **Run the reset ladder** — ``Client.recover(urn)`` (cap-gated by
      ``steal``) escalates FLR → bus reset → quarantine, verifying each
      rung; it clears the quarantine on success.
    * **Issue a control-verb reset** — ``Client.control(urn, ...)`` if
      your cap carries the right; the broker's post-condition check
      clears quarantine on success.
    * **Operator manual recovery** — physically power-cycle or re-seat
      the device, then recover.
    * **Waiting does nothing** — quarantine doesn't time-decay; explicit
      action is required.

    *Desynchronised transport.* Do NOT retry on this ``Client`` — every
    call it makes from here on raises this error, so a retry loop spins
    forever. **Reconnect**: discard the client and open a new one::

        try:
            client.control(urn, "set_authorized", True)
        except UnavailableError:
            client.close()
            client = Client.connect(path, default_deadline_ms=10_000)

    ``recover()`` is the wrong tool here — nothing is quarantined, and
    the call would be refused like any other. The connection is what
    needs replacing (D-024: crash-only; a broken connection is discarded,
    never repaired in place)."""


# --- wire-code → exception class translation ------------------------------------

_WIRE_TO_EXC: dict[str, type[MgfHrbError]] = {
    "NotFound": NotFoundError,
    "NotPermitted": NotPermittedError,
    "Conflict": ConflictError,
    "Unsupported": UnsupportedError,
    "HardwareFault": HardwareFaultError,
    "InvalidToken": InvalidTokenError,
    "BadRequest": BadRequestError,
    "Timeout": TimeoutError,
    "Overloaded": OverloadedError,
    # Connection-budget back-pressure (aggregate `MAX_ACTIVE_CLIENTS` or
    # per-tenant `MAX_CONN_PER_UID`): the broker refused a new connection
    # rather than overcommit. Same family as `Overloaded` — a transient
    # "back off and retry" signal, not a permanent failure.
    "TooBusy": OverloadedError,
    "Revoked": RevokedError,
    "Unavailable": UnavailableError,
}


# O1 (review wave 3) — memoise unknown wire-err codes for log-flood
# protection. A buggy broker emitting thousands of unknown codes per
# second (or a hostile test fixture) would otherwise flood the SDK's
# WARNING log at one line per call. The first occurrence of each
# unknown code logs at WARNING; subsequent occurrences degrade
# silently to the typed MgfHrbError. This is intentionally per-process
# (not per-Client) because the log emit IS the operator-actionable
# signal — repeating it doesn't add information.
#
# The set is unbounded in principle (a hostile broker emitting random
# strings could grow it without limit), but each entry is a small
# string and the realistic worst case is "broker added 1-2 new Err
# variants" — handful of entries. We don't enforce a hard cap because
# the alternative (silently dropping the memo) would either re-trigger
# the flood OR silently lose the warning entirely; neither is right.
_warned_unknown_wire_codes: set[str] = set()


def from_wire(err_str: str) -> MgfHrbError:
    """Translate a broker-side ``Err`` code (the ``"err"`` field in a failed
    response) into the matching Python exception.

    Defensive shaping:
      * Non-string inputs (broker bug emitting ``"err": null`` or an
        integer) coerce to the literal ``"Unknown"`` so the call never
        raises a TypeError before the consumer's except-handler can run.
      * Unrecognised string codes fall back to ``MgfHrbError`` so a
        future Zig-side ``Err`` variant doesn't silently lose its
        typing — the consumer at least gets a typed ``MgfHrbError``.
        I3 (review wave 2): a WARNING log fires on first sight of each
        unrecognised code so version-skew between broker and SDK is
        visible. O1 (review wave 3): the WARNING is rate-limited to
        once-per-code-per-process so a buggy broker doesn't flood logs.
    """
    if not isinstance(err_str, str):
        # Defensive: M19 in the boundary audit flagged that broker bugs
        # could send a non-string `err` field. Coerce rather than raise.
        # Same once-per-occurrence discipline applies — log the raw
        # repr only once per process to avoid flood.
        if "<non-string>" not in _warned_unknown_wire_codes:
            _warned_unknown_wire_codes.add("<non-string>")
            logger.warning(
                "broker wire-err field was not a string: %r — coerced to 'Unknown' "
                "(further non-string err fields will degrade silently this process)",
                err_str,
            )
        err_str = "Unknown"
    cls = _WIRE_TO_EXC.get(err_str)
    if cls is None:
        # I3 + O1 — log only on the FIRST sight of each unknown code.
        # Sustained flow of the same code from a buggy broker no
        # longer drowns the log; net effect is "the operator gets
        # told once per unique skew event, then the SDK continues
        # gracefully."
        if err_str not in _warned_unknown_wire_codes:
            _warned_unknown_wire_codes.add(err_str)
            logger.warning(
                "unrecognised broker wire-err code %r — broker/SDK version "
                "skew, or a new Err variant the SDK hasn't been updated for. "
                "Falling back to generic MgfHrbError. Subsequent occurrences "
                "of this code will degrade silently this process.",
                err_str,
            )
        cls = MgfHrbError
    return cls(err_str)
