"""
NetworkManager — central orchestrator for transport-agnostic networking.

Manages transport plugins, peer resolution, health monitoring, UPnP,
peer ping-based IP detection, and connectivity upgrades.

Depends on: config, models, identity, state, network/transport
"""

import asyncio
import ipaddress
import os
import random
import sys
import time
from collections import deque
from datetime import datetime, timezone
from typing import Callable, Optional
from urllib.parse import urlparse

import httpx

from darkmatter.config import (
    PEER_LOOKUP_TIMEOUT,
    PEER_LOOKUP_MAX_CONCURRENT,
    HEALTH_CHECK_INTERVAL,
    HEALTH_FAILURE_THRESHOLD,
    STALE_CONNECTION_AGE,
    UPNP_PORT_RANGE,
    TRUST_NEGATIVE_TIMEOUT,
    CONNECTIVITY_UPGRADE_INTERVAL,
    PING_INTERVAL,
    PING_IP_WINDOW,
    PING_SILENCE_THRESHOLD,
    BOOTSTRAP_PEERS,
    BOOTSTRAP_MODE,
    BOOTSTRAP_RECONNECT_INTERVAL,
    BOOTSTRAP_RECONNECT_MAX,
)
from darkmatter.security import sign_peer_update
from darkmatter.network.transport import Transport, SendResult
from darkmatter.network.transports.http import strip_base_url
from darkmatter.logging import get_logger

_log = get_logger("manager")


# =============================================================================
# Global accessor (mirrors state.py pattern)
# =============================================================================

_network_manager: Optional["NetworkManager"] = None


def get_network_manager() -> "NetworkManager":
    """Get the global NetworkManager instance."""
    if _network_manager is None:
        raise RuntimeError("NetworkManager not initialized — call set_network_manager() first")
    return _network_manager


def set_network_manager(mgr: "NetworkManager") -> None:
    """Set the global NetworkManager instance."""
    global _network_manager
    _network_manager = mgr


# =============================================================================
# UPnP helpers (kept as module-level functions, called by manager)
# =============================================================================

def try_upnp_mapping(local_port: int) -> Optional[tuple]:
    """Try to create a UPnP port mapping. Returns (url, upnp_obj, ext_port) or None."""
    import random
    try:
        import miniupnpc
        upnp = miniupnpc.UPnP()
        upnp.discoverdelay = 2000
        devices = upnp.discover()
        if devices == 0:
            return None
        upnp.selectigd()
        external_ip = upnp.externalipaddress()
        if not external_ip:
            return None

        for _ in range(5):
            ext_port = random.randint(*UPNP_PORT_RANGE)
            try:
                upnp.addportmapping(
                    ext_port, "TCP", upnp.lanaddr, local_port,
                    "DarkMatter mesh", ""
                )
                url = f"http://{external_ip}:{ext_port}"
                return (url, upnp, ext_port)
            except Exception as e:
                _log.warning("UPnP port mapping attempt failed (port %s): %s", ext_port, e)
                continue

        return None
    except Exception as e:
        _log.error("UPnP mapping failed: %s", e)
        return None


# =============================================================================
# URL locality check
# =============================================================================

def is_local_url(url: str) -> bool:
    """Check if a URL points to a local/private address (localhost, LAN, etc.)."""
    try:
        parsed = urlparse(url)
        host = parsed.hostname or ""
        if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
            return True
        addr = ipaddress.ip_address(host)
        return addr.is_private or addr.is_loopback
    except (ValueError, TypeError):
        return False


# =============================================================================
# NetworkManager
# =============================================================================

class NetworkManager:
    """Central orchestrator for transport-agnostic networking.

    Manages transport plugins, peer resolution, health monitoring,
    NAT detection, UPnP, and peer URL recovery.
    """

    def __init__(self, state_getter: Callable, state_saver: Callable):
        self._get_state = state_getter
        self._save_state = state_saver
        self._transports: list[Transport] = []
        self._tasks: list[asyncio.Task] = []
        # Peer ping state
        self._observed_ips: deque = deque()  # (timestamp, ip) tuples
        self._last_known_ip: Optional[str] = None
        self._last_inbound_ping: float = 0.0

    # -- Transport registry --

    def register_transport(self, transport: Transport) -> None:
        """Register a transport plugin. Sorted by priority (lower = tried first)."""
        self._transports.append(transport)
        self._transports.sort(key=lambda t: t.priority)

    def get_transport(self, name: str) -> Optional[Transport]:
        """Get a registered transport by name."""
        for t in self._transports:
            if t.name == name:
                return t
        return None

    # -- Core API --

    async def send(self, agent_id: str, path: str, payload: dict) -> SendResult:
        """Send a message to a peer, trying transports in priority order.

        Searches across all hosted agents' connections to find the peer.
        Tries each available transport. On first success, resets health_failures
        and returns. On all failures, returns aggregate error.
        """
        from darkmatter.state import list_hosted_agents, get_state_for

        # Search for connection across all hosted agents
        conn = None
        state = self._get_state()
        if state:
            conn = state.connections.get(agent_id)
        if conn is None:
            for aid in list_hosted_agents():
                s = get_state_for(aid)
                if s and agent_id in s.connections:
                    conn = s.connections[agent_id]
                    break
        if conn is None:
            return SendResult(success=False, transport_name="none",
                              error=f"No connection to agent {agent_id[:12]}...")

        errors = []
        for transport in self._transports:
            if not transport.available:
                continue
            result = await transport.send(conn, path, payload)
            if result.success:
                conn.health_failures = 0
                return result
            errors.append(f"{transport.name}: {result.error}")

        return SendResult(
            success=False,
            transport_name="none",
            error="; ".join(errors) if errors else "No transports available",
        )

    def preferred_transport_for(self, agent_id: str):
        """Return the first currently-usable transport for a peer, or None."""
        state = self._get_state()
        conn = state.connections.get(agent_id) if state else None
        if conn is None:
            return None
        for transport in self._transports:
            if transport.available:
                return transport
        return None


    def peers(self) -> dict:
        """Return all current connections across all hosted agents."""
        from darkmatter.state import list_hosted_agents, get_state_for

        all_conns = {}
        for aid in list_hosted_agents():
            s = get_state_for(aid)
            if s:
                all_conns.update(s.connections)
        # Fallback for single-agent
        if not all_conns:
            state = self._get_state()
            if state:
                return state.connections
        return all_conns

    # -- HTTP request helper --

    async def http_request(
        self, url: str, method: str = "GET", timeout: float = 10.0, **kwargs
    ) -> "httpx.Response":
        """Make an HTTP request (used by antimatter and other subsystems)."""
        async with httpx.AsyncClient(timeout=timeout) as client:
            return await getattr(client, method.lower())(url, **kwargs)

    # -- Peer resolution --

    def get_public_url(self, agent_id: str = None) -> str:
        """Get the public URL for an agent.

        If agent_id is provided, returns an agent-scoped URL
        (e.g. http://host:port/__darkmatter__/{agent_id}) so remote peers
        can route back to the correct local agent.

        If agent_id is None, returns the base URL.
        """
        state = self._get_state()
        base_url = None
        if state is not None and state.public_url:
            base_url = state.public_url
        if not base_url:
            base_url = os.environ.get("DARKMATTER_PUBLIC_URL", "").rstrip("/")
        if not base_url:
            port = state.port if state else 8100
            base_url = f"http://localhost:{port}"

        if agent_id:
            return f"{base_url}/__darkmatter__/{agent_id}"
        return base_url

    async def discover_public_url(self) -> str:
        """Discover the best public URL for this agent.

        Priority: env var > UPnP > ping-observed IP > localhost fallback.
        If UPnP succeeds, stores the mapping on state._upnp_mapping
        so cleanup can remove it on shutdown.
        """
        state = self._get_state()
        port = state.port if state else 8100

        env_url = os.environ.get("DARKMATTER_PUBLIC_URL", "").rstrip("/")
        if env_url:
            _log.info("Public URL (env): %s", env_url)
            return env_url

        result = await asyncio.to_thread(try_upnp_mapping, port)
        if result is not None:
            url, upnp_obj, ext_port = result
            if state is not None:
                state._upnp_mapping = (url, upnp_obj, ext_port)
            _log.info("Public URL (UPnP): %s", url)
            return url

        # Use ping-observed IP if available
        if self._last_known_ip:
            url = f"http://{self._last_known_ip}:{port}"
            _log.info("Public URL (ping): %s", url)
            return url

        url = f"http://localhost:{port}"
        _log.info("Public URL (fallback): %s", url)
        return url

    async def broadcast_peer_update(self, agent_id: str = None) -> None:
        """Notify all connected peers of our current URL, bio, and display name.

        If agent_id is specified, broadcast for that agent only.
        Otherwise broadcast for all hosted agents.

        Local peers receive our LAN URL (so they can reach us directly) while
        remote peers receive our public URL.
        """
        from darkmatter.state import list_hosted_agents, get_state_for

        if agent_id:
            agent_ids = [agent_id]
        else:
            agent_ids = list_hosted_agents()

        for aid in agent_ids:
            state = get_state_for(aid) if aid != (self._get_state() or object()).agent_id else self._get_state()
            if state is None:
                state = get_state_for(aid)
            if state is None:
                continue

            base_url = state.public_url or f"http://127.0.0.1:{state.port}"
            from darkmatter.network.discovery import _get_lan_ip
            lan_ip = _get_lan_ip()
            lan_base = f"http://{lan_ip}:{state.port}" if lan_ip != "localhost" else f"http://localhost:{state.port}"

            # Build transport address map
            addresses = {}
            for t in self._transports:
                if t.available:
                    addr = t.get_address(state)
                    if addr:
                        addresses[t.name] = addr

            def _build_payload(url_for_peer: str, _state=state) -> dict:
                timestamp = datetime.now(timezone.utc).isoformat()
                p = {
                    "agent_id": _state.agent_id,
                    "new_url": url_for_peer,
                    "addresses": addresses,
                    "timestamp": timestamp,
                    "bio": _state.bio,
                    "display_name": _state.display_name,
                }
                if _state.public_key_hex:
                    p["public_key_hex"] = _state.public_key_hex
                if _state.private_key_hex and _state.public_key_hex:
                    p["signature"] = sign_peer_update(
                        _state.private_key_hex, _state.agent_id, url_for_peer, timestamp
                    )
                return p

            for conn in list(state.connections.values()):
                try:
                    # Local peers get our LAN URL; remote peers get the public URL
                    peer_url = lan_base if is_local_url(conn.agent_url) else base_url
                    payload = _build_payload(peer_url)
                    result = await self.send(
                        conn.agent_id, "/__darkmatter__/peer_update", payload)
                    if not result.success:
                        _log.warning("Failed to notify %s... of URL change: %s", conn.agent_id[:12], result.error)
                except Exception as e:
                    _log.warning("Failed to notify %s... of URL change: %s", conn.agent_id[:12], e)

    async def lookup_peer_url(self, target_agent_id: str,
                              exclude_urls: Optional[set[str]] = None) -> Optional[str]:
        """Find an agent's current URL via peer consensus lookup."""
        if exclude_urls is None:
            exclude_urls = set()

        return await self._peer_consensus_lookup(target_agent_id, exclude_urls)

    async def _peer_consensus_lookup(self, target_agent_id: str,
                                      exclude_urls: set[str]) -> Optional[str]:
        """Fan out to connected peers, collect ALL responses, pick URL by trust-weighted consensus."""
        state = self._get_state()
        peers = [c for c in state.connections.values() if c.agent_id != target_agent_id]
        if not peers:
            return None

        peers = peers[:PEER_LOOKUP_MAX_CONCURRENT]

        async def _query(conn):
            try:
                base = strip_base_url(conn.agent_url)
                async with httpx.AsyncClient(timeout=PEER_LOOKUP_TIMEOUT) as client:
                    resp = await client.get(f"{base}/__darkmatter__/peer_lookup/{target_agent_id}")
                    if resp.status_code == 200:
                        data = resp.json()
                        url = data.get("url")
                        if url and url not in exclude_urls:
                            return (conn.agent_id, url)
            except Exception:
                pass
            return None

        results = await asyncio.gather(*[_query(p) for p in peers], return_exceptions=True)
        responses = [r for r in results if r is not None and not isinstance(r, Exception)]

        if not responses:
            return None

        # Group by URL, weight by trust
        url_scores: dict[str, float] = {}
        for peer_id, url in responses:
            imp = state.impressions.get(peer_id)
            if not imp:
                _log.warning("Peer lookup: no impression for %s\u2026, using default trust 0.5", peer_id[:12])
            weight = imp.score if imp else 0.5  # Default trust for unscored peers
            weight = max(weight, 0.1)  # Floor so even low-trust peers count
            url_scores[url] = url_scores.get(url, 0.0) + weight

        return max(url_scores, key=url_scores.get)

    # -- Lifecycle --

    async def start(self) -> None:
        """Start all transports, discover public URL, start background tasks."""
        state = self._get_state()

        # Start transports
        for transport in self._transports:
            if transport.available:
                await transport.start(state)

        # Wire up HttpTransport with peer lookup and public URL getter
        http_transport = self.get_transport("http")
        if http_transport is not None:
            http_transport._lookup_peer_url = self.lookup_peer_url
            http_transport._save_state = self._save_state
            http_transport._get_public_url = self.get_public_url

        # Discover public URL
        state.public_url = await self.discover_public_url()

        # Start background tasks
        self._tasks.append(asyncio.create_task(self._health_loop()))
        self._tasks.append(asyncio.create_task(self._connectivity_upgrade_loop()))
        self._tasks.append(asyncio.create_task(self._ping_loop()))
        self._tasks.append(asyncio.create_task(self._bootstrap_loop()))
        _log.info("Network health loop: ENABLED (%ss interval)", HEALTH_CHECK_INTERVAL)
        _log.info("Connectivity upgrade loop: ENABLED (%ss interval)", CONNECTIVITY_UPGRADE_INTERVAL)
        _log.info("Peer ping loop: ENABLED (%ss interval)", PING_INTERVAL)
        if not BOOTSTRAP_MODE and BOOTSTRAP_PEERS:
            _log.info("Bootstrap loop: ENABLED (peers: %s)", ", ".join(BOOTSTRAP_PEERS))
        elif BOOTSTRAP_MODE:
            _log.info("Bootstrap mode: ACTIVE (this node is a bootstrap peer)")
        _log.info("UPnP: AVAILABLE")

    async def stop(self) -> None:
        """Cancel background tasks, stop transports, cleanup UPnP."""
        for task in self._tasks:
            task.cancel()
            try:
                await task
            except asyncio.CancelledError:
                pass
        self._tasks.clear()

        for transport in self._transports:
            await transport.stop()

        # UPnP cleanup
        state = self._get_state()
        if state is not None and getattr(state, '_upnp_mapping', None) is not None:
            url, upnp_obj, ext_port = state._upnp_mapping
            try:
                upnp_obj.deleteportmapping(ext_port, "TCP")
                _log.info("UPnP mapping removed (port %s)", ext_port)
            except Exception as e:
                _log.error("UPnP cleanup failed: %s", e)
            state._upnp_mapping = None

    # -- Background loops (internal) --

    async def _health_loop(self) -> None:
        """Periodically check connection health."""
        while True:
            try:
                await asyncio.sleep(HEALTH_CHECK_INTERVAL)

                # Connection health checks
                await self._check_connection_health()

                # WebRTC dead channel cleanup
                await self._cleanup_dead_webrtc()

                # Trust-based auto-disconnect
                await self._check_trust_disconnects()

                # Insight cache cleanup
                self._prune_stale_insights()

                # Update connectivity levels on all connections
                self.update_connectivity_levels()

            except asyncio.CancelledError:
                return
            except Exception as e:
                _log.error("Health loop error: %s", e)

    async def _check_connection_health(self) -> None:
        """Check health of all stale connections across all hosted agents."""
        from darkmatter.state import list_hosted_agents, get_state_for

        all_conns = []
        for aid in list_hosted_agents():
            s = get_state_for(aid)
            if s:
                for conn in s.connections.values():
                    all_conns.append((s, conn))

        # Fallback for single-agent mode
        if not all_conns:
            state = self._get_state()
            if state:
                all_conns = [(state, c) for c in state.connections.values()]

        for state, conn in all_conns:
            # Skip recently active connections
            if conn.last_activity:
                try:
                    last = datetime.fromisoformat(conn.last_activity.replace("Z", "+00:00"))
                    age = (datetime.now(timezone.utc) - last).total_seconds()
                    if age < STALE_CONNECTION_AGE:
                        continue
                except Exception:
                    pass

            # Check reachability via all transports
            reachable = False
            for transport in self._transports:
                if not transport.available:
                    continue
                if await transport.is_reachable(conn):
                    reachable = True
                    conn.health_failures = 0
                    # Try to upgrade to a better transport
                    if conn.transport == "http":
                        webrtc = self.get_transport("webrtc")
                        if webrtc and webrtc.available:
                            asyncio.create_task(webrtc.upgrade(state, conn))
                    break

            if not reachable:
                conn.health_failures += 1
                if conn.health_failures >= HEALTH_FAILURE_THRESHOLD:
                    _log.warning(
                        "Connection %s... unhealthy (%s failures, url=%s)",
                        conn.agent_id[:12], conn.health_failures, conn.agent_url,
                    )

    async def _cleanup_dead_webrtc(self) -> None:
        """Detect dead WebRTC channels, clean up, and downgrade to HTTP.

        The connectivity upgrade loop will re-attempt WebRTC later.
        """
        from darkmatter.state import list_hosted_agents, get_state_for

        webrtc = self.get_transport("webrtc")
        if not webrtc:
            return

        for aid in list_hosted_agents():
            s = get_state_for(aid)
            if s is None:
                continue
            for conn in list(s.connections.values()):
                if conn.webrtc_pc is None:
                    continue
                pc_state = getattr(conn.webrtc_pc, "connectionState", "unknown")
                if pc_state in ("failed", "closed", "disconnected"):
                    peer = conn.agent_display_name or conn.agent_id[:12]
                    _log.info("WebRTC dead channel (%s) for %s, downgrading to HTTP",
                              pc_state, peer)
                    await webrtc.cleanup(conn)

    async def _check_trust_disconnects(self) -> None:
        """Auto-disconnect peers with sustained negative trust scores across all hosted agents."""
        from darkmatter.wallet.antimatter import auto_disconnect_peer
        from darkmatter.state import save_state, list_hosted_agents, get_state_for

        now = datetime.now(timezone.utc)

        for aid in list_hosted_agents():
            state = get_state_for(aid)
            if state is None:
                continue

            to_disconnect = []
            for agent_id, imp in list(state.impressions.items()):
                if agent_id not in state.connections:
                    continue
                if not imp.negative_since:
                    continue
                try:
                    neg_dt = datetime.fromisoformat(imp.negative_since)
                    elapsed = (now - neg_dt).total_seconds()
                    if elapsed >= TRUST_NEGATIVE_TIMEOUT:
                        to_disconnect.append(agent_id)
                except (ValueError, TypeError):
                    continue

            for agent_id in to_disconnect:
                try:
                    if await auto_disconnect_peer(state, agent_id):
                        save_state(agent_id=aid)
                except Exception as e:
                    _log.error("Trust disconnect failed for %s...: %s", agent_id[:16], e)

    def _prune_stale_insights(self) -> None:
        """Remove cached peer insights from disconnected peers or older than INSIGHT_CACHE_TTL."""
        from darkmatter.config import INSIGHT_CACHE_TTL
        from darkmatter.state import save_state, list_hosted_agents, get_state_for

        now = datetime.now(timezone.utc)

        for aid in list_hosted_agents():
            state = get_state_for(aid)
            if state is None:
                continue

            keep = []
            pruned = 0

            for insight in state.insights:
                if insight.author_agent_id == state.agent_id:
                    keep.append(insight)
                    continue
                if insight.author_agent_id not in state.connections:
                    pruned += 1
                    continue
                try:
                    updated = datetime.fromisoformat(insight.updated_at.replace("Z", "+00:00"))
                    age = (now - updated).total_seconds()
                    if age > INSIGHT_CACHE_TTL:
                        pruned += 1
                        continue
                except Exception:
                    pass
                keep.append(insight)

            if pruned:
                state.insights = keep
                save_state(agent_id=aid)
                _log.info("Pruned %s stale peer insight(s) for agent %s...", pruned, aid[:12])

    # -- Connectivity level --

    def determine_connectivity_level(self, conn) -> tuple[int, str]:
        """Determine the connectivity level for a connection.

        Returns (level, method_label):
            1 = direct (HTTP or WebRTC with direct signaling)
            2 = LAN WebRTC (WebRTC via LAN multicast signaling)
            3 = Peer-relayed WebRTC (WebRTC via mutual peer SDP relay)
            0 = unknown
        """
        signaling = getattr(conn, "_signaling_method", "")

        if conn.transport == "webrtc" and conn.webrtc_channel is not None:
            ready = getattr(conn.webrtc_channel, "readyState", None)
            if ready == "open":
                if signaling == "lan":
                    return 2, "lan-webrtc"
                elif signaling == "peer_relay":
                    return 3, "peer-relay"
                else:
                    return 1, "direct"

        if conn.transport == "http":
            return 1, "direct"

        return 0, "unknown"

    def update_connectivity_levels(self) -> None:
        """Update connectivity_level and connectivity_method on all connections across all agents."""
        from darkmatter.state import list_hosted_agents, get_state_for

        for aid in list_hosted_agents():
            state = get_state_for(aid)
            if state is None:
                continue
            for conn in state.connections.values():
                level, method = self.determine_connectivity_level(conn)
                conn.connectivity_level = level
                conn.connectivity_method = method

    # -- Connectivity upgrade loop --

    async def _connectivity_upgrade_loop(self) -> None:
        """Periodically try to upgrade connections to better connectivity levels across all agents."""
        while True:
            try:
                await asyncio.sleep(CONNECTIVITY_UPGRADE_INTERVAL)
                from darkmatter.state import list_hosted_agents, get_state_for

                webrtc = self.get_transport("webrtc")
                if not webrtc or not webrtc.available:
                    continue

                for aid in list_hosted_agents():
                    state = get_state_for(aid)
                    if state is None:
                        continue

                    for conn in list(state.connections.values()):
                        level, _ = self.determine_connectivity_level(conn)

                        if level <= 2:
                            continue

                        if conn.agent_id in state.discovered_peers:
                            from darkmatter.network.transports.webrtc import LANSignaling
                            success = await webrtc.upgrade(state, conn, LANSignaling())
                            if success:
                                conn._signaling_method = "lan"
                                lvl, meth = self.determine_connectivity_level(conn)
                                conn.connectivity_level = lvl
                                conn.connectivity_method = meth
                                peer = conn.agent_display_name or conn.agent_id[:12]
                                _log.info("Upgraded %s to L%s:%s", peer, lvl, meth)
                                continue

                        if level > 3 and len(state.connections) > 1:
                            from darkmatter.network.transports.webrtc import PeerRelaySignaling
                            success = await webrtc.upgrade(state, conn, PeerRelaySignaling())
                            if success:
                                conn._signaling_method = "peer_relay"
                                lvl, meth = self.determine_connectivity_level(conn)
                                conn.connectivity_level = lvl
                                conn.connectivity_method = meth
                                peer = conn.agent_display_name or conn.agent_id[:12]
                                _log.info("Upgraded %s to L%s:%s", peer, lvl, meth)

            except asyncio.CancelledError:
                return
            except Exception as e:
                _log.error("Connectivity upgrade loop error: %s", e)

    # -- Relay polling (SDP + messages) --

    # -- Peer Ping Loop --

    async def _ping_loop(self) -> None:
        """Periodically ping a random connected peer to detect IP changes.

        Each peer's ping response includes `your_ip` — the IP they see us on.
        We track these observations in a sliding window and broadcast a peer
        update if the majority IP changes.
        """
        while True:
            try:
                await asyncio.sleep(PING_INTERVAL)

                # Collect all connections across all hosted agents
                from darkmatter.state import list_hosted_agents, get_state_for
                all_conns = []
                for aid in list_hosted_agents():
                    s = get_state_for(aid)
                    if s:
                        all_conns.extend(s.connections.values())

                # Fallback
                if not all_conns:
                    state = self._get_state()
                    if state and state.connections:
                        all_conns = list(state.connections.values())

                if not all_conns:
                    continue

                conn = random.choice(all_conns)
                state = self._get_state()
                base_url = strip_base_url(conn.agent_url)

                try:
                    async with httpx.AsyncClient(timeout=5.0) as client:
                        resp = await client.post(
                            f"{base_url}/__darkmatter__/ping",
                            json={
                                "agent_id": state.agent_id,
                                "timestamp": datetime.now(timezone.utc).isoformat(),
                            },
                        )
                        if resp.status_code == 200:
                            data = resp.json()
                            observed_ip = data.get("your_ip")
                            if observed_ip and observed_ip != "unknown":
                                now = time.time()
                                self._observed_ips.append((now, observed_ip))

                                # Prune old observations
                                cutoff = now - PING_IP_WINDOW
                                while self._observed_ips and self._observed_ips[0][0] < cutoff:
                                    self._observed_ips.popleft()

                                # Check for majority IP
                                majority_ip = self._get_majority_ip()
                                if majority_ip and majority_ip != self._last_known_ip:
                                    old_ip = self._last_known_ip
                                    self._last_known_ip = majority_ip
                                    if old_ip is not None:
                                        _log.info("Public IP changed (ping): %s -> %s", old_ip, majority_ip)
                                        state.public_url = await self.discover_public_url()
                                        await self.broadcast_peer_update()
                                    else:
                                        _log.info("Public IP detected (ping): %s", majority_ip)

                            # Update last_activity on the connection
                            conn.last_activity = datetime.now(timezone.utc).isoformat()
                except Exception:
                    pass  # Best-effort — don't spam errors for pings

                # Check for inbound ping silence
                if (self._last_inbound_ping > 0 and
                        time.time() - self._last_inbound_ping > PING_SILENCE_THRESHOLD and
                        len(state.connections) > 0):
                    # No peers have pinged us in a while — possible network issue
                    pass  # Future: could trigger re-discovery

            except asyncio.CancelledError:
                return
            except Exception as e:
                _log.error("Ping loop error: %s", e)

    def _get_majority_ip(self) -> Optional[str]:
        """Return the most frequently observed IP in the sliding window, if it's a majority."""
        if not self._observed_ips:
            return None
        counts: dict[str, int] = {}
        for _, ip in self._observed_ips:
            counts[ip] = counts.get(ip, 0) + 1
        best_ip = max(counts, key=counts.get)
        # Require majority (>50% of observations)
        if counts[best_ip] > len(self._observed_ips) / 2:
            return best_ip
        return None

    # -- Bootstrap loop --

    async def _bootstrap_loop(self) -> None:
        """Maintain connections to bootstrap peers with exponential backoff.

        Skips if this node is in bootstrap mode (to avoid connecting to itself)
        or if no bootstrap peers are configured.
        """
        if BOOTSTRAP_MODE or not BOOTSTRAP_PEERS:
            return

        backoff = {url: BOOTSTRAP_RECONNECT_INTERVAL for url in BOOTSTRAP_PEERS}

        while True:
            try:
                await asyncio.sleep(min(backoff.values()))

                state = self._get_state()
                if state is None:
                    continue

                # Collect all connected peer URLs across all hosted agents
                from darkmatter.state import list_hosted_agents, get_state_for
                connected_urls = set()
                for aid in list_hosted_agents():
                    s = get_state_for(aid)
                    if s:
                        for conn in s.connections.values():
                            connected_urls.add(strip_base_url(conn.agent_url))
                # Fallback for single-agent
                for conn in state.connections.values():
                    connected_urls.add(strip_base_url(conn.agent_url))

                for bootstrap_url in BOOTSTRAP_PEERS:
                    base = bootstrap_url.rstrip("/")

                    # Skip if already connected to this bootstrap peer
                    if base in connected_urls:
                        backoff[bootstrap_url] = BOOTSTRAP_RECONNECT_INTERVAL
                        continue

                    try:
                        from darkmatter.network.mesh import (
                            build_outbound_request_payload,
                            build_connection_from_accepted,
                        )

                        public_url = self.get_public_url(agent_id=state.agent_id)
                        payload = build_outbound_request_payload(state, public_url)

                        async with httpx.AsyncClient(timeout=15.0) as client:
                            resp = await client.post(
                                f"{base}/__darkmatter__/connection_request",
                                json=payload,
                            )

                        if resp.status_code == 200:
                            result_data = resp.json()
                            if result_data.get("auto_accepted"):
                                peer_id = result_data["agent_id"]
                                if peer_id not in state.connections:
                                    conn = build_connection_from_accepted(result_data)
                                    state.connections[peer_id] = conn
                                    self._save_state()
                                    _log.info(
                                        "Bootstrap: connected to %s (%s)",
                                        result_data.get("agent_display_name", peer_id[:12]),
                                        bootstrap_url,
                                    )
                                backoff[bootstrap_url] = BOOTSTRAP_RECONNECT_INTERVAL
                            else:
                                _log.info("Bootstrap: connection request pending at %s", bootstrap_url)
                                backoff[bootstrap_url] = min(
                                    backoff[bootstrap_url] * 2, BOOTSTRAP_RECONNECT_MAX
                                )
                        else:
                            _log.warning(
                                "Bootstrap: %s returned %d", bootstrap_url, resp.status_code
                            )
                            backoff[bootstrap_url] = min(
                                backoff[bootstrap_url] * 2, BOOTSTRAP_RECONNECT_MAX
                            )

                    except Exception as e:
                        _log.warning("Bootstrap: failed to connect to %s: %s", bootstrap_url, e)
                        backoff[bootstrap_url] = min(
                            backoff[bootstrap_url] * 2, BOOTSTRAP_RECONNECT_MAX
                        )

            except asyncio.CancelledError:
                return
            except Exception as e:
                _log.error("Bootstrap loop error: %s", e)
