# 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

"""
Skill Communication Bus for Familiar

Enables inter-skill communication and coordination:
- Direct skill-to-skill invocation
- Event-based pub/sub messaging
- Shared state management
- Workflow orchestration

This transforms Familiar from isolated skills into a coordinated
multi-agent system where skills can delegate and collaborate.

Usage:
    from familiar.core.bus import (
        SkillBus, Message, get_skill_bus,
        publish, subscribe, invoke
    )

    # Pub/sub messaging
    bus = get_skill_bus()
    bus.subscribe("email.received", handle_new_email)
    bus.publish("email.received", {"from": "alice@example.com", "subject": "Hello"})

    # Direct invocation
    result = bus.invoke("calendar", "add_event", {"title": "Meeting", "time": "3pm"})

    # Workflow orchestration
    workflow = bus.create_workflow("process_expense")
    workflow.add_step("email", "get_attachments", depends_on=[])
    workflow.add_step("ocr", "extract_text", depends_on=["email"])
    workflow.add_step("tasks", "create_task", depends_on=["ocr"])
    result = workflow.execute({"email_id": "123"})
"""

import logging
import queue
import random
import threading
import time
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional

if TYPE_CHECKING:
    from familiar.core.persistence import StateBackend
import traceback
from collections import defaultdict
from datetime import datetime
from enum import Enum

logger = logging.getLogger(__name__)


# ============================================================
# RETRY POLICY
# ============================================================


class RetryStrategy(str, Enum):
    """Backoff strategies for retry."""

    NONE = "none"  # No retry
    FIXED = "fixed"  # Fixed delay between retries
    LINEAR = "linear"  # Linearly increasing delay
    EXPONENTIAL = "exponential"  # Exponentially increasing delay


@dataclass
class RetryPolicy:
    """
    Configurable retry policy with backoff strategies.

    Usage:
        # Default exponential backoff
        policy = RetryPolicy()

        # Custom configuration
        policy = RetryPolicy(
            max_attempts=5,
            base_delay=0.5,
            max_delay=30.0,
            strategy=RetryStrategy.EXPONENTIAL,
            jitter=0.1,
            retryable_exceptions=(ConnectionError, TimeoutError)
        )

        # Use with invoke
        result = bus.invoke("skill", "action", data, retry_policy=policy)
    """

    max_attempts: int = 3
    base_delay: float = 1.0  # seconds
    max_delay: float = 60.0  # seconds
    strategy: RetryStrategy = RetryStrategy.EXPONENTIAL
    jitter: float = 0.1  # Random jitter factor (0.1 = ±10%)

    # Which exceptions trigger retry (None = all exceptions)
    retryable_exceptions: Optional[tuple] = None

    # Callback for retry events: (attempt, exception, delay) -> None
    on_retry: Optional[Callable] = None

    # Callback to determine if exception is retryable: (exception) -> bool
    is_retryable: Optional[Callable] = None

    def should_retry(self, exception: Exception) -> bool:
        """Determine if an exception should trigger a retry."""
        # Custom check function takes precedence
        if self.is_retryable:
            return self.is_retryable(exception)

        # Check against exception types
        if self.retryable_exceptions:
            return isinstance(exception, self.retryable_exceptions)

        # Default: retry on common transient errors
        transient_types = (
            ConnectionError,
            TimeoutError,
            OSError,
        )

        # Also check for common error messages
        error_msg = str(exception).lower()
        transient_messages = [
            "connection",
            "timeout",
            "temporarily",
            "unavailable",
            "rate limit",
            "too many requests",
            "retry",
            "503",
            "502",
        ]

        if isinstance(exception, transient_types):
            return True

        if any(msg in error_msg for msg in transient_messages):
            return True

        return False

    def calculate_delay(self, attempt: int) -> float:
        """
        Calculate delay before next retry attempt.

        Args:
            attempt: Current attempt number (1-based)

        Returns:
            Delay in seconds
        """
        if self.strategy == RetryStrategy.NONE:
            return 0.0
        elif self.strategy == RetryStrategy.FIXED:
            delay = self.base_delay
        elif self.strategy == RetryStrategy.LINEAR:
            delay = self.base_delay * attempt
        else:  # EXPONENTIAL
            delay = self.base_delay * (2 ** (attempt - 1))

        # Apply jitter to prevent thundering herd
        if self.jitter > 0:
            jitter_range = delay * self.jitter
            delay += random.uniform(-jitter_range, jitter_range)

        # Clamp to max
        return min(max(0, delay), self.max_delay)

    def execute(self, func: Callable, *args, **kwargs) -> Any:
        """
        Execute a function with retry policy.

        Args:
            func: Function to execute
            *args, **kwargs: Arguments to pass to function

        Returns:
            Result from function

        Raises:
            Last exception if all retries exhausted
        """
        last_exception = None

        for attempt in range(1, self.max_attempts + 1):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                last_exception = e

                # Check if we should retry
                if attempt >= self.max_attempts:
                    break

                if not self.should_retry(e):
                    break

                # Calculate delay
                delay = self.calculate_delay(attempt)

                # Notify callback
                if self.on_retry:
                    try:
                        self.on_retry(attempt, e, delay)
                    except Exception:
                        pass  # Don't let callback errors affect retry
                else:
                    logger.warning(f"Retry {attempt}/{self.max_attempts} after {delay:.2f}s: {e}")

                # Wait before retry
                if delay > 0:
                    time.sleep(delay)

        raise last_exception


# Default retry policy for the bus
DEFAULT_RETRY_POLICY = RetryPolicy(
    max_attempts=3, base_delay=1.0, strategy=RetryStrategy.EXPONENTIAL, jitter=0.1
)

# No-retry policy for when retry is disabled
NO_RETRY_POLICY = RetryPolicy(max_attempts=1, strategy=RetryStrategy.NONE)


# ============================================================
# DEAD LETTER QUEUE
# ============================================================


@dataclass
class DeadLetter:
    """
    A failed message stored for later inspection or replay.

    Dead letters capture failed invocations with full context
    for debugging, alerting, and manual/automatic replay.
    """

    # Identity
    id: str = ""

    # Original invocation details
    skill_name: str = ""
    action: str = ""
    payload: Dict[str, Any] = field(default_factory=dict)
    context: Dict[str, Any] = field(default_factory=dict)

    # Failure details
    error_type: str = ""
    error_message: str = ""
    error_traceback: str = ""

    # Retry information
    attempts: int = 0
    first_attempt_at: str = ""
    last_attempt_at: str = ""

    # Metadata
    source_skill: str = ""
    created_at: str = field(default_factory=lambda: datetime.now().isoformat())

    def __post_init__(self):
        if not self.id:
            import secrets

            self.id = secrets.token_hex(8)

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

    @classmethod
    def from_exception(
        cls,
        skill_name: str,
        action: str,
        payload: Dict,
        exception: Exception,
        attempts: int,
        context: Dict = None,
        source_skill: str = "",
    ) -> "DeadLetter":
        """Create a DeadLetter from an exception."""
        return cls(
            skill_name=skill_name,
            action=action,
            payload=payload,
            context=context or {},
            error_type=type(exception).__name__,
            error_message=str(exception),
            error_traceback=traceback.format_exc(),
            attempts=attempts,
            first_attempt_at=datetime.now().isoformat(),
            last_attempt_at=datetime.now().isoformat(),
            source_skill=source_skill,
        )


class DeadLetterQueue:
    """
    Thread-safe queue for failed messages.

    Provides storage, inspection, and replay capabilities
    for messages that failed after all retry attempts.

    Usage:
        dlq = DeadLetterQueue(max_size=1000)

        # Add failed message
        dlq.add(dead_letter)

        # Inspect
        print(f"Failed messages: {dlq.size()}")
        for dl in dlq.peek(10):
            print(f"  {dl.skill_name}.{dl.action}: {dl.error_message}")

        # Get and remove
        dead_letter = dlq.get()

        # Clear old entries
        dlq.clear(older_than_hours=24)
    """

    def __init__(self, max_size: int = 1000):
        self.max_size = max_size
        self._queue: List[DeadLetter] = []
        self._lock = threading.Lock()
        self._stats = {
            "total_added": 0,
            "total_replayed": 0,
            "total_discarded": 0,
        }

    def add(self, dead_letter: DeadLetter) -> bool:
        """
        Add a dead letter to the queue.

        Returns True if added, False if queue is full and oldest was evicted.
        """
        with self._lock:
            evicted = False

            # Evict oldest if at capacity
            if len(self._queue) >= self.max_size:
                self._queue.pop(0)
                self._stats["total_discarded"] += 1
                evicted = True

            self._queue.append(dead_letter)
            self._stats["total_added"] += 1

            logger.warning(
                f"Dead letter added: {dead_letter.skill_name}.{dead_letter.action} "
                f"after {dead_letter.attempts} attempts - {dead_letter.error_message}"
            )

            return not evicted

    def get(self, id: str = None) -> Optional[DeadLetter]:
        """
        Get and remove a dead letter.

        If id is provided, gets that specific letter.
        Otherwise, gets the oldest (FIFO).
        """
        with self._lock:
            if not self._queue:
                return None

            if id:
                for i, dl in enumerate(self._queue):
                    if dl.id == id:
                        return self._queue.pop(i)
                return None
            else:
                return self._queue.pop(0)

    def peek(self, n: int = 10, skill_name: str = None) -> List[DeadLetter]:
        """
        Peek at dead letters without removing them.

        Args:
            n: Maximum number to return
            skill_name: Filter by skill name (optional)
        """
        with self._lock:
            if skill_name:
                filtered = [dl for dl in self._queue if dl.skill_name == skill_name]
                return filtered[:n]
            return self._queue[:n]

    def size(self) -> int:
        """Get current queue size."""
        with self._lock:
            return len(self._queue)

    def clear(self, older_than_hours: float = None) -> int:
        """
        Clear dead letters.

        Args:
            older_than_hours: If provided, only clear entries older than this

        Returns:
            Number of entries cleared
        """
        with self._lock:
            if older_than_hours is None:
                count = len(self._queue)
                self._queue.clear()
                return count

            from datetime import timedelta

            cutoff = datetime.now() - timedelta(hours=older_than_hours)

            original_count = len(self._queue)
            self._queue = [
                dl for dl in self._queue if datetime.fromisoformat(dl.created_at) > cutoff
            ]

            return original_count - len(self._queue)

    def get_stats(self) -> Dict[str, Any]:
        """Get queue statistics."""
        with self._lock:
            # Count by skill
            by_skill = defaultdict(int)
            for dl in self._queue:
                by_skill[dl.skill_name] += 1

            # Count by error type
            by_error = defaultdict(int)
            for dl in self._queue:
                by_error[dl.error_type] += 1

            return {
                **self._stats,
                "current_size": len(self._queue),
                "max_size": self.max_size,
                "by_skill": dict(by_skill),
                "by_error_type": dict(by_error),
            }

    def mark_replayed(self):
        """Increment replay counter (call after successful replay)."""
        with self._lock:
            self._stats["total_replayed"] += 1


# ============================================================
# MESSAGE TYPES
# ============================================================


class MessageType(str, Enum):
    """Types of messages on the bus."""

    EVENT = "event"  # Pub/sub notification
    REQUEST = "request"  # RPC-style invocation
    RESPONSE = "response"  # Response to request
    BROADCAST = "broadcast"  # All skills receive
    WORKFLOW = "workflow"  # Workflow coordination


class Priority(str, Enum):
    """Message priority levels."""

    LOW = "low"
    NORMAL = "normal"
    HIGH = "high"
    URGENT = "urgent"


@dataclass
class Message:
    """
    A message on the skill bus.

    Messages can be events (pub/sub) or requests (RPC-style).
    """

    # Identity
    id: str = ""
    type: MessageType = MessageType.EVENT

    # Routing
    topic: str = ""  # For events: topic name
    source_skill: str = ""  # Who sent it
    target_skill: str = ""  # For requests: who should handle

    # Payload
    action: str = ""  # For requests: action to invoke
    payload: Dict[str, Any] = field(default_factory=dict)

    # Response tracking
    correlation_id: str = ""  # Links request/response
    reply_to: str = ""  # Where to send response

    # Metadata
    priority: Priority = Priority.NORMAL
    timestamp: str = ""
    ttl_seconds: int = 300  # Time to live

    # Context for the receiving skill
    context: Dict[str, Any] = field(default_factory=dict)

    def __post_init__(self):
        if not self.id:
            import secrets

            self.id = secrets.token_hex(8)
        if not self.timestamp:
            self.timestamp = datetime.now().isoformat()

    def to_dict(self) -> dict:
        return {**asdict(self), "type": self.type.value, "priority": self.priority.value}

    @classmethod
    def from_dict(cls, data: dict) -> "Message":
        if "type" in data:
            data["type"] = MessageType(data["type"])
        if "priority" in data:
            data["priority"] = Priority(data["priority"])
        return cls(**data)

    def create_response(self, result: Any, error: str = None) -> "Message":
        """Create a response message for this request."""
        return Message(
            type=MessageType.RESPONSE,
            source_skill=self.target_skill,
            target_skill=self.source_skill,
            correlation_id=self.id,
            payload={"result": result, "error": error},
            context=self.context,
        )


# ============================================================
# SUBSCRIPTION MANAGEMENT
# ============================================================


@dataclass
class Subscription:
    """A subscription to a topic."""

    topic: str
    handler: Callable
    skill_name: str = ""
    filter_fn: Optional[Callable] = None  # Optional message filter
    created_at: str = field(default_factory=lambda: datetime.now().isoformat())

    def matches(self, message: Message) -> bool:
        """Check if this subscription matches a message."""
        # Topic matching with wildcards
        if self.topic == "*":
            return True

        if self.topic.endswith(".*"):
            prefix = self.topic[:-2]
            if not message.topic.startswith(prefix):
                return False
        elif self.topic != message.topic:
            return False

        # Custom filter
        if self.filter_fn and not self.filter_fn(message):
            return False

        return True


# ============================================================
# SKILL REGISTRY
# ============================================================


@dataclass
class SkillEndpoint:
    """A registered skill endpoint."""

    skill_name: str
    actions: Dict[str, Callable] = field(default_factory=dict)
    metadata: Dict[str, Any] = field(default_factory=dict)

    _sig_cache: Dict[str, Any] = field(default_factory=dict, repr=False)

    def __post_init__(self):
        """Cache signatures for actions passed at construction time."""
        import inspect

        for action_name, handler in self.actions.items():
            if action_name not in self._sig_cache:
                try:
                    self._sig_cache[action_name] = inspect.signature(handler)
                except (ValueError, TypeError):
                    self._sig_cache[action_name] = None

    def register_action(self, action_name: str, handler: Callable):
        """Register an action handler."""
        import inspect

        self.actions[action_name] = handler
        # Cache signature at registration time to avoid per-call overhead
        try:
            self._sig_cache[action_name] = inspect.signature(handler)
        except (ValueError, TypeError):
            self._sig_cache[action_name] = None

    def invoke(self, action: str, payload: dict, context: dict) -> Any:
        """Invoke an action on this skill."""
        if action not in self.actions:
            raise ValueError(f"Unknown action '{action}' on skill '{self.skill_name}'")

        handler = self.actions[action]
        sig = self._sig_cache.get(action)

        if sig and "context" in sig.parameters:
            return handler(payload, context=context)
        elif sig and "_context" in sig.parameters:
            return handler(payload, _context=context)
        else:
            return handler(payload)


# ============================================================
# SHARED STATE
# ============================================================


class SharedState:
    """
    Thread-safe shared state accessible to all skills.

    Use for coordination data that multiple skills need to access.

    v1.4.0: Now supports optional persistent backends via the StateBackend interface.
    When a backend is provided, state survives process restarts.
    """

    def __init__(self, backend: "StateBackend" = None, namespace: str = "default"):
        """
        Initialize SharedState.

        Args:
            backend: Optional StateBackend for persistence (from persistence module).
                    If None, uses in-memory storage (original behavior).
            namespace: Namespace for state isolation when using persistent backend.
        """
        self._backend = backend
        self._namespace = namespace
        self._state: Dict[str, Any] = {}
        self._lock = threading.RLock()
        self._watchers: Dict[str, List[Callable]] = defaultdict(list)

        # If using persistent backend, load existing state
        if self._backend is not None:
            self._load_from_backend()

    def _load_from_backend(self):
        """Load state from persistent backend."""
        if self._backend is None:
            return
        try:
            for key in self._backend.keys(namespace=self._namespace):
                entry = self._backend.get(key, namespace=self._namespace)
                if entry is not None:
                    self._state[key] = entry.value
        except Exception as e:
            logger.warning(f"Failed to load state from backend: {e}")

    def get(self, key: str, default: Any = None) -> Any:
        """Get a value from shared state."""
        with self._lock:
            # If using backend, check for fresher data
            if self._backend is not None:
                entry = self._backend.get(key, namespace=self._namespace)
                if entry is not None:
                    self._state[key] = entry.value
                    return entry.value
                return default
            return self._state.get(key, default)

    def set(self, key: str, value: Any, notify: bool = True, ttl_seconds: int = None):
        """
        Set a value in shared state.

        Args:
            key: State key
            value: Value to store
            notify: Whether to notify watchers
            ttl_seconds: Optional TTL for persistent backend (v1.4.0)
        """
        watchers_snapshot = []
        with self._lock:
            old_value = self._state.get(key)
            self._state[key] = value

            # Persist to backend if available
            if self._backend is not None:
                try:
                    self._backend.set(
                        key, value, namespace=self._namespace, ttl_seconds=ttl_seconds
                    )
                except Exception as e:
                    logger.error(f"Failed to persist state: {e}")

            if notify and key in self._watchers:
                watchers_snapshot = list(self._watchers[key])

        # Notify watchers outside the lock to avoid deadlocks with slow callbacks
        for watcher in watchers_snapshot:
            try:
                watcher(key, old_value, value)
            except Exception as e:
                logger.error(f"State watcher error: {e}")

    def delete(self, key: str):
        """Delete a value from shared state."""
        with self._lock:
            self._state.pop(key, None)

            # Delete from backend if available
            if self._backend is not None:
                try:
                    self._backend.delete(key, namespace=self._namespace)
                except Exception as e:
                    logger.error(f"Failed to delete from backend: {e}")

    def watch(self, key: str, callback: Callable):
        """Watch for changes to a key."""
        with self._lock:
            self._watchers[key].append(callback)

    def unwatch(self, key: str, callback: Callable):
        """Stop watching a key."""
        with self._lock:
            if key in self._watchers and callback in self._watchers[key]:
                self._watchers[key].remove(callback)

    def keys(self) -> List[str]:
        """Get all keys."""
        with self._lock:
            if self._backend is not None:
                return self._backend.keys(namespace=self._namespace)
            return list(self._state.keys())

    def to_dict(self) -> dict:
        """Export state as dict (for debugging)."""
        with self._lock:
            return self._state.copy()

    # v1.4.0: New methods for persistent state
    def set_with_ttl(self, key: str, value: Any, ttl_seconds: int, notify: bool = True):
        """Set a value with TTL (requires persistent backend)."""
        self.set(key, value, notify=notify, ttl_seconds=ttl_seconds)

    def create_checkpoint(self, description: str = "") -> Optional[str]:
        """
        Create a checkpoint of current state (requires SQLite backend).

        Returns checkpoint ID or None if backend doesn't support checkpointing.
        """
        if self._backend is not None and hasattr(self._backend, "create_checkpoint"):
            return self._backend.create_checkpoint(
                namespace=self._namespace, description=description
            )
        return None

    def restore_checkpoint(self, checkpoint_id: str) -> bool:
        """
        Restore state from a checkpoint (requires SQLite backend).

        Returns True if successful.
        """
        if self._backend is not None and hasattr(self._backend, "restore_checkpoint"):
            success = self._backend.restore_checkpoint(checkpoint_id)
            if success:
                self._load_from_backend()
            return success
        return False

    @property
    def is_persistent(self) -> bool:
        """Check if state is using a persistent backend."""
        return self._backend is not None


# ============================================================
# WORKFLOW ENGINE
# ============================================================


class WorkflowStepStatus(str, Enum):
    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    SKIPPED = "skipped"


@dataclass
class WorkflowStep:
    """A step in a workflow."""

    id: str
    skill: str
    action: str
    depends_on: List[str] = field(default_factory=list)
    input_mapping: Dict[str, str] = field(default_factory=dict)  # Maps workflow context keys
    output_key: str = ""  # Where to store result in workflow context
    status: WorkflowStepStatus = WorkflowStepStatus.PENDING
    result: Any = None
    error: str = None


@dataclass
class Workflow:
    """
    A multi-step workflow coordinating multiple skills.

    Steps can have dependencies and share context.
    """

    name: str
    steps: List[WorkflowStep] = field(default_factory=list)
    context: Dict[str, Any] = field(default_factory=dict)

    # Execution state
    status: str = "draft"
    started_at: str = ""
    completed_at: str = ""

    def add_step(
        self,
        skill: str,
        action: str,
        step_id: str = None,
        depends_on: List[str] = None,
        input_mapping: Dict[str, str] = None,
        output_key: str = None,
    ):
        """Add a step to the workflow."""
        if step_id is None:
            step_id = f"step_{len(self.steps) + 1}"

        step = WorkflowStep(
            id=step_id,
            skill=skill,
            action=action,
            depends_on=depends_on or [],
            input_mapping=input_mapping or {},
            output_key=output_key or step_id,
        )

        self.steps.append(step)
        return self

    def get_ready_steps(self) -> List[WorkflowStep]:
        """Get steps that are ready to execute (dependencies met)."""
        completed_ids = {s.id for s in self.steps if s.status == WorkflowStepStatus.COMPLETED}

        ready = []
        for step in self.steps:
            if step.status != WorkflowStepStatus.PENDING:
                continue

            # Check all dependencies are complete
            if all(dep in completed_ids for dep in step.depends_on):
                ready.append(step)

        return ready

    def build_step_input(self, step: WorkflowStep) -> dict:
        """Build input for a step from workflow context."""
        input_data = {}

        for target_key, source_key in step.input_mapping.items():
            if source_key in self.context:
                input_data[target_key] = self.context[source_key]

        # Also include full context as _workflow_context
        input_data["_workflow_context"] = self.context

        return input_data


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


class SkillBus:
    """
    Central message bus for skill communication.

    Provides:
    - Pub/sub event messaging
    - RPC-style skill invocation with retry
    - Dead letter queue for failed messages
    - Shared state management (with optional persistence)
    - Workflow orchestration
    """

    def __init__(
        self,
        async_processing: bool = True,
        default_retry_policy: RetryPolicy = None,
        dead_letter_queue_size: int = 1000,
        state_backend: "StateBackend" = None,  # v1.4.0: Optional persistent backend
        state_namespace: str = "default",  # v1.4.0: Namespace for state isolation
    ):
        self._skills: Dict[str, SkillEndpoint] = {}
        self._subscriptions: List[Subscription] = []

        # v1.4.0: SharedState now supports optional persistent backends
        self._shared_state = SharedState(backend=state_backend, namespace=state_namespace)

        self._lock = threading.RLock()

        # Retry and dead letter configuration
        self._default_retry_policy = default_retry_policy or DEFAULT_RETRY_POLICY
        self._dead_letter_queue = DeadLetterQueue(max_size=dead_letter_queue_size)

        # Async message processing
        self._async = async_processing
        self._message_queue: queue.Queue = queue.Queue()
        self._worker_thread: Optional[threading.Thread] = None
        self._running = False

        # Response tracking for sync invocations
        self._pending_responses: Dict[str, queue.Queue] = {}

        if async_processing:
            self._start_worker()

    def _start_worker(self):
        """Start background message processor."""
        self._running = True
        self._worker_thread = threading.Thread(target=self._process_messages, daemon=True)
        self._worker_thread.start()

    def _process_messages(self):
        """Background thread that processes messages."""
        while self._running:
            try:
                message = self._message_queue.get(timeout=0.1)
                self._handle_message(message)
            except queue.Empty:
                continue
            except Exception as e:
                logger.error(f"Message processing error: {e}")

    def stop(self):
        """Stop the message bus."""
        self._running = False
        if self._worker_thread:
            self._worker_thread.join(timeout=2)

    # --------------------------------------------------------
    # SKILL REGISTRATION
    # --------------------------------------------------------

    def register_skill(
        self,
        skill_name: str,
        actions: Optional[Dict[str, Callable]] = None,
        metadata: Optional[Dict] = None,
    ) -> SkillEndpoint:
        """
        Register a skill on the bus.

        Args:
            skill_name: Unique identifier for the skill
            actions: Dict of action_name -> handler_function
            metadata: Additional skill metadata

        Returns:
            SkillEndpoint for further configuration
        """
        with self._lock:
            endpoint = SkillEndpoint(
                skill_name=skill_name, actions=actions or {}, metadata=metadata or {}
            )
            self._skills[skill_name] = endpoint
            logger.info(f"Registered skill: {skill_name}")
            return endpoint

    def unregister_skill(self, skill_name: str):
        """Unregister a skill."""
        with self._lock:
            self._skills.pop(skill_name, None)
            # Remove related subscriptions
            self._subscriptions = [s for s in self._subscriptions if s.skill_name != skill_name]

    def get_skill(self, skill_name: str) -> Optional[SkillEndpoint]:
        """Get a registered skill."""
        return self._skills.get(skill_name)

    def list_skills(self) -> List[str]:
        """List all registered skills."""
        return list(self._skills.keys())

    # --------------------------------------------------------
    # PUB/SUB MESSAGING
    # --------------------------------------------------------

    def subscribe(
        self,
        topic: str,
        handler: Callable,
        skill_name: str = "",
        filter_fn: Optional[Callable] = None,
    ):
        """
        Subscribe to a topic.

        Args:
            topic: Topic to subscribe to (supports ".*" wildcard suffix)
            handler: Function to call when message received
            skill_name: Name of subscribing skill (for tracking)
            filter_fn: Optional function(message) -> bool to filter messages
        """
        with self._lock:
            sub = Subscription(
                topic=topic, handler=handler, skill_name=skill_name, filter_fn=filter_fn
            )
            self._subscriptions.append(sub)
            logger.debug(f"Subscription added: {topic} ({skill_name})")

    def unsubscribe(self, topic: str, handler: Callable):
        """Unsubscribe from a topic."""
        with self._lock:
            self._subscriptions = [
                s for s in self._subscriptions if not (s.topic == topic and s.handler == handler)
            ]

    def publish(
        self,
        topic: str,
        payload: Dict[str, Any],
        source_skill: str = "",
        priority: Priority = Priority.NORMAL,
    ):
        """
        Publish an event to a topic.

        All subscribers to the topic will receive the message.
        """
        message = Message(
            type=MessageType.EVENT,
            topic=topic,
            source_skill=source_skill,
            payload=payload,
            priority=priority,
        )

        if self._async:
            self._message_queue.put(message)
        else:
            self._handle_message(message)

    def _handle_message(self, message: Message):
        """Process a message - dispatch to subscribers or handle request."""
        if message.type == MessageType.EVENT or message.type == MessageType.BROADCAST:
            self._dispatch_event(message)
        elif message.type == MessageType.REQUEST:
            self._handle_request(message)
        elif message.type == MessageType.RESPONSE:
            self._handle_response(message)

    def _dispatch_event(self, message: Message):
        """Dispatch event to matching subscribers."""
        with self._lock:
            matching = [s for s in self._subscriptions if s.matches(message)]

        for sub in matching:
            try:
                sub.handler(message)
            except Exception as e:
                logger.error(f"Subscriber error ({sub.skill_name}): {e}")
                logger.debug(traceback.format_exc())

    # --------------------------------------------------------
    # RPC-STYLE INVOCATION
    # --------------------------------------------------------

    def invoke(
        self,
        skill_name: str,
        action: str,
        payload: Dict[str, Any],
        context: Optional[Dict] = None,
        timeout: float = 30.0,
        source_skill: str = "",
        retry_policy: RetryPolicy = None,
        send_to_dlq: bool = True,
    ) -> Any:
        """
        Invoke an action on a skill (synchronous) with retry support.

        Args:
            skill_name: Target skill
            action: Action to invoke
            payload: Input data for the action
            context: Additional context to pass
            timeout: Max seconds to wait for response
            source_skill: Calling skill name (for tracking)
            retry_policy: Custom retry policy (uses default if None)
            send_to_dlq: Whether to send to dead letter queue on final failure

        Returns:
            Result from the skill action

        Raises:
            ValueError: If skill/action not found
            TimeoutError: If response not received in time
            Exception: If skill action fails after all retries

        Example:
            # Using default retry policy
            result = bus.invoke("calendar", "get_events", {"date": "today"})

            # Custom retry policy
            result = bus.invoke(
                "email", "send",
                {"to": "user@example.com", "body": "Hello"},
                retry_policy=RetryPolicy(max_attempts=5, base_delay=2.0)
            )

            # No retry
            result = bus.invoke(
                "cache", "get",
                {"key": "data"},
                retry_policy=NO_RETRY_POLICY
            )
        """
        skill = self._skills.get(skill_name)
        if not skill:
            raise ValueError(f"Skill not found: {skill_name}")

        # Use provided policy or default
        policy = retry_policy if retry_policy is not None else self._default_retry_policy

        # Track attempts for dead letter
        attempts = 0
        first_attempt_at = datetime.now().isoformat()

        def _do_invoke():
            nonlocal attempts
            attempts += 1
            return skill.invoke(action, payload, context or {})

        try:
            # Execute with retry policy
            if policy.max_attempts <= 1:
                # No retry - direct invocation
                return _do_invoke()
            else:
                return policy.execute(_do_invoke)

        except Exception as e:
            logger.error(
                f"Skill invocation failed after {attempts} attempts: {skill_name}.{action}: {e}"
            )

            # Send to dead letter queue if enabled
            if send_to_dlq:
                dead_letter = DeadLetter(
                    skill_name=skill_name,
                    action=action,
                    payload=payload,
                    context=context or {},
                    error_type=type(e).__name__,
                    error_message=str(e),
                    error_traceback=traceback.format_exc(),
                    attempts=attempts,
                    first_attempt_at=first_attempt_at,
                    last_attempt_at=datetime.now().isoformat(),
                    source_skill=source_skill,
                )
                self._dead_letter_queue.add(dead_letter)

            raise

    def invoke_no_retry(
        self,
        skill_name: str,
        action: str,
        payload: Dict[str, Any],
        context: Optional[Dict] = None,
        timeout: float = 30.0,
        source_skill: str = "",
    ) -> Any:
        """
        Invoke an action without any retry (convenience method).

        Equivalent to invoke(..., retry_policy=NO_RETRY_POLICY, send_to_dlq=False)
        """
        return self.invoke(
            skill_name=skill_name,
            action=action,
            payload=payload,
            context=context,
            timeout=timeout,
            source_skill=source_skill,
            retry_policy=NO_RETRY_POLICY,
            send_to_dlq=False,
        )

    def invoke_async(
        self,
        skill_name: str,
        action: str,
        payload: Dict[str, Any],
        callback: Callable = None,
        context: Optional[Dict] = None,
        source_skill: str = "",
    ) -> str:
        """
        Invoke an action asynchronously.

        Returns message ID for tracking. Callback will be called with result.
        """
        message = Message(
            type=MessageType.REQUEST,
            source_skill=source_skill,
            target_skill=skill_name,
            action=action,
            payload=payload,
            context=context or {},
        )

        if callback:
            # Register callback for response
            self._pending_responses[message.id] = callback

        self._message_queue.put(message)
        return message.id

    def _handle_request(self, message: Message):
        """Handle an incoming request message."""
        skill = self._skills.get(message.target_skill)

        if not skill:
            response = message.create_response(
                None, error=f"Skill not found: {message.target_skill}"
            )
        else:
            try:
                result = skill.invoke(message.action, message.payload, message.context)
                response = message.create_response(result)
            except Exception as e:
                response = message.create_response(None, error=str(e))

        self._handle_message(response)

    def _handle_response(self, message: Message):
        """Handle a response message."""
        callback = self._pending_responses.pop(message.correlation_id, None)

        if callback:
            if callable(callback):
                try:
                    result = message.payload.get("result")
                    error = message.payload.get("error")
                    callback(result, error)
                except Exception as e:
                    logger.error(f"Response callback error: {e}")
            elif isinstance(callback, queue.Queue):
                callback.put(message)

    # --------------------------------------------------------
    # SHARED STATE
    # --------------------------------------------------------

    @property
    def state(self) -> SharedState:
        """Access shared state."""
        return self._shared_state

    # --------------------------------------------------------
    # DEAD LETTER QUEUE
    # --------------------------------------------------------

    @property
    def dead_letters(self) -> DeadLetterQueue:
        """Access the dead letter queue."""
        return self._dead_letter_queue

    def get_dead_letters(self, n: int = 10, skill_name: str = None) -> List[DeadLetter]:
        """
        Get dead letters for inspection.

        Args:
            n: Maximum number to return
            skill_name: Filter by skill (optional)

        Returns:
            List of DeadLetter objects (does not remove them)
        """
        return self._dead_letter_queue.peek(n, skill_name)

    def replay_dead_letter(
        self, dead_letter_id: str = None, retry_policy: RetryPolicy = None
    ) -> Any:
        """
        Replay a dead letter (retry the failed invocation).

        Args:
            dead_letter_id: ID of specific dead letter to replay.
                           If None, replays the oldest one.
            retry_policy: Policy for this replay attempt.
                         If None, uses single attempt (no retry).

        Returns:
            Result from the skill action

        Raises:
            ValueError: If no dead letters available
            Exception: If replay fails
        """
        dl = self._dead_letter_queue.get(dead_letter_id)

        if not dl:
            raise ValueError("No dead letters to replay")

        logger.info(f"Replaying dead letter: {dl.skill_name}.{dl.action}")

        try:
            result = self.invoke(
                skill_name=dl.skill_name,
                action=dl.action,
                payload=dl.payload,
                context=dl.context,
                source_skill=dl.source_skill,
                retry_policy=retry_policy or NO_RETRY_POLICY,
                send_to_dlq=True,  # Re-add if fails again
            )
            self._dead_letter_queue.mark_replayed()
            return result
        except Exception:
            # It will be re-added to DLQ by invoke()
            raise

    def replay_all_dead_letters(
        self, skill_name: str = None, max_replays: int = 100, stop_on_failure: bool = False
    ) -> Dict[str, Any]:
        """
        Replay multiple dead letters.

        Args:
            skill_name: Only replay dead letters for this skill
            max_replays: Maximum number to attempt
            stop_on_failure: Stop replaying if one fails

        Returns:
            Dict with success/failure counts and details
        """
        results = {"attempted": 0, "succeeded": 0, "failed": 0, "failures": []}

        for _ in range(max_replays):
            # Get next dead letter
            dead_letters = self._dead_letter_queue.peek(1, skill_name)
            if not dead_letters:
                break

            dl = self._dead_letter_queue.get(dead_letters[0].id)
            if not dl:
                continue

            results["attempted"] += 1

            try:
                self.invoke(
                    skill_name=dl.skill_name,
                    action=dl.action,
                    payload=dl.payload,
                    context=dl.context,
                    source_skill=dl.source_skill,
                    retry_policy=NO_RETRY_POLICY,
                    send_to_dlq=True,
                )
                results["succeeded"] += 1
                self._dead_letter_queue.mark_replayed()

            except Exception as e:
                results["failed"] += 1
                results["failures"].append(
                    {"id": dl.id, "skill": dl.skill_name, "action": dl.action, "error": str(e)}
                )

                if stop_on_failure:
                    break

        return results

    def clear_dead_letters(self, older_than_hours: float = None) -> int:
        """
        Clear dead letters from the queue.

        Args:
            older_than_hours: Only clear entries older than this.
                            If None, clears all.

        Returns:
            Number of entries cleared
        """
        return self._dead_letter_queue.clear(older_than_hours)

    def get_dead_letter_stats(self) -> Dict[str, Any]:
        """Get dead letter queue statistics."""
        return self._dead_letter_queue.get_stats()

    # --------------------------------------------------------
    # RETRY POLICY MANAGEMENT
    # --------------------------------------------------------

    @property
    def default_retry_policy(self) -> RetryPolicy:
        """Get the default retry policy."""
        return self._default_retry_policy

    @default_retry_policy.setter
    def default_retry_policy(self, policy: RetryPolicy):
        """Set the default retry policy."""
        self._default_retry_policy = policy

    # --------------------------------------------------------
    # WORKFLOWS
    # --------------------------------------------------------

    def create_workflow(self, name: str) -> Workflow:
        """Create a new workflow."""
        return Workflow(name=name)

    def execute_workflow(
        self, workflow: Workflow, initial_context: Optional[Dict] = None, max_parallel: int = 3
    ) -> Dict[str, Any]:
        """
        Execute a workflow.

        Args:
            workflow: The workflow to execute
            initial_context: Initial data for the workflow
            max_parallel: Max steps to run in parallel

        Returns:
            Final workflow context with all step results
        """
        workflow.context = initial_context or {}
        workflow.status = "running"
        workflow.started_at = datetime.now().isoformat()

        try:
            while True:
                ready_steps = workflow.get_ready_steps()

                if not ready_steps:
                    # Check if we're done or stuck
                    pending = [s for s in workflow.steps if s.status == WorkflowStepStatus.PENDING]
                    if pending:
                        failed = [
                            s for s in workflow.steps if s.status == WorkflowStepStatus.FAILED
                        ]
                        if failed:
                            workflow.status = "failed"
                            break
                        # Stuck - shouldn't happen with valid DAG
                        logger.error(
                            f"Workflow stuck with pending steps: {[s.id for s in pending]}"
                        )
                        workflow.status = "stuck"
                        break
                    else:
                        # All done — but only if no steps failed
                        any_failed = any(
                            s.status == WorkflowStepStatus.FAILED for s in workflow.steps
                        )
                        workflow.status = "failed" if any_failed else "completed"
                        break

                # Execute ready steps (could parallelize here)
                for step in ready_steps[:max_parallel]:
                    step.status = WorkflowStepStatus.RUNNING

                    try:
                        input_data = workflow.build_step_input(step)
                        result = self.invoke(step.skill, step.action, input_data)

                        step.status = WorkflowStepStatus.COMPLETED
                        step.result = result

                        # Store result in workflow context
                        if step.output_key:
                            workflow.context[step.output_key] = result

                    except Exception as e:
                        step.status = WorkflowStepStatus.FAILED
                        step.error = str(e)
                        logger.error(f"Workflow step {step.id} failed: {e}")

        finally:
            workflow.completed_at = datetime.now().isoformat()

        return workflow.context


# ============================================================
# GLOBAL INSTANCE & CONVENIENCE FUNCTIONS
# ============================================================

_bus: Optional[SkillBus] = None
_bus_lock = threading.Lock()


def get_skill_bus() -> SkillBus:
    """Get the global skill bus instance."""
    global _bus
    if _bus is None:
        with _bus_lock:
            if _bus is None:
                _bus = SkillBus()
    return _bus


def set_skill_bus(bus: SkillBus):
    """Set the global skill bus instance."""
    global _bus
    with _bus_lock:
        if _bus:
            _bus.stop()
        _bus = bus


def publish(topic: str, payload: Dict, source: str = ""):
    """Publish an event to the global bus."""
    get_skill_bus().publish(topic, payload, source)


def subscribe(topic: str, handler: Callable, skill: str = ""):
    """Subscribe to a topic on the global bus."""
    get_skill_bus().subscribe(topic, handler, skill)


def invoke(
    skill: str, action: str, payload: Dict, context: Dict = None, retry_policy: RetryPolicy = None
) -> Any:
    """
    Invoke a skill action on the global bus with optional retry.

    Args:
        skill: Target skill name
        action: Action to invoke
        payload: Input data
        context: Additional context
        retry_policy: Custom retry policy (uses bus default if None)

    Returns:
        Result from skill action
    """
    return get_skill_bus().invoke(skill, action, payload, context, retry_policy=retry_policy)


def invoke_no_retry(skill: str, action: str, payload: Dict, context: Dict = None) -> Any:
    """Invoke a skill action without retry (convenience function)."""
    return get_skill_bus().invoke_no_retry(skill, action, payload, context)


# ============================================================
# DECORATOR FOR SKILL REGISTRATION
# ============================================================


def skill_action(skill_name: str, action_name: str = None):
    """
    Decorator to register a function as a skill action.

    Usage:
        @skill_action("calendar")
        def add_event(data: dict) -> str:
            ...

        @skill_action("calendar", "delete")
        def delete_event(data: dict) -> str:
            ...
    """

    def decorator(func):
        name = action_name or func.__name__

        bus = get_skill_bus()
        skill = bus.get_skill(skill_name)

        if not skill:
            skill = bus.register_skill(skill_name)

        skill.register_action(name, func)

        return func

    return decorator
