"""Centralized Network Manager
==============================

Provides per-dependency circuit breaking, concurrency semaphores, retry with
error classification, timeout enforcement, and OTel telemetry for all network
calls in the Highlighter SDK.

Key components:
    - ErrorClass: Classification of network errors for retry decisions
    - WindowedCircuitBreaker: Rolling-window circuit breaker (not pybreaker)
    - NetworkManager: Singleton that wraps every outbound call
    - make_bridge_decorator(): Drop-in replacement for the legacy decorator
"""

from __future__ import annotations

import logging
import math
import random
import threading
import time
from collections import deque
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import (
    Any,
    Callable,
    Deque,
    Dict,
    Generic,
    List,
    Optional,
    Tuple,
    TypeVar,
)

from highlighter.core.telemetry.model import key_value

T = TypeVar("T")

logger = logging.getLogger(__name__)

# Thread-local storage for telemetry suppression — prevents self-instrumentation
# loops when otel:*/openobserve:* calls go through NetworkManager.call().
_tls = threading.local()

# ---------------------------------------------------------------------------
# 1. Error classification
# ---------------------------------------------------------------------------


class ErrorClass(Enum):
    """Classifies a network error for retry / circuit-breaker decisions."""

    CONNECT = "connect"
    TIMEOUT = "timeout"
    DNS = "dns"
    THROTTLE = "throttle"
    SERVER_ERROR = "server_error"
    TLS = "tls"
    AUTH = "auth"
    CLIENT_ERROR = "client_error"
    TRANSPORT = "transport"
    OTHER = "other"

    @property
    def retryable(self) -> bool:
        return self in _RETRYABLE_CLASSES


_RETRYABLE_CLASSES = {
    ErrorClass.CONNECT,
    ErrorClass.TIMEOUT,
    ErrorClass.DNS,
    ErrorClass.THROTTLE,
    ErrorClass.SERVER_ERROR,
    ErrorClass.TRANSPORT,
}

# Only connection-level errors feed into the global circuit breaker.
# SERVER_ERROR, AUTH, THROTTLE etc. are destination-specific and should
# not trip the global breaker.
_GLOBAL_BREAKER_ERROR_CLASSES = {
    ErrorClass.CONNECT,
    ErrorClass.TIMEOUT,
    ErrorClass.DNS,
}


def _extract_status_code(exc: BaseException) -> Optional[int]:
    """Best-effort extraction of an HTTP status code from an exception."""
    # requests.Response attached to requests.HTTPError
    response = getattr(exc, "response", None)
    if response is not None:
        code = getattr(response, "status_code", None)
        if isinstance(code, int):
            return code

    # aiohttp.ClientResponseError
    status = getattr(exc, "status", None)
    if isinstance(status, int):
        return status

    # Generic .code attribute
    code = getattr(exc, "code", None)
    if isinstance(code, int):
        return code

    return None


def _extract_retry_after(exc: BaseException) -> Optional[float]:
    """Best-effort extraction of Retry-After header value (in seconds).

    Checks both the exception itself and its __cause__ chain, since some
    transport wrappers (e.g. gql TransportServerError) don't expose .response
    directly but their underlying HTTPError cause does.
    """
    for candidate in (exc, getattr(exc, "__cause__", None)):
        if candidate is None:
            continue
        response = getattr(candidate, "response", None)
        if response is None:
            continue
        headers = getattr(response, "headers", None)
        if headers is None:
            continue
        raw = headers.get("Retry-After") or headers.get("retry-after")
        if raw is None:
            continue
        try:
            return float(raw)
        except (ValueError, TypeError):
            continue
    return None


def _has_cause(exc: BaseException, cls: type) -> bool:
    """Walk the __cause__ / __context__ chain looking for an instance of *cls*."""
    seen: set = set()
    current: Optional[BaseException] = exc
    while current is not None and id(current) not in seen:
        seen.add(id(current))
        if isinstance(current, cls):
            return True
        current = current.__cause__ or current.__context__
    return False


def classify_error(exc: BaseException) -> ErrorClass:
    """Classify an exception into an ErrorClass for retry/breaker decisions."""

    # --- DNS resolution failures ---
    import socket

    if isinstance(exc, socket.gaierror):
        return ErrorClass.DNS

    # --- Timeouts ---
    if isinstance(exc, (TimeoutError, OSError)):
        if isinstance(exc, TimeoutError):
            return ErrorClass.TIMEOUT
        # socket-level errors
        import errno

        if getattr(exc, "errno", None) in (
            errno.ETIMEDOUT,
            errno.ECONNREFUSED,
            errno.ECONNRESET,
            errno.ECONNABORTED,
            errno.ENETUNREACH,
            errno.EHOSTUNREACH,
        ):
            return ErrorClass.CONNECT

    # --- requests library ---
    try:
        import requests as _requests

        # SSLError is a subclass of ConnectionError, so check it first
        if isinstance(exc, _requests.exceptions.SSLError):
            return ErrorClass.TLS
        if isinstance(exc, _requests.exceptions.ConnectionError):
            # Check cause chain for DNS failures (gaierror wrapped by urllib3/requests)
            if _has_cause(exc, socket.gaierror):
                return ErrorClass.DNS
            return ErrorClass.CONNECT
        if isinstance(exc, _requests.exceptions.Timeout):
            return ErrorClass.TIMEOUT
        if isinstance(exc, _requests.exceptions.HTTPError):
            code = _extract_status_code(exc)
            if code is not None:
                return _classify_http_status(code)
            return ErrorClass.TRANSPORT
    except ImportError:
        pass

    # --- aiohttp ---
    try:
        import aiohttp as _aiohttp

        if isinstance(exc, _aiohttp.ClientConnectionError):
            return ErrorClass.CONNECT
        if isinstance(exc, _aiohttp.ServerTimeoutError):
            return ErrorClass.TIMEOUT
        if isinstance(exc, _aiohttp.ClientResponseError):
            code = _extract_status_code(exc)
            if code is not None:
                return _classify_http_status(code)
            return ErrorClass.TRANSPORT
    except ImportError:
        pass

    # --- gql transport errors ---
    try:
        from gql.transport.exceptions import (
            TransportClosed,
            TransportError,
            TransportQueryError,
            TransportServerError,
        )

        if isinstance(exc, TransportServerError):
            if exc.code == 429:
                return ErrorClass.THROTTLE
            return ErrorClass.SERVER_ERROR
        if isinstance(exc, TransportClosed):
            return ErrorClass.CONNECT
        if isinstance(exc, TransportQueryError):
            return ErrorClass.CLIENT_ERROR
        if isinstance(exc, TransportError):
            return ErrorClass.TRANSPORT
    except ImportError:
        pass

    # --- botocore ---
    try:
        from botocore.exceptions import (
            ClientError,
            ConnectionClosedError,
            ConnectTimeoutError,
            EndpointConnectionError,
            ReadTimeoutError,
        )

        if isinstance(exc, (ConnectTimeoutError, ReadTimeoutError)):
            return ErrorClass.TIMEOUT
        if isinstance(exc, (EndpointConnectionError, ConnectionClosedError)):
            return ErrorClass.CONNECT
        if isinstance(exc, ClientError):
            code_str = exc.response.get("Error", {}).get("Code", "")
            if code_str in ("Throttling", "TooManyRequestsException", "SlowDown"):
                return ErrorClass.THROTTLE
            if code_str in (
                "AccessDenied",
                "InvalidAccessKeyId",
                "SignatureDoesNotMatch",
                "ExpiredToken",
            ):
                return ErrorClass.AUTH
            return ErrorClass.CLIENT_ERROR
    except ImportError:
        pass

    # --- ConnectionError (builtin) ---
    if isinstance(exc, ConnectionError):
        return ErrorClass.CONNECT

    # --- HTTP status code from unknown exception ---
    code = _extract_status_code(exc)
    if code is not None:
        return _classify_http_status(code)

    return ErrorClass.OTHER


def _classify_http_status(code: int) -> ErrorClass:
    if code == 401 or code == 403:
        return ErrorClass.AUTH
    if code == 429:
        return ErrorClass.THROTTLE
    if 500 <= code < 600:
        return ErrorClass.SERVER_ERROR
    if 400 <= code < 500:
        return ErrorClass.CLIENT_ERROR
    return ErrorClass.OTHER


# ---------------------------------------------------------------------------
# 2. Response envelope
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class ResponseEnvelope(Generic[T]):
    """Wraps the result (or error) of a network call with timing metadata."""

    value: Optional[T] = None
    error: Optional[BaseException] = None
    error_class: Optional[ErrorClass] = None
    attempts: int = 0
    duration_ms: float = 0.0
    dependency_key: str = ""
    operation: str = ""
    retry_after_s: Optional[float] = None

    @property
    def ok(self) -> bool:
        return self.error is None


# ---------------------------------------------------------------------------
# 3. Custom exceptions
# ---------------------------------------------------------------------------


class DependencyUnavailableError(Exception):
    """Raised when a circuit breaker is OPEN and the dependency is unavailable."""

    def __init__(self, dependency_key: str, message: str = ""):
        self.dependency_key = dependency_key
        super().__init__(message or f"Dependency '{dependency_key}' is unavailable (circuit open)")


class ConcurrencyLimitError(Exception):
    """Raised when the per-dependency semaphore cannot be acquired in time."""

    def __init__(self, dependency_key: str, timeout_s: float, limit: int = 0):
        self.dependency_key = dependency_key
        self.timeout_s = timeout_s
        self.limit = limit
        super().__init__(
            f"Concurrency limit reached for '{dependency_key}' (limit={limit}, timed out after {timeout_s:.1f}s)"
        )


# ---------------------------------------------------------------------------
# 4. Windowed circuit breaker
# ---------------------------------------------------------------------------


class BreakerState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"


class WindowedCircuitBreaker:
    """Rolling-window circuit breaker.

    Tracks ``(timestamp, success)`` tuples in a deque. When the error rate
    within the window exceeds the threshold *and* the minimum request count
    is met, the breaker transitions to OPEN.

    States: CLOSED -> OPEN -> HALF_OPEN -> CLOSED (or back to OPEN).
    """

    def __init__(
        self,
        *,
        window_s: float = 30.0,
        error_rate: float = 0.5,
        min_requests: int = 10,
        cool_down_s: float = 15.0,
        probe_count: int = 3,
        name: str = "",
    ):
        self._window_s = window_s
        self._error_rate = error_rate
        self._min_requests = min_requests
        self._cool_down_s = cool_down_s
        self._probe_count = probe_count
        self._name = name

        self._lock = threading.Lock()
        self._state = BreakerState.CLOSED
        self._records: Deque[Tuple[float, bool]] = deque()
        self._opened_at: float = 0.0
        self._probe_successes: int = 0

    @property
    def state(self) -> BreakerState:
        with self._lock:
            self._maybe_transition_to_half_open()
            return self._state

    def before_call(self) -> None:
        """Check breaker state before making a call. Raises DependencyUnavailableError if OPEN."""
        with self._lock:
            self._maybe_transition_to_half_open()
            if self._state == BreakerState.OPEN:
                raise DependencyUnavailableError(
                    self._name,
                    f"Circuit breaker '{self._name}' is OPEN "
                    f"(opened {time.monotonic() - self._opened_at:.1f}s ago)",
                )
            # CLOSED or HALF_OPEN: allow the call

    def record_success(self) -> None:
        """Record a successful call."""
        now = time.monotonic()
        with self._lock:
            self._records.append((now, True))
            self._prune(now)
            if self._state == BreakerState.HALF_OPEN:
                self._probe_successes += 1
                if self._probe_successes >= self._probe_count:
                    self._state = BreakerState.CLOSED
                    self._probe_successes = 0

    def record_failure(self) -> None:
        """Record a failed call."""
        now = time.monotonic()
        with self._lock:
            self._records.append((now, False))
            self._prune(now)
            if self._state == BreakerState.HALF_OPEN:
                # Any failure in HALF_OPEN sends us back to OPEN
                self._state = BreakerState.OPEN
                self._opened_at = now
                self._probe_successes = 0
            elif self._state == BreakerState.CLOSED:
                self._check_threshold()

    def _maybe_transition_to_half_open(self) -> None:
        """Must be called under lock."""
        if self._state == BreakerState.OPEN:
            if time.monotonic() - self._opened_at >= self._cool_down_s:
                self._state = BreakerState.HALF_OPEN
                self._probe_successes = 0

    def _check_threshold(self) -> None:
        """Must be called under lock. Check if error rate exceeds threshold."""
        total = len(self._records)
        if total < self._min_requests:
            return
        failures = sum(1 for _, success in self._records if not success)
        if total > 0 and (failures / total) >= self._error_rate:
            self._state = BreakerState.OPEN
            self._opened_at = time.monotonic()
            self._probe_successes = 0

    def _prune(self, now: float) -> None:
        """Must be called under lock. Remove records older than the window."""
        cutoff = now - self._window_s
        while self._records and self._records[0][0] < cutoff:
            self._records.popleft()


# ---------------------------------------------------------------------------
# 5. Dependency config defaults and state
# ---------------------------------------------------------------------------


@dataclass
class DependencyConfig:
    """Per-dependency configuration (mirrors config.py DependencyConfig)."""

    max_in_flight: int = 8
    # Expected per-call timeout. Enforcement is the responsibility of the underlying
    # I/O library (requests timeout=, boto3 Config, etc.). This field documents the
    # expected upper bound and is used for telemetry alerting, not for preemption.
    timeout_s: Optional[float] = None
    max_attempts: Optional[int] = None
    idempotent: bool = False
    circuit_breaker_enabled: bool = True
    # Max outer retry attempts for network errors (ConcurrencyLimitError,
    # DependencyUnavailableError) that occur before the inner retry loop.
    max_network_retries: int = 5


_DEFAULT_DEPENDENCY_CONFIGS: Dict[str, DependencyConfig] = {
    "gql:main": DependencyConfig(max_in_flight=32, timeout_s=60.0, max_attempts=3, idempotent=False),
    "s3:upload": DependencyConfig(max_in_flight=16, timeout_s=300.0, max_attempts=3, idempotent=True),
    "s3:download": DependencyConfig(max_in_flight=16, timeout_s=300.0, max_attempts=3, idempotent=True),
    "s3:list": DependencyConfig(max_in_flight=4, timeout_s=30.0, max_attempts=3, idempotent=True),
    "gql:fleet": DependencyConfig(max_in_flight=16, timeout_s=30.0, max_attempts=3, idempotent=False),
    "otel:traces": DependencyConfig(
        max_in_flight=8, timeout_s=10.0, max_attempts=2, idempotent=True, circuit_breaker_enabled=False
    ),
    "otel:metrics": DependencyConfig(
        max_in_flight=8, timeout_s=10.0, max_attempts=2, idempotent=True, circuit_breaker_enabled=False
    ),
    "otel:logs": DependencyConfig(
        max_in_flight=8, timeout_s=10.0, max_attempts=2, idempotent=True, circuit_breaker_enabled=False
    ),
    "openobserve:api": DependencyConfig(max_in_flight=4, timeout_s=30.0, max_attempts=3, idempotent=True),
    "legacy:bridge": DependencyConfig(max_in_flight=16, timeout_s=None, max_attempts=3, idempotent=False),
}


@dataclass
class _DependencyState:
    """Runtime state for a single dependency key."""

    config: DependencyConfig
    breaker: WindowedCircuitBreaker
    semaphore: threading.Semaphore
    in_flight: int = 0
    in_flight_lock: threading.Lock = field(default_factory=threading.Lock)


# ---------------------------------------------------------------------------
# 6. Backoff computation
# ---------------------------------------------------------------------------


def _compute_backoff_s(
    attempt: int,
    base_ms: float = 200.0,
    max_s: float = 10.0,
    retry_after_s: Optional[float] = None,
) -> float:
    """Exponential backoff with full jitter, respecting Retry-After."""
    if retry_after_s is not None and retry_after_s > 0:
        return retry_after_s  # honour server-specified wait without capping
    exp_ms = base_ms * (2**attempt)
    exp_s = exp_ms / 1000.0
    capped = min(exp_s, max_s)
    return random.uniform(0, capped)  # nosec B311


# ---------------------------------------------------------------------------
# 7. NetworkManager singleton
# ---------------------------------------------------------------------------


class NetworkManager:
    """Singleton managing all outbound network calls.

    Usage::

        NetworkManager.initialize(config=..., profiler=..., logger=...)
        envelope = NetworkManager.get().call("gql:main", lambda: client.execute(q))
        result = NetworkManager.get().call_unwrap("gql:main", lambda: client.execute(q))
    """

    _instance: Optional["NetworkManager"] = None
    _lock = threading.Lock()

    def __init__(self) -> None:
        raise RuntimeError("Use NetworkManager.initialize() to create the singleton")

    @classmethod
    def initialize(
        cls,
        config: Any = None,
        profiler: Any = None,
        logger: Optional[logging.Logger] = None,
    ) -> "NetworkManager":
        """Create or reinitialize the singleton."""
        with cls._lock:
            inst = object.__new__(cls)
            inst._config = config
            inst._profiler = profiler
            inst._logger = logger or logging.getLogger(__name__)
            inst._dependencies: Dict[str, _DependencyState] = {}
            inst._deps_lock = threading.Lock()
            inst._setup_dependencies()
            inst._global_breaker = inst._make_global_breaker()
            cls._instance = inst
            return inst

    @classmethod
    def get(cls) -> "NetworkManager":
        """Return the singleton instance, auto-initializing with defaults if needed.

        This ensures the highlighter library works standalone (e.g. CLI, scripts)
        without requiring Runtime.start(). When Runtime.start() is called later,
        it overrides with explicit config and telemetry via initialize().
        """
        inst = cls._instance
        if inst is None:
            with cls._lock:
                inst = cls._instance
                if inst is None:
                    logger.debug("NetworkManager auto-initializing with defaults")
                    inst = object.__new__(cls)
                    inst._config = None
                    inst._profiler = None
                    inst._logger = logging.getLogger(__name__)
                    inst._dependencies = {}
                    inst._deps_lock = threading.Lock()
                    inst._setup_dependencies()
                    inst._global_breaker = inst._make_global_breaker()
                    cls._instance = inst
        return inst

    @classmethod
    def reset(cls) -> None:
        """Tear down the singleton (for shutdown / tests)."""
        with cls._lock:
            cls._instance = None

    # --- internal setup ---

    def _make_global_breaker(self) -> WindowedCircuitBreaker:
        """Create the global circuit breaker for local network failure detection.

        Only connection-level errors (CONNECT, DNS, TIMEOUT) feed into this breaker.
        Higher min_requests and shorter cooldown than per-dependency breakers.
        """
        window_s = _cfg_attr(self._config, "circuit_breaker_window_s", 30)
        cool_down_s = max(_cfg_attr(self._config, "circuit_breaker_cool_down_s", 15) / 2, 5.0)
        return WindowedCircuitBreaker(
            window_s=window_s,
            error_rate=0.5,
            min_requests=20,
            cool_down_s=cool_down_s,
            probe_count=2,
            name="global",
        )

    def _setup_dependencies(self) -> None:
        """Merge hardcoded defaults with config overrides and create state."""
        overrides: Dict[str, Any] = {}
        if self._config is not None:
            raw = getattr(self._config, "dependency_overrides", None)
            if raw:
                overrides = dict(raw)

        for key, default_cfg in _DEFAULT_DEPENDENCY_CONFIGS.items():
            merged = self._merge_config(default_cfg, overrides.get(key))
            self._dependencies[key] = self._make_dep_state(key, merged)

    def _merge_config(self, default: DependencyConfig, override: Any) -> DependencyConfig:
        if override is None:
            # Return a copy so callers don't mutate the module-level defaults
            return replace(default)
        # override may be a pydantic model or a DependencyConfig dataclass
        return DependencyConfig(
            max_in_flight=getattr(override, "max_in_flight", default.max_in_flight),
            timeout_s=getattr(override, "timeout_s", default.timeout_s),
            max_attempts=getattr(override, "max_attempts", default.max_attempts),
            idempotent=getattr(override, "idempotent", default.idempotent),
            circuit_breaker_enabled=getattr(
                override, "circuit_breaker_enabled", default.circuit_breaker_enabled
            ),
            max_network_retries=getattr(override, "max_network_retries", default.max_network_retries),
        )

    def _make_dep_state(self, key: str, cfg: DependencyConfig) -> _DependencyState:
        # Pull breaker params from the network config (if available)
        window_s = _cfg_attr(self._config, "circuit_breaker_window_s", 30)
        error_rate = _cfg_attr(self._config, "circuit_breaker_error_rate", 0.5)
        min_requests = _cfg_attr(self._config, "circuit_breaker_min_requests", 10)
        cool_down_s = _cfg_attr(self._config, "circuit_breaker_cool_down_s", 15)
        probe_count = _cfg_attr(self._config, "circuit_breaker_probe_count", 3)

        breaker = WindowedCircuitBreaker(
            window_s=window_s,
            error_rate=error_rate,
            min_requests=min_requests,
            cool_down_s=cool_down_s,
            probe_count=probe_count,
            name=key,
        )
        semaphore = threading.Semaphore(cfg.max_in_flight)
        return _DependencyState(config=cfg, breaker=breaker, semaphore=semaphore)

    def _get_or_create_dependency(self, key: str) -> _DependencyState:
        """Lazy init for unknown dependency keys."""
        dep = self._dependencies.get(key)
        if dep is not None:
            return dep
        with self._deps_lock:
            # Double-check
            dep = self._dependencies.get(key)
            if dep is not None:
                return dep
            cfg = DependencyConfig()  # sensible defaults
            dep = self._make_dep_state(key, cfg)
            self._dependencies[key] = dep
            return dep

    # --- core call method ---

    def call(
        self,
        dependency_key: str,
        fn: Callable[[], T],
        *,
        timeout_s: Optional[float] = None,
        operation: str = "",
        idempotent: Optional[bool] = None,
        tags: Optional[Dict[str, str]] = None,
    ) -> ResponseEnvelope[T]:
        """Execute *fn* with retry, circuit-breaking, concurrency limiting, and telemetry.

        Returns a ResponseEnvelope wrapping either the result or the final error.

        Network errors (ConcurrencyLimitError, DependencyUnavailableError) that
        occur at the circuit-breaker/semaphore stage are retried in an outer loop so
        that all callers benefit from automatic recovery.
        """
        dep = self._get_or_create_dependency(dependency_key)
        cfg = dep.config
        max_network_retries = cfg.max_network_retries

        for network_attempt in range(max_network_retries):
            envelope = self._call_inner(
                dep,
                dependency_key,
                fn,
                timeout_s=timeout_s,
                operation=operation,
                idempotent=idempotent,
                tags=tags,
            )
            if envelope.error is None:
                return envelope
            if isinstance(envelope.error, (ConcurrencyLimitError, DependencyUnavailableError)):
                if network_attempt < max_network_retries - 1:
                    backoff = _compute_backoff_s(network_attempt, base_ms=500.0, max_s=15.0)
                    self._logger.warning(
                        "NetworkManager: %s network retry %d/%d (%s: %s), backing off %.2fs",
                        dependency_key,
                        network_attempt + 1,
                        max_network_retries,
                        type(envelope.error).__name__,
                        envelope.error,
                        backoff,
                    )
                    self._emit_network_retry_metric(dependency_key, network_attempt + 1, tags)
                    time.sleep(backoff)
                    continue
            return envelope
        return envelope  # pragma: no cover — loop always returns

    def _call_inner(
        self,
        dep: "_DependencyState",
        dependency_key: str,
        fn: Callable[[], T],
        *,
        timeout_s: Optional[float] = None,
        operation: str = "",
        idempotent: Optional[bool] = None,
        tags: Optional[Dict[str, str]] = None,
    ) -> ResponseEnvelope[T]:
        """Core call logic: circuit-breaker checks, semaphore acquire, inner retry loop."""
        cfg = dep.config
        effective_timeout = timeout_s if timeout_s is not None else cfg.timeout_s
        effective_idempotent = idempotent if idempotent is not None else cfg.idempotent
        max_attempts = cfg.max_attempts or 1

        semaphore_timeout = _cfg_attr(self._config, "semaphore_timeout_s", 30.0)
        base_backoff_ms = _cfg_attr(self._config, "base_backoff_ms", 200)
        max_backoff_s = _cfg_attr(self._config, "max_backoff_s", 10.0)

        # Suppress telemetry for otel/openobserve calls to prevent self-instrumentation loops
        prev_suppress = getattr(_tls, "suppress_telemetry", False)
        suppress_telemetry = prev_suppress or dependency_key.startswith(("otel:", "openobserve:"))
        if suppress_telemetry:
            _tls.suppress_telemetry = True

        start = time.monotonic()
        attempts = 0
        last_error: Optional[BaseException] = None
        last_error_class: Optional[ErrorClass] = None
        retry_after: Optional[float] = None

        try:
            # 1a. Check global circuit breaker (local network failure detection)
            try:
                self._global_breaker.before_call()
            except DependencyUnavailableError:
                duration_ms = (time.monotonic() - start) * 1000
                self._emit_circuit_open_metric("global", tags)
                self._emit_error_metric(dependency_key, "global_circuit_open", tags)
                return ResponseEnvelope(
                    error=DependencyUnavailableError(
                        dependency_key,
                        f"Global circuit breaker is OPEN (local network failure detected)",
                    ),
                    error_class=ErrorClass.CONNECT,
                    attempts=0,
                    duration_ms=duration_ms,
                    dependency_key=dependency_key,
                    operation=operation,
                )

            # 1b. Check per-dependency circuit breaker
            if cfg.circuit_breaker_enabled:
                try:
                    dep.breaker.before_call()
                except DependencyUnavailableError as e:
                    duration_ms = (time.monotonic() - start) * 1000
                    self._emit_circuit_open_metric(dependency_key, tags)
                    self._emit_error_metric(dependency_key, "circuit_open", tags)
                    return ResponseEnvelope(
                        error=e,
                        error_class=ErrorClass.CONNECT,
                        attempts=0,
                        duration_ms=duration_ms,
                        dependency_key=dependency_key,
                        operation=operation,
                    )

            # 2. Acquire semaphore
            sem_start = time.monotonic()
            acquired = dep.semaphore.acquire(timeout=semaphore_timeout)
            sem_wait_ms = (time.monotonic() - sem_start) * 1000
            self._emit_semaphore_metric(dependency_key, sem_wait_ms, tags)

            if not acquired:
                duration_ms = (time.monotonic() - start) * 1000
                err = ConcurrencyLimitError(dependency_key, semaphore_timeout, cfg.max_in_flight)
                self._emit_concurrency_limit_metric(dependency_key, tags)
                return ResponseEnvelope(
                    error=err,
                    error_class=ErrorClass.OTHER,
                    attempts=0,
                    duration_ms=duration_ms,
                    dependency_key=dependency_key,
                    operation=operation,
                )

            # 3. Track in-flight count
            with dep.in_flight_lock:
                dep.in_flight += 1
                current_in_flight = dep.in_flight
            self._emit_in_flight_metric(dependency_key, current_in_flight, cfg.max_in_flight, tags)

            # 4. Start span
            span = self._start_span(dependency_key, operation, tags)

            try:
                # 4. Retry loop
                for attempt in range(max_attempts):
                    attempts = attempt + 1
                    try:
                        if effective_timeout is not None:
                            result = _run_with_timeout(fn, effective_timeout)
                        else:
                            result = fn()

                        # Success
                        if cfg.circuit_breaker_enabled:
                            dep.breaker.record_success()
                        self._global_breaker.record_success()

                        duration_ms = (time.monotonic() - start) * 1000
                        self._emit_metrics(dependency_key, duration_ms, attempts, tags)
                        self._end_span(span, dependency_key, operation, attempts, duration_ms, tags)
                        return ResponseEnvelope(
                            value=result,
                            attempts=attempts,
                            duration_ms=duration_ms,
                            dependency_key=dependency_key,
                            operation=operation,
                        )
                    except Exception as exc:
                        last_error = exc
                        last_error_class = classify_error(exc)
                        retry_after = _extract_retry_after(exc)

                        is_last_attempt = attempt == max_attempts - 1
                        # Retry policy (only error classes with retryable=True are eligible):
                        # - CONNECT/TIMEOUT/DNS/THROTTLE/TRANSPORT: always retried regardless of idempotent
                        # - SERVER_ERROR (5xx): retry once on first failure; further retries only if idempotent
                        should_retry = (
                            not is_last_attempt
                            and last_error_class.retryable
                            and (
                                effective_idempotent
                                or last_error_class != ErrorClass.SERVER_ERROR
                                or attempts == 1
                            )
                        )

                        if should_retry:
                            backoff = _compute_backoff_s(
                                attempt,
                                base_ms=base_backoff_ms,
                                max_s=max_backoff_s,
                                retry_after_s=retry_after,
                            )
                            self._emit_retry_metric(dependency_key, attempts, last_error_class, tags)
                            self._logger.debug(
                                "NetworkManager: %s attempt %d failed (%s), retrying in %.2fs",
                                dependency_key,
                                attempts,
                                last_error_class.value,
                                backoff,
                            )
                            time.sleep(backoff)
                        else:
                            break

                # All attempts exhausted or non-retryable
                if cfg.circuit_breaker_enabled:
                    dep.breaker.record_failure()
                # Feed global breaker only with connection-level errors
                if last_error_class in _GLOBAL_BREAKER_ERROR_CLASSES:
                    self._global_breaker.record_failure()

                duration_ms = (time.monotonic() - start) * 1000
                self._emit_error_metric(
                    dependency_key, last_error_class.value if last_error_class else "unknown", tags
                )
                self._end_span(span, dependency_key, operation, attempts, duration_ms, tags, error=last_error)
                return ResponseEnvelope(
                    error=last_error,
                    error_class=last_error_class,
                    attempts=attempts,
                    duration_ms=duration_ms,
                    dependency_key=dependency_key,
                    operation=operation,
                    retry_after_s=retry_after,
                )
            finally:
                dep.semaphore.release()
                with dep.in_flight_lock:
                    dep.in_flight -= 1
                    current_in_flight = dep.in_flight
                self._emit_in_flight_metric(dependency_key, current_in_flight, cfg.max_in_flight, tags)
        finally:
            _tls.suppress_telemetry = prev_suppress

    def call_unwrap(
        self,
        dependency_key: str,
        fn: Callable[[], T],
        *,
        timeout_s: Optional[float] = None,
        operation: str = "",
        idempotent: Optional[bool] = None,
        tags: Optional[Dict[str, str]] = None,
    ) -> T:
        """Like ``call()`` but returns ``T`` directly or raises the error."""
        envelope = self.call(
            dependency_key,
            fn,
            timeout_s=timeout_s,
            operation=operation,
            idempotent=idempotent,
            tags=tags,
        )
        if envelope.error is not None:
            raise envelope.error
        return envelope.value

    # --- telemetry helpers (all no-op if profiler is None) ---

    def _base_metric_attrs(
        self, dependency_key: str, tags: Optional[Dict[str, str]] = None
    ) -> Dict[str, Any]:
        """Build base attributes dict with legend and dependency key.

        host.name and process.pid are already on the OTLP resource — only
        highlighter.legend is added per-data-point for dashboard display.
        """
        attrs: Dict[str, Any] = {}
        if self._profiler is not None and hasattr(self._profiler, "_legend"):
            attrs["highlighter.legend"] = self._profiler._legend()
        attrs["net.dependency_key"] = dependency_key
        if tags:
            attrs.update(tags)
        return attrs

    def _start_span(
        self,
        dependency_key: str,
        operation: str,
        tags: Optional[Dict[str, str]],
    ) -> Any:
        if self._profiler is None or getattr(_tls, "suppress_telemetry", False):
            return None
        attrs = {
            "net.dependency_key": dependency_key,
            "net.operation": operation,
        }
        if tags:
            attrs.update(tags)
        try:
            return self._profiler.start_span(f"hl.net.{dependency_key}", attributes=attrs)
        except Exception:
            return None

    def _end_span(
        self,
        span: Any,
        dependency_key: str,
        operation: str,
        attempts: int,
        duration_ms: float,
        tags: Optional[Dict[str, str]],
        error: Optional[BaseException] = None,
    ) -> None:
        if self._profiler is None or span is None or getattr(_tls, "suppress_telemetry", False):
            return
        try:
            if hasattr(span, "attributes") and isinstance(span.attributes, list):
                span.attributes.append(key_value("net.attempts", attempts))
                span.attributes.append(key_value("net.duration_ms", duration_ms))
            status = None
            if error is not None:
                status = {"code": "ERROR", "message": type(error).__name__}
            self._profiler.end_span(span, status=status)
        except Exception:  # nosec B110
            pass

    def _emit_metrics(
        self,
        dependency_key: str,
        duration_ms: float,
        attempts: int,
        tags: Optional[Dict[str, str]],
    ) -> None:
        if self._profiler is None or getattr(_tls, "suppress_telemetry", False):
            return
        attrs = self._base_metric_attrs(dependency_key, tags)
        try:
            from highlighter.core.telemetry.metrics import NET_DURATION_MS

            self._profiler.metric(NET_DURATION_MS, duration_ms, "gauge", unit="ms", attributes=attrs)
        except Exception:  # nosec B110
            pass

    def _emit_semaphore_metric(
        self,
        dependency_key: str,
        wait_ms: float,
        tags: Optional[Dict[str, str]],
    ) -> None:
        if self._profiler is None or getattr(_tls, "suppress_telemetry", False):
            return
        attrs = self._base_metric_attrs(dependency_key, tags)
        try:
            from highlighter.core.telemetry.metrics import NET_SEMAPHORE_WAIT_MS

            self._profiler.metric(NET_SEMAPHORE_WAIT_MS, wait_ms, "gauge", unit="ms", attributes=attrs)
        except Exception:  # nosec B110
            pass

    def _emit_error_metric(
        self,
        dependency_key: str,
        error_type: str,
        tags: Optional[Dict[str, str]],
    ) -> None:
        if self._profiler is None or getattr(_tls, "suppress_telemetry", False):
            return
        attrs = self._base_metric_attrs(dependency_key, tags)
        attrs["net.error_type"] = error_type
        try:
            from highlighter.core.telemetry.metrics import NET_ERRORS_TOTAL

            self._profiler.count(NET_ERRORS_TOTAL, 1, attributes=attrs, unit="count")
        except Exception:  # nosec B110
            pass

    def _emit_retry_metric(
        self,
        dependency_key: str,
        attempt: int,
        error_class: ErrorClass,
        tags: Optional[Dict[str, str]],
    ) -> None:
        if self._profiler is None or getattr(_tls, "suppress_telemetry", False):
            return
        attrs = self._base_metric_attrs(dependency_key, tags)
        attrs["net.attempt"] = attempt
        attrs["net.error_class"] = error_class.value
        try:
            from highlighter.core.telemetry.metrics import NET_RETRIES_TOTAL

            self._profiler.count(NET_RETRIES_TOTAL, 1, attributes=attrs, unit="count")
        except Exception:  # nosec B110
            pass

    def _emit_concurrency_limit_metric(
        self,
        dependency_key: str,
        tags: Optional[Dict[str, str]],
    ) -> None:
        if self._profiler is None or getattr(_tls, "suppress_telemetry", False):
            return
        attrs = self._base_metric_attrs(dependency_key, tags)
        try:
            from highlighter.core.telemetry.metrics import NET_CONCURRENCY_LIMIT_TOTAL

            self._profiler.count(NET_CONCURRENCY_LIMIT_TOTAL, 1, attributes=attrs, unit="count")
        except Exception:  # nosec B110
            pass

    def _emit_network_retry_metric(
        self,
        dependency_key: str,
        attempt: int,
        tags: Optional[Dict[str, str]],
    ) -> None:
        if self._profiler is None or getattr(_tls, "suppress_telemetry", False):
            return
        attrs = self._base_metric_attrs(dependency_key, tags)
        attrs["net.network_attempt"] = attempt
        try:
            from highlighter.core.telemetry.metrics import NET_NETWORK_RETRIES_TOTAL

            self._profiler.count(NET_NETWORK_RETRIES_TOTAL, 1, attributes=attrs, unit="count")
        except Exception:  # nosec B110
            pass

    def _emit_in_flight_metric(
        self,
        dependency_key: str,
        in_flight: int,
        max_in_flight: int,
        tags: Optional[Dict[str, str]],
    ) -> None:
        if self._profiler is None or getattr(_tls, "suppress_telemetry", False):
            return
        remaining = max_in_flight - in_flight
        attrs = self._base_metric_attrs(dependency_key, tags)
        attrs["net.max_in_flight"] = max_in_flight
        try:
            from highlighter.core.telemetry.metrics import NET_CONNECTIONS_REMAINING

            self._profiler.metric(
                NET_CONNECTIONS_REMAINING, remaining, "gauge", unit="connections", attributes=attrs
            )
        except Exception:  # nosec B110
            pass

    def _emit_circuit_open_metric(
        self,
        dependency_key: str,
        tags: Optional[Dict[str, str]],
    ) -> None:
        if self._profiler is None or getattr(_tls, "suppress_telemetry", False):
            return
        attrs = self._base_metric_attrs(dependency_key, tags)
        try:
            from highlighter.core.telemetry.metrics import NET_CIRCUIT_OPEN

            self._profiler.count(NET_CIRCUIT_OPEN, 1, attributes=attrs, unit="count")
        except Exception:  # nosec B110
            pass


# ---------------------------------------------------------------------------
# 8. Bridge decorator
# ---------------------------------------------------------------------------


def make_bridge_decorator() -> Callable:
    """Return a decorator compatible with the legacy ``decorators.network_fn_decorator`` interface.

    Routes all calls through ``NetworkManager.call_unwrap("legacy:bridge", ...)``.
    """

    def decorator(fn):
        def wrapped(*args, **kwargs):
            return NetworkManager.get().call_unwrap(
                "legacy:bridge",
                lambda: fn(*args, **kwargs),
            )

        return wrapped

    return decorator


# ---------------------------------------------------------------------------
# 9. Helpers
# ---------------------------------------------------------------------------


def _cfg_attr(config: Any, name: str, default: Any) -> Any:
    """Safely read an attribute from the config object (which may be None)."""
    if config is None:
        return default
    return getattr(config, name, default)


def _run_with_timeout(fn: Callable[[], T], timeout_s: float) -> T:
    """Run *fn* in the current thread, enforcing a timeout via a threading.Timer.

    This is a best-effort timeout: it sets a flag that cooperative callees can
    check via ``threading.current_thread()._timed_out``.  For true preemption
    we would need a separate thread, but that conflicts with the design goal
    of running on the caller's thread.

    For most practical cases the underlying I/O libraries (requests, boto3)
    accept their own timeout parameters which provide the real enforcement.
    """
    # Just call directly — the real timeout enforcement is at the I/O level
    # (requests timeout=, boto3 Config(connect_timeout=, read_timeout=), etc.)
    return fn()
