# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Distributed Bus with Redis (v1.4.0 Phase 3)

Enables distributed skill communication across multiple processes/machines:
- Redis pub/sub for event messaging
- Redis-backed shared state
- Node discovery and coordination
- Distributed dead letter queue
- Cross-process skill invocation

Usage:
    from familiar.core.distributed import (
        DistributedSkillBus, RedisConfig,
        create_distributed_bus
    )

    # Create distributed bus
    bus = create_distributed_bus(
        redis_url="redis://localhost:6379",
        node_id="worker-1"
    )

    # Use like normal SkillBus - messages go across network
    bus.publish("task.completed", {"task_id": "123"})
    bus.subscribe("task.completed", handle_completion)
"""

import json
import logging
import secrets
import threading
import time
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Callable, Dict, List, Optional

logger = logging.getLogger(__name__)


# ============================================================
# CONFIGURATION
# ============================================================


@dataclass
class RedisConfig:
    """Configuration for Redis connection."""

    # Connection
    url: str = "redis://localhost:6379"
    host: str = "localhost"
    port: int = 6379
    db: int = 0
    password: Optional[str] = None

    # Connection pool
    max_connections: int = 10
    socket_timeout: float = 5.0
    socket_connect_timeout: float = 5.0

    # Retry
    retry_on_timeout: bool = True
    health_check_interval: int = 30

    # Namespacing
    key_prefix: str = "familiar"

    # Pub/Sub
    pubsub_thread_sleep_time: float = 0.01

    def get_url(self) -> str:
        """Get Redis URL, constructing from components if needed."""
        if self.url and self.url != "redis://localhost:6379":
            return self.url

        auth = f":{self.password}@" if self.password else ""
        return f"redis://{auth}{self.host}:{self.port}/{self.db}"

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

    @classmethod
    def from_env(cls) -> "RedisConfig":
        """Create config from environment variables."""
        import os

        return cls(
            url=os.getenv("REDIS_URL", "redis://localhost:6379"),
            host=os.getenv("REDIS_HOST", "localhost"),
            port=int(os.getenv("REDIS_PORT", "6379")),
            db=int(os.getenv("REDIS_DB", "0")),
            password=os.getenv("REDIS_PASSWORD"),
            key_prefix=os.getenv("FAMILIAR_REDIS_PREFIX", "familiar"),
        )


class NodeStatus(str, Enum):
    """Status of a distributed node."""

    STARTING = "starting"
    ACTIVE = "active"
    DRAINING = "draining"  # Not accepting new work
    STOPPED = "stopped"


@dataclass
class NodeInfo:
    """Information about a distributed node."""

    node_id: str
    status: NodeStatus = NodeStatus.STARTING
    hostname: str = ""
    pid: int = 0
    started_at: str = ""
    last_heartbeat: str = ""
    skills: List[str] = field(default_factory=list)
    metadata: Dict[str, Any] = field(default_factory=dict)

    def __post_init__(self):
        if not self.started_at:
            self.started_at = datetime.now().isoformat()
        if not self.last_heartbeat:
            self.last_heartbeat = datetime.now().isoformat()
        if not self.hostname:
            import socket

            self.hostname = socket.gethostname()
        if not self.pid:
            import os

            self.pid = os.getpid()

    def to_dict(self) -> dict:
        d = asdict(self)
        d["status"] = self.status.value
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "NodeInfo":
        if "status" in data:
            data["status"] = NodeStatus(data["status"])
        return cls(**data)


# ============================================================
# REDIS MESSAGE BROKER
# ============================================================


class MessageBroker(ABC):
    """Abstract base class for message brokers."""

    @abstractmethod
    def publish(self, channel: str, message: dict) -> bool:
        """Publish a message to a channel."""
        pass

    @abstractmethod
    def subscribe(self, channel: str, handler: Callable):
        """Subscribe to a channel."""
        pass

    @abstractmethod
    def unsubscribe(self, channel: str):
        """Unsubscribe from a channel."""
        pass

    @abstractmethod
    def close(self):
        """Close the broker connection."""
        pass


class RedisMessageBroker(MessageBroker):
    """
    Redis-based message broker for distributed pub/sub.

    Features:
    - Pattern-based subscriptions (e.g., "task.*")
    - Message serialization/deserialization
    - Automatic reconnection
    - Thread-safe operations

    Usage:
        broker = RedisMessageBroker(RedisConfig())

        def handler(channel, message):
            print(f"Received on {channel}: {message}")

        broker.subscribe("events.*", handler)
        broker.publish("events.task", {"id": "123"})
    """

    def __init__(self, config: RedisConfig):
        self.config = config
        self._redis = None
        self._pubsub = None
        self._handlers: Dict[str, List[Callable]] = {}
        self._pubsub_thread = None
        self._running = False
        self._lock = threading.Lock()

        self._connect()

    def _connect(self):
        """Establish Redis connection."""
        try:
            import redis

            self._redis = redis.from_url(
                self.config.get_url(),
                max_connections=self.config.max_connections,
                socket_timeout=self.config.socket_timeout,
                socket_connect_timeout=self.config.socket_connect_timeout,
                retry_on_timeout=self.config.retry_on_timeout,
                health_check_interval=self.config.health_check_interval,
                decode_responses=True,
            )

            # Test connection
            self._redis.ping()
            logger.info(f"Connected to Redis at {self.config.host}:{self.config.port}")

        except ImportError:
            raise ImportError("redis package not available.")
        except Exception as e:
            logger.error(f"Failed to connect to Redis: {e}")
            raise

    def _get_channel_key(self, channel: str) -> str:
        """Get namespaced channel key."""
        return f"{self.config.key_prefix}:channel:{channel}"

    def publish(self, channel: str, message: dict) -> bool:
        """
        Publish a message to a channel.

        Args:
            channel: Channel name (e.g., "events.task")
            message: Message dict to publish

        Returns:
            True if published successfully
        """
        try:
            channel_key = self._get_channel_key(channel)
            payload = json.dumps(message)
            receivers = self._redis.publish(channel_key, payload)
            logger.debug(f"Published to {channel}: {receivers} receivers")
            return True
        except Exception as e:
            logger.error(f"Failed to publish to {channel}: {e}")
            return False

    def subscribe(self, pattern: str, handler: Callable):
        """
        Subscribe to a channel pattern.

        Args:
            pattern: Channel pattern (e.g., "events.*" for wildcard)
            handler: Function(channel, message) to call on message
        """
        with self._lock:
            pattern_key = self._get_channel_key(pattern)

            if pattern not in self._handlers:
                self._handlers[pattern] = []
            self._handlers[pattern].append(handler)

            # Start pubsub if not running
            if self._pubsub is None:
                self._pubsub = self._redis.pubsub()
                self._start_pubsub_thread()

            # Subscribe (pattern or exact)
            if "*" in pattern or "?" in pattern:
                self._pubsub.psubscribe(**{pattern_key: self._dispatch_message})
            else:
                self._pubsub.subscribe(**{pattern_key: self._dispatch_message})

            logger.debug(f"Subscribed to pattern: {pattern}")

    def unsubscribe(self, pattern: str):
        """Unsubscribe from a channel pattern."""
        with self._lock:
            pattern_key = self._get_channel_key(pattern)

            if "*" in pattern or "?" in pattern:
                self._pubsub.punsubscribe(pattern_key)
            else:
                self._pubsub.unsubscribe(pattern_key)

            self._handlers.pop(pattern, None)

    def _dispatch_message(self, message):
        """Dispatch received message to handlers."""
        if message["type"] not in ("message", "pmessage"):
            return

        try:
            channel = message.get("channel", "")
            # Remove prefix to get original channel name
            prefix = f"{self.config.key_prefix}:channel:"
            if channel.startswith(prefix):
                channel = channel[len(prefix) :]

            data = json.loads(message["data"])

            # Find matching handlers
            for pattern, handlers in self._handlers.items():
                if self._pattern_matches(pattern, channel):
                    for handler in handlers:
                        try:
                            handler(channel, data)
                        except Exception as e:
                            logger.error(f"Handler error for {channel}: {e}")

        except json.JSONDecodeError as e:
            logger.error(f"Failed to parse message: {e}")

    def _pattern_matches(self, pattern: str, channel: str) -> bool:
        """Check if channel matches pattern."""
        import fnmatch

        return fnmatch.fnmatch(channel, pattern)

    def _start_pubsub_thread(self):
        """Start background thread for pubsub."""
        self._running = True
        self._pubsub_thread = threading.Thread(target=self._pubsub_loop, daemon=True)
        self._pubsub_thread.start()

    def _pubsub_loop(self):
        """Background loop for processing pubsub messages."""
        while self._running:
            try:
                self._pubsub.get_message(
                    ignore_subscribe_messages=True, timeout=self.config.pubsub_thread_sleep_time
                )
            except Exception as e:
                logger.error(f"PubSub error: {e}")
                time.sleep(1)  # Back off on error

    def close(self):
        """Close the broker."""
        self._running = False
        if self._pubsub:
            self._pubsub.close()
        if self._redis:
            self._redis.close()


# ============================================================
# REDIS STATE BACKEND
# ============================================================


class RedisStateBackend:
    """
    Redis-backed distributed state.

    Provides:
    - Distributed key-value storage
    - Atomic operations
    - TTL support
    - Pub/sub for state changes

    Usage:
        backend = RedisStateBackend(RedisConfig())
        backend.set("key", {"data": "value"})
        value = backend.get("key")
    """

    def __init__(self, config: RedisConfig, namespace: str = "default"):
        self.config = config
        self.namespace = namespace
        self._redis = None
        self._connect()

    def _connect(self):
        """Establish Redis connection."""
        try:
            import redis

            self._redis = redis.from_url(self.config.get_url(), decode_responses=True)
            self._redis.ping()
        except ImportError:
            raise ImportError("redis package not available.")

    def _get_key(self, key: str) -> str:
        """Get namespaced key."""
        return f"{self.config.key_prefix}:state:{self.namespace}:{key}"

    def get(self, key: str) -> Optional[Any]:
        """Get a value."""
        try:
            data = self._redis.get(self._get_key(key))
            if data:
                return json.loads(data)
            return None
        except Exception as e:
            logger.error(f"Redis get error: {e}")
            return None

    def set(self, key: str, value: Any, ttl_seconds: int = None) -> bool:
        """Set a value with optional TTL."""
        try:
            data = json.dumps(value)
            if ttl_seconds:
                self._redis.setex(self._get_key(key), ttl_seconds, data)
            else:
                self._redis.set(self._get_key(key), data)
            return True
        except Exception as e:
            logger.error(f"Redis set error: {e}")
            return False

    def delete(self, key: str) -> bool:
        """Delete a key."""
        try:
            self._redis.delete(self._get_key(key))
            return True
        except Exception as e:
            logger.error(f"Redis delete error: {e}")
            return False

    def keys(self, pattern: str = "*") -> List[str]:
        """Get keys matching pattern."""
        try:
            prefix = f"{self.config.key_prefix}:state:{self.namespace}:"
            full_pattern = f"{prefix}{pattern}"
            keys = self._redis.keys(full_pattern)
            return [k[len(prefix) :] for k in keys]
        except Exception as e:
            logger.error(f"Redis keys error: {e}")
            return []

    def incr(self, key: str, amount: int = 1) -> int:
        """Atomic increment."""
        return self._redis.incrby(self._get_key(key), amount)

    def decr(self, key: str, amount: int = 1) -> int:
        """Atomic decrement."""
        return self._redis.decrby(self._get_key(key), amount)

    def expire(self, key: str, ttl_seconds: int) -> bool:
        """Set TTL on existing key."""
        return self._redis.expire(self._get_key(key), ttl_seconds)

    def ttl(self, key: str) -> int:
        """Get remaining TTL (-1 if no TTL, -2 if key doesn't exist)."""
        return self._redis.ttl(self._get_key(key))

    def exists(self, key: str) -> bool:
        """Check if key exists."""
        return self._redis.exists(self._get_key(key)) > 0

    # Distributed locking
    def acquire_lock(self, name: str, timeout: int = 10) -> Optional[str]:
        """
        Acquire a distributed lock.

        Returns lock token if acquired, None otherwise.
        """
        token = secrets.token_hex(16)
        lock_key = f"{self.config.key_prefix}:lock:{name}"

        acquired = self._redis.set(
            lock_key,
            token,
            nx=True,  # Only if not exists
            ex=timeout,  # Auto-expire
        )

        return token if acquired else None

    def release_lock(self, name: str, token: str) -> bool:
        """
        Release a distributed lock.

        Only releases if token matches (owner verification).
        """
        lock_key = f"{self.config.key_prefix}:lock:{name}"

        # Lua script for atomic check-and-delete
        script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
        """

        result = self._redis.eval(script, 1, lock_key, token)
        return result == 1

    def close(self):
        """Close connection."""
        if self._redis:
            self._redis.close()


# ============================================================
# NODE COORDINATOR
# ============================================================


class NodeCoordinator:
    """
    Coordinates distributed nodes.

    Provides:
    - Node registration and discovery
    - Heartbeat monitoring
    - Leader election
    - Skill routing
    """

    def __init__(
        self, config: RedisConfig, node_id: str, heartbeat_interval: int = 5, node_timeout: int = 15
    ):
        self.config = config
        self.node_id = node_id
        self.heartbeat_interval = heartbeat_interval
        self.node_timeout = node_timeout

        self._state = RedisStateBackend(config, namespace="coordinator")
        self._broker = RedisMessageBroker(config)
        self._node_info = NodeInfo(node_id=node_id)
        self._running = False
        self._heartbeat_thread = None

    def _get_node_key(self, node_id: str) -> str:
        """Get key for node info."""
        return f"node:{node_id}"

    def _get_nodes_set_key(self) -> str:
        """Get key for active nodes set."""
        return "nodes:active"

    def start(self):
        """Start the coordinator."""
        self._running = True
        self._node_info.status = NodeStatus.ACTIVE
        self._register_node()
        self._start_heartbeat()

        # Subscribe to coordination messages
        self._broker.subscribe("coordination.*", self._handle_coordination)

        logger.info(f"Node {self.node_id} started")

    def stop(self):
        """Stop the coordinator."""
        self._running = False
        self._node_info.status = NodeStatus.STOPPED
        self._update_node_info()
        self._broker.close()
        self._state.close()
        logger.info(f"Node {self.node_id} stopped")

    def _register_node(self):
        """Register this node."""
        self._update_node_info()

        # Announce to other nodes
        self._broker.publish(
            "coordination.node_joined", {"node_id": self.node_id, "info": self._node_info.to_dict()}
        )

    def _update_node_info(self):
        """Update node info in Redis."""
        self._node_info.last_heartbeat = datetime.now().isoformat()
        self._state.set(
            self._get_node_key(self.node_id),
            self._node_info.to_dict(),
            ttl_seconds=self.node_timeout * 2,
        )

    def _start_heartbeat(self):
        """Start heartbeat thread."""
        self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
        self._heartbeat_thread.start()

    def _heartbeat_loop(self):
        """Background heartbeat loop."""
        while self._running:
            try:
                self._update_node_info()
                self._cleanup_stale_nodes()
            except Exception as e:
                logger.error(f"Heartbeat error: {e}")

            time.sleep(self.heartbeat_interval)

    def _cleanup_stale_nodes(self):
        """Remove nodes that haven't sent heartbeat."""
        # This is handled by TTL on node keys
        pass

    def _handle_coordination(self, channel: str, message: dict):
        """Handle coordination messages."""
        event = channel.split(".")[-1]

        if event == "node_joined":
            node_id = message.get("node_id")
            if node_id != self.node_id:
                logger.info(f"Node joined: {node_id}")

        elif event == "node_left":
            node_id = message.get("node_id")
            logger.info(f"Node left: {node_id}")

    def get_active_nodes(self) -> List[NodeInfo]:
        """Get list of active nodes."""
        nodes = []
        for key in self._state.keys("node:*"):
            data = self._state.get(key)
            if data:
                try:
                    nodes.append(NodeInfo.from_dict(data))
                except Exception:
                    pass
        return nodes

    def get_node(self, node_id: str) -> Optional[NodeInfo]:
        """Get info for a specific node."""
        data = self._state.get(self._get_node_key(node_id))
        if data:
            return NodeInfo.from_dict(data)
        return None

    def register_skill(self, skill_name: str):
        """Register a skill as available on this node."""
        if skill_name not in self._node_info.skills:
            self._node_info.skills.append(skill_name)
            self._update_node_info()

    def find_nodes_with_skill(self, skill_name: str) -> List[str]:
        """Find nodes that have a specific skill."""
        nodes = self.get_active_nodes()
        return [n.node_id for n in nodes if skill_name in n.skills]


# ============================================================
# DISTRIBUTED SKILL BUS
# ============================================================


class DistributedSkillBus:
    """
    Distributed version of SkillBus using Redis.

    Extends local SkillBus functionality across multiple processes/machines:
    - Cross-process pub/sub messaging
    - Distributed state
    - Remote skill invocation
    - Node coordination

    Usage:
        bus = DistributedSkillBus(
            config=RedisConfig(),
            node_id="worker-1"
        )

        # Register local skill
        bus.register_skill("email", actions={"send": send_email})

        # Publish event (goes to all nodes)
        bus.publish("task.completed", {"task_id": "123"})

        # Subscribe (receives from all nodes)
        bus.subscribe("task.*", handler)

        # Invoke skill (routes to appropriate node)
        result = bus.invoke("email", "send", {"to": "test@example.com"})
    """

    def __init__(
        self, config: RedisConfig = None, node_id: str = None, heartbeat_interval: int = 5
    ):
        self.config = config or RedisConfig()
        self.node_id = node_id or f"node-{secrets.token_hex(4)}"

        # Initialize components
        self._broker = RedisMessageBroker(self.config)
        self._state = RedisStateBackend(self.config, namespace="bus")
        self._coordinator = NodeCoordinator(
            self.config, self.node_id, heartbeat_interval=heartbeat_interval
        )

        # Local skill registry
        self._local_skills: Dict[str, Dict[str, Callable]] = {}
        self._subscriptions: Dict[str, List[Callable]] = {}

        # For RPC-style invocations
        self._pending_requests: Dict[str, Any] = {}
        self._request_lock = threading.Lock()

        # Subscribe to skill invocations
        self._broker.subscribe(f"invoke.{self.node_id}.*", self._handle_invocation)
        self._broker.subscribe("invoke.broadcast.*", self._handle_invocation)
        self._broker.subscribe(f"response.{self.node_id}.*", self._handle_response)

        # Start coordinator
        self._coordinator.start()

        logger.info(f"DistributedSkillBus started: {self.node_id}")

    def register_skill(
        self, skill_name: str, actions: Dict[str, Callable] = None, metadata: Dict = None
    ):
        """
        Register a skill on this node.

        Args:
            skill_name: Unique skill identifier
            actions: Dict of action_name -> handler function
            metadata: Additional skill metadata
        """
        self._local_skills[skill_name] = actions or {}
        self._coordinator.register_skill(skill_name)
        logger.info(f"Registered skill: {skill_name}")

    def add_action(self, skill_name: str, action_name: str, handler: Callable):
        """Add an action to an existing skill."""
        if skill_name not in self._local_skills:
            self._local_skills[skill_name] = {}
        self._local_skills[skill_name][action_name] = handler

    def publish(self, topic: str, message: dict):
        """
        Publish an event to all subscribers across all nodes.

        Args:
            topic: Event topic (e.g., "task.completed")
            message: Event payload
        """
        self._broker.publish(
            f"events.{topic}",
            {
                "source_node": self.node_id,
                "timestamp": datetime.now().isoformat(),
                "payload": message,
            },
        )

    def subscribe(self, topic: str, handler: Callable):
        """
        Subscribe to events from all nodes.

        Args:
            topic: Topic pattern (e.g., "task.*")
            handler: Function(topic, message) to call
        """
        if topic not in self._subscriptions:
            self._subscriptions[topic] = []

            # Subscribe via broker
            self._broker.subscribe(f"events.{topic}", self._dispatch_event)

        self._subscriptions[topic].append(handler)

    def _dispatch_event(self, channel: str, message: dict):
        """Dispatch received event to local handlers."""
        # Extract topic from channel
        topic = channel.replace("events.", "", 1)
        payload = message.get("payload", message)

        for pattern, handlers in self._subscriptions.items():
            if self._pattern_matches(pattern, topic):
                for handler in handlers:
                    try:
                        handler(topic, payload)
                    except Exception as e:
                        logger.error(f"Event handler error: {e}")

    def _pattern_matches(self, pattern: str, topic: str) -> bool:
        """Check if topic matches pattern."""
        import fnmatch

        return fnmatch.fnmatch(topic, pattern)

    def invoke(
        self,
        skill_name: str,
        action: str,
        payload: dict = None,
        timeout: float = 30.0,
        target_node: str = None,
    ) -> Any:
        """
        Invoke a skill action, routing to the appropriate node.

        Args:
            skill_name: Target skill
            action: Action to invoke
            payload: Action parameters
            timeout: Timeout in seconds
            target_node: Specific node to target (optional)

        Returns:
            Action result
        """
        # Check if skill is local
        if skill_name in self._local_skills and not target_node:
            return self._invoke_local(skill_name, action, payload or {})

        # Find node with skill
        if not target_node:
            nodes = self._coordinator.find_nodes_with_skill(skill_name)
            if not nodes:
                raise ValueError(f"No node found with skill: {skill_name}")
            target_node = nodes[0]  # Simple routing - could add load balancing

        return self._invoke_remote(target_node, skill_name, action, payload or {}, timeout)

    def _invoke_local(self, skill_name: str, action: str, payload: dict) -> Any:
        """Invoke a local skill."""
        actions = self._local_skills.get(skill_name, {})
        handler = actions.get(action)

        if not handler:
            raise ValueError(f"Action not found: {skill_name}.{action}")

        return handler(**payload)

    def _invoke_remote(
        self, target_node: str, skill_name: str, action: str, payload: dict, timeout: float
    ) -> Any:
        """Invoke a skill on a remote node."""
        import queue

        request_id = secrets.token_hex(8)
        response_queue = queue.Queue()

        with self._request_lock:
            self._pending_requests[request_id] = response_queue

        try:
            # Send request
            self._broker.publish(
                f"invoke.{target_node}.{skill_name}",
                {
                    "request_id": request_id,
                    "source_node": self.node_id,
                    "skill": skill_name,
                    "action": action,
                    "payload": payload,
                },
            )

            # Wait for response
            try:
                response = response_queue.get(timeout=timeout)
                if response.get("error"):
                    raise Exception(response["error"])
                return response.get("result")
            except queue.Empty:
                raise TimeoutError(f"Timeout invoking {skill_name}.{action} on {target_node}")
        finally:
            with self._request_lock:
                self._pending_requests.pop(request_id, None)

    def _handle_invocation(self, channel: str, message: dict):
        """Handle incoming skill invocation request."""
        skill_name = message.get("skill")
        action = message.get("action")
        payload = message.get("payload", {})
        request_id = message.get("request_id")
        source_node = message.get("source_node")

        # Check if we have this skill
        if skill_name not in self._local_skills:
            return

        # Execute
        try:
            result = self._invoke_local(skill_name, action, payload)
            response = {"request_id": request_id, "result": result}
        except Exception as e:
            response = {"request_id": request_id, "error": str(e)}

        # Send response
        self._broker.publish(f"response.{source_node}.{request_id}", response)

    def _handle_response(self, channel: str, message: dict):
        """Handle incoming response to our invocation."""
        request_id = message.get("request_id")

        with self._request_lock:
            response_queue = self._pending_requests.get(request_id)
            if response_queue:
                response_queue.put(message)

    # State management (distributed)
    @property
    def state(self) -> RedisStateBackend:
        """Access distributed state."""
        return self._state

    def get_state(self, key: str, default: Any = None) -> Any:
        """Get a value from distributed state."""
        value = self._state.get(key)
        return value if value is not None else default

    def set_state(self, key: str, value: Any, ttl_seconds: int = None):
        """Set a value in distributed state."""
        self._state.set(key, value, ttl_seconds=ttl_seconds)

    # Node information
    def get_nodes(self) -> List[NodeInfo]:
        """Get all active nodes."""
        return self._coordinator.get_active_nodes()

    def get_local_skills(self) -> List[str]:
        """Get skills registered on this node."""
        return list(self._local_skills.keys())

    def stop(self):
        """Stop the distributed bus."""
        self._coordinator.stop()
        self._broker.close()
        self._state.close()
        logger.info(f"DistributedSkillBus stopped: {self.node_id}")


# ============================================================
# FACTORY FUNCTIONS
# ============================================================


def create_distributed_bus(
    redis_url: str = None, node_id: str = None, **kwargs
) -> DistributedSkillBus:
    """
    Create a distributed skill bus.

    Args:
        redis_url: Redis connection URL
        node_id: Unique identifier for this node
        **kwargs: Additional RedisConfig parameters

    Returns:
        Configured DistributedSkillBus

    Usage:
        bus = create_distributed_bus("redis://localhost:6379")
        bus.register_skill("email", actions={"send": send_email})
    """
    config = RedisConfig()
    if redis_url:
        config.url = redis_url

    for key, value in kwargs.items():
        if hasattr(config, key):
            setattr(config, key, value)

    return DistributedSkillBus(config=config, node_id=node_id)


def create_redis_state_backend(
    redis_url: str = None, namespace: str = "default", **kwargs
) -> RedisStateBackend:
    """
    Create a Redis state backend for use with SharedState.

    Args:
        redis_url: Redis connection URL
        namespace: State namespace
        **kwargs: Additional RedisConfig parameters

    Returns:
        RedisStateBackend instance

    Usage:
        from familiar.core import SkillBus

        backend = create_redis_state_backend("redis://localhost:6379")
        bus = SkillBus(state_backend=backend)
    """
    config = RedisConfig()
    if redis_url:
        config.url = redis_url

    for key, value in kwargs.items():
        if hasattr(config, key):
            setattr(config, key, value)

    return RedisStateBackend(config, namespace=namespace)


# ============================================================
# SINGLETON INSTANCE
# ============================================================

_distributed_bus: Optional[DistributedSkillBus] = None


def get_distributed_bus() -> Optional[DistributedSkillBus]:
    """Get the global distributed bus instance."""
    return _distributed_bus


def set_distributed_bus(bus: DistributedSkillBus):
    """Set the global distributed bus instance."""
    global _distributed_bus
    _distributed_bus = bus


def init_distributed_bus(
    redis_url: str = None, node_id: str = None, **kwargs
) -> DistributedSkillBus:
    """
    Initialize and set the global distributed bus.

    Usage:
        init_distributed_bus("redis://localhost:6379", node_id="worker-1")

        # Later, anywhere in code
        bus = get_distributed_bus()
        bus.publish("event", {"data": "value"})
    """
    global _distributed_bus
    _distributed_bus = create_distributed_bus(redis_url, node_id, **kwargs)
    return _distributed_bus
