# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Licensed under the MIT License

"""
Mesh Model Bridge — Model Sharing Across Peers

Enables mesh peers to advertise, browse, and transfer .familiar-model
packages. Two modes:
  - "download": chunked transfer of model packages
  - "delegate": remote inference via mesh

All transfers respect trust permissions (share_models) and are
disabled under HIPAA mode.

Integration:
  Initialized by MeshGateway.init_model_bridge().
  Routes mesh.model.* events from _handle_mesh_event().
"""

from __future__ import annotations

import base64
import hashlib
import json
import logging
import threading
import time
import zipfile
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

# Topic constants
MESH_MODEL_ADVERTISE = "mesh.model.advertise"
MESH_MODEL_CATALOG_REQUEST = "mesh.model.catalog_request"
MESH_MODEL_CATALOG_RESPONSE = "mesh.model.catalog_response"
MESH_MODEL_DOWNLOAD_REQUEST = "mesh.model.download_request"
MESH_MODEL_DOWNLOAD_CHUNK = "mesh.model.download_chunk"
MESH_MODEL_DOWNLOAD_COMPLETE = "mesh.model.download_complete"
MESH_MODEL_INFER_REQUEST = "mesh.model.infer_request"
MESH_MODEL_INFER_RESPONSE = "mesh.model.infer_response"

# Transfer settings
CHUNK_SIZE = 256 * 1024  # 256KB raw
MAX_MODEL_SIZE = 4 * 1024 * 1024 * 1024  # 4GB


def _utcnow() -> datetime:
    return datetime.now(timezone.utc)


# ============================================================
# MODEL ENTRY
# ============================================================


@dataclass
class ModelEntry:
    """Metadata for a locally registered model package."""

    model_id: str  # SHA-256 of package file
    name: str
    package_path: str  # absolute path to .familiar-model
    package_size: int  # bytes
    genesis_hash: str  # creator's genesis hash
    provenance: dict = field(default_factory=dict)
    shareable: bool = False
    share_scope: str = "all"  # "all" or comma-separated node_ids
    created_at: str = ""
    tags: list = field(default_factory=list)
    backend: str = ""  # "unsloth"|"peft"|"ollama"
    base_model: str = ""  # e.g. "qwen2.5:3b"

    def __post_init__(self):
        if not self.created_at:
            self.created_at = _utcnow().isoformat()

    def catalog_entry(self) -> dict:
        """Subset for sharing (no local paths)."""
        return {
            "model_id": self.model_id,
            "name": self.name,
            "package_size": self.package_size,
            "genesis_hash": self.genesis_hash,
            "tags": self.tags,
            "backend": self.backend,
            "base_model": self.base_model,
            "created_at": self.created_at,
        }

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, d: dict) -> "ModelEntry":
        known = {f.name for f in cls.__dataclass_fields__.values()}
        return cls(**{k: v for k, v in d.items() if k in known})


# ============================================================
# MODEL REGISTRY — persistence
# ============================================================


class ModelRegistry:
    """
    Persists ModelEntry records to disk.
    Thread-safe. Stored in MESH_DIR/model_registry.json.
    """

    def __init__(self, storage_path: Path):
        self._path = storage_path / "model_registry.json"
        self._lock = threading.Lock()
        self._models: Dict[str, ModelEntry] = {}
        self._load()

    def _load(self):
        if self._path.exists():
            try:
                data = json.loads(self._path.read_text())
                for entry in data.get("models", []):
                    model = ModelEntry.from_dict(entry)
                    self._models[model.model_id] = model
            except (json.JSONDecodeError, IOError, OSError) as e:
                logger.warning(f"Failed to load model registry: {e}")

    def _save(self):
        try:
            self._path.parent.mkdir(parents=True, exist_ok=True)
            data = {"models": [m.to_dict() for m in self._models.values()]}
            self._path.write_text(json.dumps(data, indent=2))
        except (IOError, OSError) as e:
            logger.warning(f"Failed to save model registry: {e}")

    def register(self, entry: ModelEntry):
        with self._lock:
            self._models[entry.model_id] = entry
            self._save()

    def unregister(self, model_id: str) -> bool:
        with self._lock:
            if model_id in self._models:
                del self._models[model_id]
                self._save()
                return True
            return False

    def get(self, model_id: str) -> Optional[ModelEntry]:
        with self._lock:
            return self._models.get(model_id)

    def get_shareable(self, for_node: Optional[str] = None) -> list:
        """Get models marked as shareable, optionally filtered by node scope."""
        with self._lock:
            results = []
            for m in self._models.values():
                if not m.shareable:
                    continue
                if for_node and m.share_scope != "all":
                    if for_node not in m.share_scope.split(","):
                        continue
                results.append(m)
            return results

    def get_all(self) -> list:
        with self._lock:
            return list(self._models.values())

    def share(self, model_id: str, scope: str = "all") -> bool:
        with self._lock:
            m = self._models.get(model_id)
            if m:
                m.shareable = True
                m.share_scope = scope
                self._save()
                return True
            return False

    def unshare(self, model_id: str) -> bool:
        with self._lock:
            m = self._models.get(model_id)
            if m:
                m.shareable = False
                self._save()
                return True
            return False

    def scan_packages_dir(self) -> int:
        """Auto-discover .familiar-model files in the models directory."""
        models_dir = self._path.parent / "models"
        if not models_dir.exists():
            return 0

        found = 0
        for pkg_path in models_dir.glob("*.familiar-model"):
            try:
                pkg_hash = hashlib.sha256(pkg_path.read_bytes()).hexdigest()
                if pkg_hash in self._models:
                    continue

                # Try to read metadata from the package
                name = pkg_path.stem
                provenance = {}
                backend = ""
                base_model = ""
                genesis_hash = ""

                if zipfile.is_zipfile(pkg_path):
                    with zipfile.ZipFile(pkg_path, "r") as zf:
                        if "metadata.json" in zf.namelist():
                            meta = json.loads(zf.read("metadata.json"))
                            name = meta.get("name", name)
                            provenance = meta.get("provenance", {})
                            backend = meta.get("backend", "")
                            base_model = meta.get("base_model", "")
                            genesis_hash = meta.get("genesis_hash", "")

                entry = ModelEntry(
                    model_id=pkg_hash,
                    name=name,
                    package_path=str(pkg_path),
                    package_size=pkg_path.stat().st_size,
                    genesis_hash=genesis_hash,
                    provenance=provenance,
                    backend=backend,
                    base_model=base_model,
                )
                self.register(entry)
                found += 1
            except Exception as e:
                logger.debug(f"Failed to scan {pkg_path}: {e}")

        return found


# ============================================================
# MODEL BRIDGE — the main class
# ============================================================


class ModelBridge:
    """
    Bridges local model registry with mesh peers.

    Handles:
      - Advertising available models to peers
      - Catalog browsing (list peer models)
      - Chunked download of model packages
      - Delegated inference (remote model execution)
    """

    def __init__(
        self,
        node_id: str,
        node_name: str,
        mesh_dir: Path,
        trust_manager: Any = None,
        hipaa_enabled: bool = False,
    ):
        self.node_id = node_id
        self.node_name = node_name
        self.trust_manager = trust_manager
        self.hipaa_enabled = hipaa_enabled

        self.registry = ModelRegistry(mesh_dir)
        self._mesh_dir = mesh_dir

        # Gateway reference (set externally)
        self._gateway = None

        # Usage tracker (set via set_usage_tracker)
        self._usage_tracker = None

        # Peer catalogs cache: {peer_id: [catalog_entry, ...]}
        self._peer_catalogs: Dict[str, list] = {}
        self._catalog_pending: Dict[str, Any] = {}
        self._catalog_events: Dict[str, threading.Event] = {}

        # Download state: {transfer_id: {chunks, file_path, ...}}
        self._downloads: Dict[str, dict] = {}
        self._download_events: Dict[str, threading.Event] = {}

        # Inference state: {query_id: response}
        self._infer_pending: Dict[str, Any] = {}
        self._infer_events: Dict[str, threading.Event] = {}

        self._lock = threading.Lock()

    def set_gateway(self, gateway):
        """Set reference to MeshGateway for sending messages."""
        self._gateway = gateway

    def set_usage_tracker(self, tracker):
        """Set the usage tracker for Dross integration."""
        self._usage_tracker = tracker

    # ── Advertising ──

    def advertise_models(self, peer_id: Optional[str] = None):
        """Advertise shareable models to a specific peer or all peers."""
        if self.hipaa_enabled or not self._gateway:
            return

        shareable = self.registry.get_shareable(for_node=peer_id)
        if not shareable:
            return

        payload = {
            "origin_node": self.node_id,
            "origin_name": self.node_name,
            "models": [m.catalog_entry() for m in shareable],
        }

        if peer_id:
            self._send_to_peer(peer_id, MESH_MODEL_ADVERTISE, payload)
        else:
            for pid in self._get_model_peers():
                try:
                    self._send_to_peer(pid, MESH_MODEL_ADVERTISE, payload)
                except Exception as e:
                    logger.debug(f"Failed to advertise to {pid}: {e}")

    def handle_advertise(self, message: dict):
        """Handle model advertisement from a peer."""
        origin_node = message.get("origin_node", "")
        models = message.get("models", [])
        with self._lock:
            self._peer_catalogs[origin_node] = models
        logger.debug(f"Received {len(models)} model ads from {origin_node[:8]}")

    # ── Catalog ──

    def request_catalog(self, peer_id: str, timeout: float = 5.0) -> list:
        """Request the model catalog from a peer."""
        if self.hipaa_enabled or not self._gateway:
            return []

        query_id = hashlib.sha256(f"catalog:{peer_id}:{time.time()}".encode()).hexdigest()[:16]

        with self._lock:
            self._catalog_pending[query_id] = None
            self._catalog_events[query_id] = threading.Event()

        self._send_to_peer(
            peer_id,
            MESH_MODEL_CATALOG_REQUEST,
            {
                "requesting_node": self.node_id,
                "query_id": query_id,
            },
        )

        event = self._catalog_events[query_id]
        event.wait(timeout=timeout)

        with self._lock:
            result = self._catalog_pending.pop(query_id, None)
            self._catalog_events.pop(query_id, None)

        if result and isinstance(result, dict):
            models = result.get("models", [])
            origin = result.get("origin_node", peer_id)
            self._peer_catalogs[origin] = models
            return models
        return []

    def handle_catalog_request(self, message: dict) -> Optional[dict]:
        """Handle a catalog request from a peer."""
        if self.hipaa_enabled:
            return None

        requesting_node = message.get("requesting_node", "")
        if self.trust_manager and not self.trust_manager.check_permission(
            requesting_node, "share_models"
        ):
            return None

        query_id = message.get("query_id", "")
        shareable = self.registry.get_shareable(for_node=requesting_node)

        return {
            "query_id": query_id,
            "origin_node": self.node_id,
            "origin_name": self.node_name,
            "models": [m.catalog_entry() for m in shareable],
        }

    def handle_catalog_response(self, message: dict):
        """Handle a catalog response from a peer."""
        query_id = message.get("query_id", "")
        with self._lock:
            if query_id in self._catalog_pending:
                self._catalog_pending[query_id] = message
                event = self._catalog_events.get(query_id)
                if event:
                    event.set()

    def get_peer_catalogs(self) -> dict:
        """Get cached peer catalogs."""
        with self._lock:
            return dict(self._peer_catalogs)

    # ── Download (chunked transfer) ──

    def request_download(
        self, peer_id: str, model_id: str, timeout: float = 300.0
    ) -> Optional[Path]:
        """
        Download a model package from a peer via chunked transfer.

        Returns path to downloaded package, or None on failure.
        """
        if self.hipaa_enabled or not self._gateway:
            return None

        transfer_id = hashlib.sha256(f"dl:{peer_id}:{model_id}:{time.time()}".encode()).hexdigest()[
            :16
        ]

        # Prepare download directory
        dl_dir = self._mesh_dir / "downloads"
        dl_dir.mkdir(parents=True, exist_ok=True)
        dl_path = dl_dir / f"{transfer_id}.familiar-model"

        with self._lock:
            self._downloads[transfer_id] = {
                "model_id": model_id,
                "peer_id": peer_id,
                "chunks": {},
                "total_chunks": None,
                "file_path": str(dl_path),
                "complete": False,
                "package_hash": "",
            }
            self._download_events[transfer_id] = threading.Event()

        self._send_to_peer(
            peer_id,
            MESH_MODEL_DOWNLOAD_REQUEST,
            {
                "model_id": model_id,
                "transfer_id": transfer_id,
                "requesting_node": self.node_id,
            },
        )

        event = self._download_events[transfer_id]
        event.wait(timeout=timeout)

        with self._lock:
            dl_state = self._downloads.pop(transfer_id, None)
            self._download_events.pop(transfer_id, None)

        if dl_state and dl_state.get("complete"):
            result_path = Path(dl_state["file_path"])
            if result_path.exists():
                # Record usage
                if self._usage_tracker:
                    self._usage_tracker.record_usage(
                        provider_node=peer_id,
                        consumer_node=self.node_id,
                        resource_type="model_download",
                        resource_id=model_id,
                    )
                return result_path

        # Clean up partial download
        if dl_path.exists():
            try:
                dl_path.unlink()
            except OSError:
                pass
        return None

    def handle_download_request(self, message: dict, peer_id: str):
        """Handle a download request — stream chunks to the peer."""
        if self.hipaa_enabled:
            return

        requesting_node = message.get("requesting_node", peer_id)
        if self.trust_manager and not self.trust_manager.check_permission(
            requesting_node, "share_models"
        ):
            logger.debug(f"Download denied for {requesting_node}: no share_models")
            return

        model_id = message.get("model_id", "")
        transfer_id = message.get("transfer_id", "")

        model = self.registry.get(model_id)
        if not model or not model.shareable:
            logger.warning(f"Model {model_id[:16]} not found or not shareable")
            return

        pkg_path = Path(model.package_path)
        if not pkg_path.exists():
            logger.warning(f"Model package not found: {pkg_path}")
            return

        if pkg_path.stat().st_size > MAX_MODEL_SIZE:
            logger.warning(f"Model too large: {pkg_path.stat().st_size} bytes")
            return

        # Send chunks in a thread to avoid blocking
        t = threading.Thread(
            target=self._send_chunks,
            args=(requesting_node, transfer_id, pkg_path),
            daemon=True,
        )
        t.start()

    def _send_chunks(self, peer_id: str, transfer_id: str, package_path: Path):
        """Send model package in chunks to a peer (streaming, low memory)."""
        try:
            total_size = package_path.stat().st_size
            total_chunks = (total_size + CHUNK_SIZE - 1) // CHUNK_SIZE
            file_hasher = hashlib.sha256()

            with open(package_path, "rb") as f:
                for i in range(total_chunks):
                    chunk_data = f.read(CHUNK_SIZE)
                    if not chunk_data:
                        break
                    file_hasher.update(chunk_data)
                    chunk_hash = hashlib.sha256(chunk_data).hexdigest()

                    self._send_to_peer(
                        peer_id,
                        MESH_MODEL_DOWNLOAD_CHUNK,
                        {
                            "transfer_id": transfer_id,
                            "chunk_index": i,
                            "total_chunks": total_chunks,
                            "data": base64.b64encode(chunk_data).decode("ascii"),
                            "chunk_hash": chunk_hash,
                        },
                    )

                    # Small delay to avoid overwhelming the connection
                    time.sleep(0.01)

            package_hash = file_hasher.hexdigest()

            # Send completion
            self._send_to_peer(
                peer_id,
                MESH_MODEL_DOWNLOAD_COMPLETE,
                {
                    "transfer_id": transfer_id,
                    "package_hash": package_hash,
                    "total_size": total_size,
                    "model_id": package_hash,
                },
            )
            logger.info(
                f"Sent model {package_path.name} to {peer_id[:8]} "
                f"({total_chunks} chunks, {total_size} bytes)"
            )
        except Exception as e:
            logger.error(f"Failed to send chunks for {transfer_id}: {e}")

    def handle_download_chunk(self, message: dict):
        """Handle a received download chunk."""
        transfer_id = message.get("transfer_id", "")

        with self._lock:
            dl = self._downloads.get(transfer_id)
            if not dl:
                return

        chunk_index = message.get("chunk_index", 0)
        total_chunks = message.get("total_chunks", 0)
        chunk_data_b64 = message.get("data", "")
        chunk_hash = message.get("chunk_hash", "")

        try:
            chunk_data = base64.b64decode(chunk_data_b64)
        except Exception:
            logger.warning(f"Invalid base64 in chunk {chunk_index}")
            return

        # Verify chunk integrity
        if chunk_hash:
            computed = hashlib.sha256(chunk_data).hexdigest()
            if computed != chunk_hash:
                logger.warning(f"Chunk hash mismatch: {chunk_index}")
                return

        with self._lock:
            dl = self._downloads.get(transfer_id)
            if dl:
                dl["chunks"][chunk_index] = chunk_data
                dl["total_chunks"] = total_chunks
                # Validate accumulated size doesn't exceed limit
                accumulated = sum(len(c) for c in dl["chunks"].values())
                if accumulated > MAX_MODEL_SIZE:
                    logger.warning(
                        f"Download {transfer_id} exceeds MAX_MODEL_SIZE "
                        f"({accumulated} > {MAX_MODEL_SIZE}), aborting"
                    )
                    del self._downloads[transfer_id]
                    return

    def handle_download_complete(self, message: dict):
        """Handle download completion — reassemble the file."""
        transfer_id = message.get("transfer_id", "")
        package_hash = message.get("package_hash", "")
        total_size = message.get("total_size", 0)

        with self._lock:
            dl = self._downloads.get(transfer_id)
            if not dl:
                return

        total_chunks = dl.get("total_chunks", 0)
        chunks = dl.get("chunks", {})

        # Verify all chunks received
        if len(chunks) != total_chunks:
            logger.warning(
                f"Incomplete download {transfer_id}: got {len(chunks)}/{total_chunks} chunks"
            )
            with self._lock:
                event = self._download_events.get(transfer_id)
                if event:
                    event.set()
            return

        # Reassemble
        assembled = b""
        for i in range(total_chunks):
            chunk = chunks.get(i)
            if chunk is None:
                logger.warning(f"Missing chunk {i} in {transfer_id}")
                with self._lock:
                    event = self._download_events.get(transfer_id)
                    if event:
                        event.set()
                return
            assembled += chunk

        # Verify package hash
        computed_hash = hashlib.sha256(assembled).hexdigest()
        if package_hash and computed_hash != package_hash:
            logger.warning(f"Package hash mismatch for {transfer_id}")
            with self._lock:
                event = self._download_events.get(transfer_id)
                if event:
                    event.set()
            return

        # Write to file
        file_path = Path(dl["file_path"])
        try:
            file_path.write_bytes(assembled)
            with self._lock:
                dl["complete"] = True
                dl["package_hash"] = computed_hash
            logger.info(f"Download complete: {file_path.name} ({total_size} bytes)")
        except (IOError, OSError) as e:
            logger.error(f"Failed to write downloaded model: {e}")

        with self._lock:
            event = self._download_events.get(transfer_id)
            if event:
                event.set()

    # ── Delegated inference ──

    def request_inference(
        self, peer_id: str, model_id: str, prompt: str, timeout: float = 60.0
    ) -> Optional[str]:
        """
        Request inference on a peer's model.

        Returns the response text, or None on failure/timeout.
        """
        if self.hipaa_enabled or not self._gateway:
            return None

        query_id = hashlib.sha256(f"infer:{peer_id}:{model_id}:{time.time()}".encode()).hexdigest()[
            :16
        ]

        with self._lock:
            self._infer_pending[query_id] = None
            self._infer_events[query_id] = threading.Event()

        self._send_to_peer(
            peer_id,
            MESH_MODEL_INFER_REQUEST,
            {
                "requesting_node": self.node_id,
                "query_id": query_id,
                "model_id": model_id,
                "prompt": prompt,
            },
        )

        event = self._infer_events[query_id]
        event.wait(timeout=timeout)

        with self._lock:
            result = self._infer_pending.pop(query_id, None)
            self._infer_events.pop(query_id, None)

        if result and isinstance(result, dict):
            response_text = result.get("response", "")
            # Record usage
            if response_text and self._usage_tracker:
                self._usage_tracker.record_usage(
                    provider_node=peer_id,
                    consumer_node=self.node_id,
                    resource_type="model_inference",
                    resource_id=model_id,
                )
            return response_text
        return None

    def handle_infer_request(self, message: dict) -> Optional[dict]:
        """Handle an inference request from a peer."""
        if self.hipaa_enabled:
            return None

        requesting_node = message.get("requesting_node", "")
        if self.trust_manager and not self.trust_manager.check_permission(
            requesting_node, "share_models"
        ):
            return None

        query_id = message.get("query_id", "")
        model_id = message.get("model_id", "")
        prompt = message.get("prompt", "")

        model = self.registry.get(model_id)
        if not model or not model.shareable:
            return None

        # Try to run inference using the model
        response = self._run_local_inference(model, prompt)
        if response is None:
            return None

        return {
            "query_id": query_id,
            "origin_node": self.node_id,
            "origin_name": self.node_name,
            "model_id": model_id,
            "response": response,
        }

    def handle_infer_response(self, message: dict):
        """Handle an inference response from a peer."""
        query_id = message.get("query_id", "")
        with self._lock:
            if query_id in self._infer_pending:
                self._infer_pending[query_id] = message
                event = self._infer_events.get(query_id)
                if event:
                    event.set()

    def _run_local_inference(self, model: ModelEntry, prompt: str) -> Optional[str]:
        """Run inference on a local model. Returns response or None."""
        try:
            # Try Ollama-based models first
            if model.backend == "ollama" and model.base_model:
                return self._infer_ollama(model.base_model, prompt)

            # Try loading from package
            pkg_path = Path(model.package_path)
            if not pkg_path.exists():
                return None

            # For now, just report that inference is available
            # Full LoRA inference integration would go here
            logger.debug(f"Inference requested for {model.name} but no runtime available")
            return None
        except Exception as e:
            logger.warning(f"Inference failed for {model.name}: {e}")
            return None

    def _infer_ollama(self, model_name: str, prompt: str) -> Optional[str]:
        """Run inference via Ollama."""
        try:
            import httpx

            resp = httpx.post(
                "http://localhost:11434/api/generate",
                json={"model": model_name, "prompt": prompt, "stream": False},
                timeout=60.0,
            )
            if resp.status_code == 200:
                return resp.json().get("response", "")
            return None
        except Exception as e:
            logger.debug(f"Ollama inference failed: {e}")
            return None

    # ── Event dispatcher ──

    def handle_mesh_event(self, topic: str, data: dict, peer_id: str):
        """Route a mesh model event to the appropriate handler."""
        if topic == MESH_MODEL_ADVERTISE:
            self.handle_advertise(data)
        elif topic == MESH_MODEL_CATALOG_REQUEST:
            response = self.handle_catalog_request(data)
            if response:
                self._send_to_peer(peer_id, MESH_MODEL_CATALOG_RESPONSE, response)
        elif topic == MESH_MODEL_CATALOG_RESPONSE:
            self.handle_catalog_response(data)
        elif topic == MESH_MODEL_DOWNLOAD_REQUEST:
            self.handle_download_request(data, peer_id)
        elif topic == MESH_MODEL_DOWNLOAD_CHUNK:
            self.handle_download_chunk(data)
        elif topic == MESH_MODEL_DOWNLOAD_COMPLETE:
            self.handle_download_complete(data)
        elif topic == MESH_MODEL_INFER_REQUEST:
            response = self.handle_infer_request(data)
            if response:
                requesting_node = data.get("requesting_node", peer_id)
                self._send_to_peer(requesting_node, MESH_MODEL_INFER_RESPONSE, response)
        elif topic == MESH_MODEL_INFER_RESPONSE:
            self.handle_infer_response(data)

    # ── Helpers ──

    def _send_to_peer(self, peer_id: str, topic: str, payload: dict):
        """Send a mesh event to a specific peer via the gateway."""
        if not self._gateway:
            return

        import asyncio

        from .gateway import Message, MsgType

        msg = Message(
            type=MsgType.TOOL_REQUEST,
            payload={
                "mesh_event": True,
                "topic": topic,
                "data": payload,
            },
        )

        ws = self._gateway.peer_gateways.get(peer_id)
        if ws:
            try:
                loop = asyncio.get_event_loop()
                if loop.is_running():
                    asyncio.ensure_future(ws.send(msg.to_json()))
                else:
                    loop.run_until_complete(ws.send(msg.to_json()))
            except RuntimeError:
                asyncio.run(ws.send(msg.to_json()))

    def _get_model_peers(self) -> List[str]:
        """Get peer IDs with share_models permission."""
        if not self.trust_manager:
            if self._gateway:
                return list(self._gateway.peer_gateways.keys())
            return []
        trusted = self.trust_manager.get_trusted_peers()
        return [r.node_id for r in trusted if r.permissions.check("share_models")]

    def get_status(self) -> dict:
        """Get model bridge status for CLI/dashboard."""
        all_models = self.registry.get_all()
        shareable = self.registry.get_shareable()
        return {
            "hipaa_mode": self.hipaa_enabled,
            "total_models": len(all_models),
            "shareable_models": len(shareable),
            "peer_catalogs": {k: len(v) for k, v in self._peer_catalogs.items()},
            "active_downloads": len(self._downloads),
            "gateway_connected": self._gateway is not None,
        }
