"""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):
    """The URN is currently quarantined (Part 2.7.1) — 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.

    **What to do.** Resolves only after a verified clean reset
    clears the quarantine. The recovery path is the holder's or
    operator's policy choice — the broker just reports state
    honestly (G2). Options:

    * **Issue a reset** — call ``Client.control(urn, RESET, ...)``
      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; the operator then calls ``reset`` via
      the operator CLI.
    * **Wait for retry timing** — quarantine doesn't time-decay;
      explicit action is required."""


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