# 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

"""
Saga Pattern Implementation for Familiar (v1.4.0)

Provides distributed transaction management with compensation:
- Saga definitions with steps and compensations
- Orchestrated execution with automatic rollback
- Persistent saga log for recovery
- Timeout and retry handling
- Parallel step execution support

The Saga pattern manages long-running transactions across multiple
operations. If any step fails, previously completed steps are
compensated (rolled back) in reverse order.

Usage:
    from familiar.core.saga import (
        Saga, SagaStep, SagaOrchestrator,
        get_saga_orchestrator
    )

    # Define a saga for booking a trip
    saga = Saga(
        name="book_trip",
        steps=[
            SagaStep(
                name="book_flight",
                action=book_flight,
                compensation=cancel_flight,
            ),
            SagaStep(
                name="book_hotel",
                action=book_hotel,
                compensation=cancel_hotel,
            ),
            SagaStep(
                name="book_car",
                action=book_car,
                compensation=cancel_car,
            ),
        ]
    )

    # Execute the saga
    orchestrator = get_saga_orchestrator()
    result = await orchestrator.execute(saga, context={"user_id": "123"})

    if result.status == SagaStatus.COMPLETED:
        print("Trip booked successfully!")
    else:
        print(f"Booking failed: {result.error}")
        print(f"Compensations executed: {result.compensations_executed}")
"""

import asyncio
import copy
import json
import logging
import threading
import traceback
import uuid
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union

logger = logging.getLogger(__name__)


# ============================================================
# TYPES AND ENUMS
# ============================================================


class SagaStatus(str, Enum):
    """Status of a saga execution."""

    PENDING = "pending"  # Not yet started
    RUNNING = "running"  # Currently executing
    COMPLETED = "completed"  # All steps succeeded
    COMPENSATING = "compensating"  # Rolling back due to failure
    COMPENSATED = "compensated"  # Rollback completed
    FAILED = "failed"  # Failed and compensation failed
    ABORTED = "aborted"  # Manually aborted
    TIMEOUT = "timeout"  # Execution timed out


class StepStatus(str, Enum):
    """Status of a single step."""

    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    COMPENSATING = "compensating"
    COMPENSATED = "compensated"
    SKIPPED = "skipped"


class CompensationStrategy(str, Enum):
    """How to handle compensation failures."""

    RETRY = "retry"  # Retry compensation
    IGNORE = "ignore"  # Log and continue
    FAIL = "fail"  # Mark saga as failed
    MANUAL = "manual"  # Flag for manual intervention


# Type aliases
ActionFunc = Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]
CompensationFunc = Callable[[Dict[str, Any], Dict[str, Any]], Awaitable[None]]
SyncActionFunc = Callable[[Dict[str, Any]], Dict[str, Any]]
SyncCompensationFunc = Callable[[Dict[str, Any], Dict[str, Any]], None]


# ============================================================
# SAGA STEP
# ============================================================


@dataclass
class SagaStep:
    """
    A single step in a saga with action and compensation.

    The action is executed during forward progress.
    The compensation is executed during rollback if a later step fails.

    Args:
        name: Unique name for this step
        action: Async function(context) -> result_dict
        compensation: Async function(context, step_result) -> None
        timeout_seconds: Max time for this step
        retry_count: Number of retries on failure
        retry_delay_seconds: Delay between retries
        compensation_strategy: How to handle compensation failure
        parallel_group: Steps with same group run in parallel
        condition: Optional function(context) -> bool to skip step
    """

    name: str
    action: Union[ActionFunc, SyncActionFunc]
    compensation: Optional[Union[CompensationFunc, SyncCompensationFunc]] = None

    # Timeouts and retries
    timeout_seconds: float = 30.0
    retry_count: int = 0
    retry_delay_seconds: float = 1.0

    # Compensation handling
    compensation_strategy: CompensationStrategy = CompensationStrategy.RETRY
    compensation_retry_count: int = 3

    # Parallel execution
    parallel_group: Optional[str] = None

    # Conditional execution
    condition: Optional[Callable[[Dict[str, Any]], bool]] = None

    # Metadata
    description: str = ""
    tags: List[str] = field(default_factory=list)

    def __post_init__(self):
        if not self.name:
            raise ValueError("Step name is required")

    async def execute(self, context: Dict[str, Any]) -> Dict[str, Any]:
        """Execute the step action."""
        if asyncio.iscoroutinefunction(self.action):
            return await self.action(context)
        else:
            # Run sync function in executor
            loop = asyncio.get_event_loop()
            return await loop.run_in_executor(None, self.action, context)

    async def compensate(self, context: Dict[str, Any], step_result: Dict[str, Any]) -> None:
        """Execute the compensation."""
        if self.compensation is None:
            return

        if asyncio.iscoroutinefunction(self.compensation):
            await self.compensation(context, step_result)
        else:
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(None, self.compensation, context, step_result)

    def should_execute(self, context: Dict[str, Any]) -> bool:
        """Check if this step should execute based on condition."""
        if self.condition is None:
            return True
        return self.condition(context)

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "description": self.description,
            "timeout_seconds": self.timeout_seconds,
            "retry_count": self.retry_count,
            "parallel_group": self.parallel_group,
            "has_compensation": self.compensation is not None,
            "tags": self.tags,
        }


# ============================================================
# STEP EXECUTION RECORD
# ============================================================


@dataclass
class StepExecution:
    """Record of a step's execution."""

    step_name: str
    status: StepStatus = StepStatus.PENDING

    # Timing
    started_at: Optional[str] = None
    completed_at: Optional[str] = None
    duration_ms: float = 0.0

    # Results
    result: Dict[str, Any] = field(default_factory=dict)
    error: Optional[str] = None
    error_traceback: Optional[str] = None

    # Retries
    attempt_count: int = 0

    # Compensation
    compensation_status: Optional[StepStatus] = None
    compensation_error: Optional[str] = None
    compensated_at: Optional[str] = None

    def mark_started(self):
        self.status = StepStatus.RUNNING
        self.started_at = datetime.now().isoformat()
        self.attempt_count += 1

    def mark_completed(self, result: Dict[str, Any]):
        self.status = StepStatus.COMPLETED
        self.completed_at = datetime.now().isoformat()
        self.result = result
        self._calculate_duration()

    def mark_failed(self, error: Exception):
        self.status = StepStatus.FAILED
        self.completed_at = datetime.now().isoformat()
        self.error = str(error)
        self.error_traceback = traceback.format_exc()
        self._calculate_duration()

    def mark_skipped(self):
        self.status = StepStatus.SKIPPED
        self.completed_at = datetime.now().isoformat()

    def mark_compensating(self):
        self.compensation_status = StepStatus.COMPENSATING

    def mark_compensated(self):
        self.compensation_status = StepStatus.COMPENSATED
        self.compensated_at = datetime.now().isoformat()

    def mark_compensation_failed(self, error: Exception):
        self.compensation_status = StepStatus.FAILED
        self.compensation_error = str(error)

    def _calculate_duration(self):
        if self.started_at and self.completed_at:
            start = datetime.fromisoformat(self.started_at)
            end = datetime.fromisoformat(self.completed_at)
            self.duration_ms = (end - start).total_seconds() * 1000

    def to_dict(self) -> dict:
        return {
            "step_name": self.step_name,
            "status": self.status.value,
            "started_at": self.started_at,
            "completed_at": self.completed_at,
            "duration_ms": self.duration_ms,
            "result": self.result,
            "error": self.error,
            "attempt_count": self.attempt_count,
            "compensation_status": self.compensation_status.value
            if self.compensation_status
            else None,
            "compensation_error": self.compensation_error,
        }


# ============================================================
# SAGA DEFINITION
# ============================================================


@dataclass
class Saga:
    """
    A saga definition - a sequence of steps with compensations.

    Sagas represent long-running transactions that span multiple
    operations. Each step can have a compensation action that
    undoes its effect if a later step fails.
    """

    name: str
    steps: List[SagaStep] = field(default_factory=list)

    # Saga-level settings
    timeout_seconds: float = 300.0  # 5 minutes default

    # Metadata
    description: str = ""
    version: str = "1.0.0"
    tags: List[str] = field(default_factory=list)

    # Hooks
    on_start: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
    on_complete: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
    on_compensate: Optional[Callable[[Dict[str, Any], str], Awaitable[None]]] = None
    on_fail: Optional[Callable[[Dict[str, Any], Exception], Awaitable[None]]] = None

    def __post_init__(self):
        if not self.name:
            raise ValueError("Saga name is required")

        # Validate unique step names
        names = [s.name for s in self.steps]
        if len(names) != len(set(names)):
            raise ValueError("Step names must be unique within a saga")

    def add_step(self, step: SagaStep) -> "Saga":
        """Add a step to the saga (fluent interface)."""
        if any(s.name == step.name for s in self.steps):
            raise ValueError(f"Step with name '{step.name}' already exists")
        self.steps.append(step)
        return self

    def get_step(self, name: str) -> Optional[SagaStep]:
        """Get a step by name."""
        for step in self.steps:
            if step.name == name:
                return step
        return None

    def get_parallel_groups(self) -> Dict[str, List[SagaStep]]:
        """Group steps by their parallel_group."""
        groups: Dict[str, List[SagaStep]] = defaultdict(list)
        sequential: List[SagaStep] = []

        for step in self.steps:
            if step.parallel_group:
                groups[step.parallel_group].append(step)
            else:
                sequential.append(step)

        return dict(groups), sequential

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "description": self.description,
            "version": self.version,
            "timeout_seconds": self.timeout_seconds,
            "steps": [s.to_dict() for s in self.steps],
            "tags": self.tags,
        }


# ============================================================
# SAGA EXECUTION
# ============================================================


@dataclass
class SagaExecution:
    """
    Runtime state of a saga execution.

    Tracks the current status, step results, and compensation state.
    Can be persisted for recovery after failures.
    """

    # Identity
    execution_id: str = ""
    saga_name: str = ""

    # Status
    status: SagaStatus = SagaStatus.PENDING

    # Context (input and accumulated state)
    initial_context: Dict[str, Any] = field(default_factory=dict)
    current_context: Dict[str, Any] = field(default_factory=dict)

    # Step tracking
    step_executions: Dict[str, StepExecution] = field(default_factory=dict)
    current_step: Optional[str] = None
    completed_steps: List[str] = field(default_factory=list)

    # Timing
    started_at: Optional[str] = None
    completed_at: Optional[str] = None
    duration_ms: float = 0.0

    # Error handling
    error: Optional[str] = None
    error_step: Optional[str] = None
    compensations_executed: List[str] = field(default_factory=list)
    compensations_failed: List[str] = field(default_factory=list)

    # Metadata
    created_at: str = ""
    updated_at: str = ""

    def __post_init__(self):
        if not self.execution_id:
            self.execution_id = str(uuid.uuid4())
        if not self.created_at:
            self.created_at = datetime.now().isoformat()
        self.updated_at = self.created_at

    def start(self, context: Dict[str, Any]):
        """Mark saga as started."""
        self.status = SagaStatus.RUNNING
        self.initial_context = copy.deepcopy(context)
        self.current_context = copy.deepcopy(context)
        self.started_at = datetime.now().isoformat()
        self._update()

    def get_step_execution(self, step_name: str) -> StepExecution:
        """Get or create step execution record."""
        if step_name not in self.step_executions:
            self.step_executions[step_name] = StepExecution(step_name=step_name)
        return self.step_executions[step_name]

    def complete_step(self, step_name: str, result: Dict[str, Any]):
        """Mark a step as completed and merge results into context."""
        self.completed_steps.append(step_name)

        # Merge step result into context
        if result:
            self.current_context[f"_step_{step_name}"] = result
            # Also merge top-level keys
            for key, value in result.items():
                if not key.startswith("_"):
                    self.current_context[key] = value

        self._update()

    def mark_completed(self):
        """Mark saga as completed successfully."""
        self.status = SagaStatus.COMPLETED
        self.completed_at = datetime.now().isoformat()
        self._calculate_duration()
        self._update()

    def mark_compensating(self, error: str, step_name: str):
        """Mark saga as compensating due to failure."""
        self.status = SagaStatus.COMPENSATING
        self.error = error
        self.error_step = step_name
        self._update()

    def mark_compensated(self):
        """Mark saga as successfully compensated."""
        self.status = SagaStatus.COMPENSATED
        self.completed_at = datetime.now().isoformat()
        self._calculate_duration()
        self._update()

    def mark_failed(self, error: Optional[str] = None):
        """Mark saga as failed."""
        self.status = SagaStatus.FAILED
        if error:
            self.error = error
        self.completed_at = datetime.now().isoformat()
        self._calculate_duration()
        self._update()

    def mark_timeout(self):
        """Mark saga as timed out."""
        self.status = SagaStatus.TIMEOUT
        self.error = "Saga execution timed out"
        self.completed_at = datetime.now().isoformat()
        self._calculate_duration()
        self._update()

    def add_compensation_executed(self, step_name: str):
        """Record successful compensation."""
        self.compensations_executed.append(step_name)
        self._update()

    def add_compensation_failed(self, step_name: str):
        """Record failed compensation."""
        self.compensations_failed.append(step_name)
        self._update()

    def _calculate_duration(self):
        if self.started_at and self.completed_at:
            start = datetime.fromisoformat(self.started_at)
            end = datetime.fromisoformat(self.completed_at)
            self.duration_ms = (end - start).total_seconds() * 1000

    def _update(self):
        self.updated_at = datetime.now().isoformat()

    def to_dict(self) -> dict:
        return {
            "execution_id": self.execution_id,
            "saga_name": self.saga_name,
            "status": self.status.value,
            "initial_context": self.initial_context,
            "current_context": self.current_context,
            "step_executions": {k: v.to_dict() for k, v in self.step_executions.items()},
            "current_step": self.current_step,
            "completed_steps": self.completed_steps,
            "started_at": self.started_at,
            "completed_at": self.completed_at,
            "duration_ms": self.duration_ms,
            "error": self.error,
            "error_step": self.error_step,
            "compensations_executed": self.compensations_executed,
            "compensations_failed": self.compensations_failed,
            "created_at": self.created_at,
            "updated_at": self.updated_at,
        }

    def summary(self) -> str:
        """Generate a human-readable summary."""
        lines = [
            f"Saga Execution: {self.execution_id[:8]}...",
            f"Saga: {self.saga_name}",
            f"Status: {self.status.value}",
            f"Duration: {self.duration_ms:.0f}ms",
            "",
            "Steps:",
        ]

        for step_name, step_exec in self.step_executions.items():
            status_icon = {
                StepStatus.COMPLETED: "✓",
                StepStatus.FAILED: "✗",
                StepStatus.SKIPPED: "⊘",
                StepStatus.COMPENSATED: "↩",
            }.get(step_exec.status, "○")

            lines.append(f"  {status_icon} {step_name}: {step_exec.status.value}")
            if step_exec.error:
                lines.append(f"      Error: {step_exec.error[:50]}...")

        if self.error:
            lines.append("")
            lines.append(f"Error: {self.error}")

        if self.compensations_executed:
            lines.append("")
            lines.append(f"Compensated: {', '.join(self.compensations_executed)}")

        return "\n".join(lines)


# ============================================================
# SAGA LOG (Persistence)
# ============================================================


class SagaLog(ABC):
    """
    Abstract base class for saga persistence.

    Implementations can use file, database, Redis, etc.
    """

    @abstractmethod
    async def save(self, execution: SagaExecution) -> None:
        """Save or update a saga execution."""
        pass

    @abstractmethod
    async def load(self, execution_id: str) -> Optional[SagaExecution]:
        """Load a saga execution by ID."""
        pass

    @abstractmethod
    async def list_by_status(self, status: SagaStatus) -> List[SagaExecution]:
        """List executions by status."""
        pass

    @abstractmethod
    async def list_by_saga(self, saga_name: str) -> List[SagaExecution]:
        """List executions for a saga."""
        pass


class InMemorySagaLog(SagaLog):
    """In-memory saga log for development and testing."""

    def __init__(self):
        self._executions: Dict[str, SagaExecution] = {}
        self._lock = threading.RLock()

    async def save(self, execution: SagaExecution) -> None:
        with self._lock:
            self._executions[execution.execution_id] = execution

    async def load(self, execution_id: str) -> Optional[SagaExecution]:
        return self._executions.get(execution_id)

    async def list_by_status(self, status: SagaStatus) -> List[SagaExecution]:
        return [e for e in self._executions.values() if e.status == status]

    async def list_by_saga(self, saga_name: str) -> List[SagaExecution]:
        return [e for e in self._executions.values() if e.saga_name == saga_name]

    def clear(self):
        with self._lock:
            self._executions.clear()


class FileSagaLog(SagaLog):
    """File-based saga log using JSON."""

    def __init__(self, directory: str):
        self.directory = directory
        import os

        os.makedirs(directory, exist_ok=True)

    def _filepath(self, execution_id: str) -> str:
        import os

        return os.path.join(self.directory, f"{execution_id}.json")

    async def save(self, execution: SagaExecution) -> None:
        from pathlib import Path

        from .utils import atomic_write_json

        atomic_write_json(Path(self._filepath(execution.execution_id)), execution.to_dict())

    async def load(self, execution_id: str) -> Optional[SagaExecution]:
        import os

        filepath = self._filepath(execution_id)
        if not os.path.exists(filepath):
            return None

        with open(filepath, "r") as f:
            data = json.load(f)

        return self._from_dict(data)

    async def list_by_status(self, status: SagaStatus) -> List[SagaExecution]:
        import os

        results = []
        for filename in os.listdir(self.directory):
            if filename.endswith(".json"):
                exec_id = filename[:-5]
                execution = await self.load(exec_id)
                if execution and execution.status == status:
                    results.append(execution)
        return results

    async def list_by_saga(self, saga_name: str) -> List[SagaExecution]:
        import os

        results = []
        for filename in os.listdir(self.directory):
            if filename.endswith(".json"):
                exec_id = filename[:-5]
                execution = await self.load(exec_id)
                if execution and execution.saga_name == saga_name:
                    results.append(execution)
        return results

    def _from_dict(self, data: dict) -> SagaExecution:
        """Reconstruct SagaExecution from dict."""
        execution = SagaExecution(
            execution_id=data["execution_id"],
            saga_name=data["saga_name"],
            status=SagaStatus(data["status"]),
            initial_context=data.get("initial_context", {}),
            current_context=data.get("current_context", {}),
            current_step=data.get("current_step"),
            completed_steps=data.get("completed_steps", []),
            started_at=data.get("started_at"),
            completed_at=data.get("completed_at"),
            duration_ms=data.get("duration_ms", 0),
            error=data.get("error"),
            error_step=data.get("error_step"),
            compensations_executed=data.get("compensations_executed", []),
            compensations_failed=data.get("compensations_failed", []),
            created_at=data.get("created_at", ""),
            updated_at=data.get("updated_at", ""),
        )

        # Reconstruct step executions
        for name, step_data in data.get("step_executions", {}).items():
            step_exec = StepExecution(
                step_name=step_data["step_name"],
                status=StepStatus(step_data["status"]),
                started_at=step_data.get("started_at"),
                completed_at=step_data.get("completed_at"),
                duration_ms=step_data.get("duration_ms", 0),
                result=step_data.get("result", {}),
                error=step_data.get("error"),
                attempt_count=step_data.get("attempt_count", 0),
                compensation_status=StepStatus(step_data["compensation_status"])
                if step_data.get("compensation_status")
                else None,
                compensation_error=step_data.get("compensation_error"),
            )
            execution.step_executions[name] = step_exec

        return execution


# ============================================================
# SAGA ORCHESTRATOR
# ============================================================


class SagaOrchestrator:
    """
    Executes sagas with automatic compensation on failure.

    The orchestrator:
    1. Executes saga steps in order
    2. Handles retries and timeouts
    3. Automatically compensates on failure
    4. Persists execution state for recovery
    5. Supports parallel step execution

    Usage:
        orchestrator = SagaOrchestrator()

        saga = Saga(
            name="order_processing",
            steps=[
                SagaStep("reserve_inventory", reserve, unreserve),
                SagaStep("charge_payment", charge, refund),
                SagaStep("ship_order", ship, cancel_shipment),
            ]
        )

        result = await orchestrator.execute(saga, context={"order_id": "123"})
    """

    def __init__(
        self,
        saga_log: Optional[SagaLog] = None,
        default_timeout: float = 300.0,
        max_concurrent_compensations: int = 5,
    ):
        self.saga_log = saga_log or InMemorySagaLog()
        self.default_timeout = default_timeout
        self.max_concurrent_compensations = max_concurrent_compensations
        self._sagas: Dict[str, Saga] = {}
        self._lock = threading.RLock()

    def register(self, saga: Saga) -> None:
        """Register a saga definition."""
        with self._lock:
            self._sagas[saga.name] = saga
            logger.info(f"Registered saga: {saga.name}")

    def get_saga(self, name: str) -> Optional[Saga]:
        """Get a registered saga."""
        return self._sagas.get(name)

    async def execute(
        self,
        saga: Union[Saga, str],
        context: Optional[Dict[str, Any]] = None,
        execution_id: Optional[str] = None,
    ) -> SagaExecution:
        """
        Execute a saga.

        Args:
            saga: Saga definition or registered saga name
            context: Initial context data
            execution_id: Optional execution ID (for recovery)

        Returns:
            SagaExecution with final status and results
        """
        # Resolve saga
        if isinstance(saga, str):
            saga = self._sagas.get(saga)
            if not saga:
                raise ValueError(f"Saga not found: {saga}")

        context = context or {}

        # Create or load execution
        if execution_id:
            execution = await self.saga_log.load(execution_id)
            if not execution:
                raise ValueError(f"Execution not found: {execution_id}")
        else:
            execution = SagaExecution(saga_name=saga.name)

        # Execute with timeout
        try:
            timeout = saga.timeout_seconds or self.default_timeout
            result = await asyncio.wait_for(
                self._execute_saga(saga, execution, context), timeout=timeout
            )
            return result
        except asyncio.TimeoutError:
            execution.mark_timeout()
            await self._compensate(saga, execution)
            await self.saga_log.save(execution)
            return execution

    async def _execute_saga(
        self,
        saga: Saga,
        execution: SagaExecution,
        context: Dict[str, Any],
    ) -> SagaExecution:
        """Internal saga execution logic."""

        # Start
        execution.start(context)
        await self.saga_log.save(execution)

        # Call on_start hook
        if saga.on_start:
            try:
                await saga.on_start(execution.current_context)
            except Exception as e:
                logger.warning(f"on_start hook failed: {e}")

        # Execute steps
        try:
            for step in saga.steps:
                execution.current_step = step.name
                await self.saga_log.save(execution)

                # Check condition
                if not step.should_execute(execution.current_context):
                    step_exec = execution.get_step_execution(step.name)
                    step_exec.mark_skipped()
                    continue

                # Execute with retry
                result = await self._execute_step(step, execution)

                if result is None:
                    # Step failed after retries
                    raise RuntimeError(f"Step failed: {step.name}")

                execution.complete_step(step.name, result)
                await self.saga_log.save(execution)

            # All steps completed
            execution.mark_completed()
            await self.saga_log.save(execution)

            # Call on_complete hook
            if saga.on_complete:
                try:
                    await saga.on_complete(execution.current_context)
                except Exception as e:
                    logger.warning(f"on_complete hook failed: {e}")

            return execution

        except Exception as e:
            # Step failed, start compensation
            logger.error(f"Saga step failed: {e}")
            execution.mark_compensating(str(e), execution.current_step)
            await self.saga_log.save(execution)

            # Call on_compensate hook
            if saga.on_compensate:
                try:
                    await saga.on_compensate(execution.current_context, str(e))
                except Exception as hook_error:
                    logger.warning(f"on_compensate hook failed: {hook_error}")

            # Execute compensations
            await self._compensate(saga, execution)

            # Call on_fail hook
            if saga.on_fail:
                try:
                    await saga.on_fail(execution.current_context, e)
                except Exception as hook_error:
                    logger.warning(f"on_fail hook failed: {hook_error}")

            return execution

    async def _execute_step(
        self,
        step: SagaStep,
        execution: SagaExecution,
    ) -> Optional[Dict[str, Any]]:
        """Execute a single step with retry logic."""

        step_exec = execution.get_step_execution(step.name)
        attempts = 0
        max_attempts = step.retry_count + 1

        while attempts < max_attempts:
            step_exec.mark_started()

            try:
                # Execute with timeout
                result = await asyncio.wait_for(
                    step.execute(execution.current_context), timeout=step.timeout_seconds
                )

                step_exec.mark_completed(result or {})
                return result

            except asyncio.TimeoutError:
                error = TimeoutError(f"Step timed out after {step.timeout_seconds}s")
                step_exec.mark_failed(error)
                logger.warning(
                    f"Step {step.name} timed out (attempt {attempts + 1}/{max_attempts})"
                )

            except Exception as e:
                step_exec.mark_failed(e)
                logger.warning(
                    f"Step {step.name} failed (attempt {attempts + 1}/{max_attempts}): {e}"
                )

            attempts += 1

            if attempts < max_attempts:
                await asyncio.sleep(step.retry_delay_seconds)

        return None

    async def _compensate(
        self,
        saga: Saga,
        execution: SagaExecution,
    ) -> None:
        """Execute compensations for completed steps in reverse order."""

        # Get steps to compensate (completed, in reverse order)
        steps_to_compensate = []
        for step_name in reversed(execution.completed_steps):
            step = saga.get_step(step_name)
            if step and step.compensation:
                steps_to_compensate.append(step)

        if not steps_to_compensate:
            execution.mark_compensated()
            await self.saga_log.save(execution)
            return

        # Execute compensations
        all_succeeded = True

        for step in steps_to_compensate:
            step_exec = execution.get_step_execution(step.name)
            step_exec.mark_compensating()

            success = await self._execute_compensation(step, execution, step_exec)

            if success:
                step_exec.mark_compensated()
                execution.add_compensation_executed(step.name)
            else:
                step_exec.mark_compensation_failed(
                    Exception(step_exec.compensation_error or "Unknown error")
                )
                execution.add_compensation_failed(step.name)
                all_succeeded = False

                # Handle based on strategy
                if step.compensation_strategy == CompensationStrategy.FAIL:
                    break
                elif step.compensation_strategy == CompensationStrategy.MANUAL:
                    logger.error(f"Manual intervention required for {step.name}")

        # Final status
        if all_succeeded:
            execution.mark_compensated()
        else:
            execution.mark_failed("Compensation failed for some steps")

        await self.saga_log.save(execution)

    async def _execute_compensation(
        self,
        step: SagaStep,
        execution: SagaExecution,
        step_exec: StepExecution,
    ) -> bool:
        """Execute a single compensation with retry."""

        attempts = 0
        max_attempts = step.compensation_retry_count + 1

        while attempts < max_attempts:
            try:
                await step.compensate(execution.current_context, step_exec.result)
                return True

            except Exception as e:
                logger.warning(
                    f"Compensation for {step.name} failed "
                    f"(attempt {attempts + 1}/{max_attempts}): {e}"
                )
                step_exec.compensation_error = str(e)

            attempts += 1

            if attempts < max_attempts:
                await asyncio.sleep(step.retry_delay_seconds)

        return False

    async def recover(
        self,
        execution_id: str,
        saga: Optional[Saga] = None,
    ) -> SagaExecution:
        """
        Recover a failed or interrupted saga.

        Loads the execution state and continues from where it left off.
        """
        execution = await self.saga_log.load(execution_id)
        if not execution:
            raise ValueError(f"Execution not found: {execution_id}")

        saga = saga or self._sagas.get(execution.saga_name)
        if not saga:
            raise ValueError(f"Saga not found: {execution.saga_name}")

        # Determine what to do based on status
        if execution.status == SagaStatus.RUNNING:
            # Continue execution from current step
            return await self.execute(saga, execution.current_context, execution_id)

        elif execution.status == SagaStatus.COMPENSATING:
            # Continue compensation
            await self._compensate(saga, execution)
            return execution

        else:
            # Already terminal state
            return execution

    async def abort(self, execution_id: str) -> SagaExecution:
        """Abort a running saga and trigger compensation."""
        execution = await self.saga_log.load(execution_id)
        if not execution:
            raise ValueError(f"Execution not found: {execution_id}")

        if execution.status not in (SagaStatus.RUNNING, SagaStatus.PENDING):
            raise ValueError(f"Cannot abort saga in status: {execution.status}")

        saga = self._sagas.get(execution.saga_name)
        if saga:
            execution.mark_compensating("Manually aborted", execution.current_step)
            await self._compensate(saga, execution)

        execution.status = SagaStatus.ABORTED
        await self.saga_log.save(execution)

        return execution

    async def get_execution(self, execution_id: str) -> Optional[SagaExecution]:
        """Get execution by ID."""
        return await self.saga_log.load(execution_id)

    async def list_executions(
        self,
        saga_name: Optional[str] = None,
        status: Optional[SagaStatus] = None,
    ) -> List[SagaExecution]:
        """List executions with optional filters."""
        if saga_name:
            executions = await self.saga_log.list_by_saga(saga_name)
        elif status:
            executions = await self.saga_log.list_by_status(status)
        else:
            # Get all (from in-memory log)
            if isinstance(self.saga_log, InMemorySagaLog):
                executions = list(self.saga_log._executions.values())
            else:
                executions = []

        # Apply secondary filter
        if saga_name and status:
            executions = [e for e in executions if e.status == status]

        return executions


# ============================================================
# SAGA BUILDER (Fluent API)
# ============================================================


class SagaBuilder:
    """
    Fluent builder for creating sagas.

    Usage:
        saga = (SagaBuilder("order_saga")
            .with_description("Process customer order")
            .add_step("reserve", reserve_inventory, unreserve_inventory)
            .add_step("payment", charge_card, refund_card)
            .add_step("ship", ship_order, cancel_shipment)
            .with_timeout(600)
            .build())
    """

    def __init__(self, name: str):
        self._name = name
        self._description = ""
        self._version = "1.0.0"
        self._timeout = 300.0
        self._steps: List[SagaStep] = []
        self._tags: List[str] = []
        self._on_start = None
        self._on_complete = None
        self._on_compensate = None
        self._on_fail = None

    def with_description(self, description: str) -> "SagaBuilder":
        self._description = description
        return self

    def with_version(self, version: str) -> "SagaBuilder":
        self._version = version
        return self

    def with_timeout(self, seconds: float) -> "SagaBuilder":
        self._timeout = seconds
        return self

    def with_tags(self, *tags: str) -> "SagaBuilder":
        self._tags.extend(tags)
        return self

    def add_step(
        self,
        name: str,
        action: Union[ActionFunc, SyncActionFunc],
        compensation: Optional[Union[CompensationFunc, SyncCompensationFunc]] = None,
        timeout: float = 30.0,
        retries: int = 0,
        parallel_group: Optional[str] = None,
        condition: Optional[Callable[[Dict], bool]] = None,
    ) -> "SagaBuilder":
        step = SagaStep(
            name=name,
            action=action,
            compensation=compensation,
            timeout_seconds=timeout,
            retry_count=retries,
            parallel_group=parallel_group,
            condition=condition,
        )
        self._steps.append(step)
        return self

    def on_start(self, hook: Callable) -> "SagaBuilder":
        self._on_start = hook
        return self

    def on_complete(self, hook: Callable) -> "SagaBuilder":
        self._on_complete = hook
        return self

    def on_compensate(self, hook: Callable) -> "SagaBuilder":
        self._on_compensate = hook
        return self

    def on_fail(self, hook: Callable) -> "SagaBuilder":
        self._on_fail = hook
        return self

    def build(self) -> Saga:
        return Saga(
            name=self._name,
            description=self._description,
            version=self._version,
            timeout_seconds=self._timeout,
            steps=self._steps,
            tags=self._tags,
            on_start=self._on_start,
            on_complete=self._on_complete,
            on_compensate=self._on_compensate,
            on_fail=self._on_fail,
        )


# ============================================================
# GLOBAL INSTANCE
# ============================================================

_saga_orchestrator: Optional[SagaOrchestrator] = None


def get_saga_orchestrator() -> SagaOrchestrator:
    """Get the global saga orchestrator."""
    global _saga_orchestrator
    if _saga_orchestrator is None:
        _saga_orchestrator = SagaOrchestrator()
    return _saga_orchestrator


def set_saga_orchestrator(orchestrator: SagaOrchestrator) -> None:
    """Set the global saga orchestrator."""
    global _saga_orchestrator
    _saga_orchestrator = orchestrator


# ============================================================
# CONVENIENCE DECORATORS
# ============================================================


def saga_step(
    name: str,
    compensation: Optional[Union[CompensationFunc, SyncCompensationFunc]] = None,
    timeout: float = 30.0,
    retries: int = 0,
):
    """
    Decorator to mark a function as a saga step.

    Usage:
        @saga_step("reserve_inventory", compensation=unreserve)
        async def reserve_inventory(context):
            # Reserve items
            return {"reservation_id": "123"}
    """

    def decorator(func):
        func._saga_step = SagaStep(
            name=name,
            action=func,
            compensation=compensation,
            timeout_seconds=timeout,
            retry_count=retries,
        )
        return func

    return decorator


def compensates(step_name: str):
    """
    Decorator to mark a function as compensation for a step.

    Usage:
        @compensates("reserve_inventory")
        async def unreserve_inventory(context, step_result):
            # Release reservation
            pass
    """

    def decorator(func):
        func._compensates = step_name
        return func

    return decorator
