"""
AMI SDK Client - Main entry point for interacting with the AMI API.

Provides high-level interface for invoke, event ingestion, EMU management, and more.
Includes Tool Registry for unified tool registration and lifecycle-aware execution.
"""
from datetime import datetime
from pathlib import Path
from typing import Any, Awaitable, Callable, List, Union, Optional, TYPE_CHECKING

if TYPE_CHECKING:
    from memrail.prompts import PromptCreate, PromptUpdate, PromptMove, FolderCreate
from memrail.config import AMIConfig
from memrail.transport import HTTPTransport, RetryPolicy
from memrail.atoms import StateAtom, TagAtom, EventAtom, EventCorrelation
from memrail.models import (
    InvokeOptions,
    TraceOptions,
    InvokeRequest,
    InvokeResponse,
)
from memrail.serializers import (
    serialize_atoms,
    deserialize_invoke_response,
)
from memrail.errors import AMIBadRequest
from memrail.tools import (
    ToolRegistry,
    ActionExecutor,
    ClaudeBridge,
    ToolDefinition,
    ActionHandlers,
    Decision,
    ExecutionResult,
    AckResult,
    InvokeAndExecuteResult,
    ShadowMode,
    LifecycleState,
    MissingToolBehavior,
)


class AMIClient:
    """
    Main SDK client for AMI API interactions.

    Provides methods for:
    - decide(): Deterministic EMU selection (preferred)
    - invoke(): Alias for decide() (deprecated)
    - emit_event(): Single event ingestion
    - emit_events_batch(): Batch event ingestion
    - register_emu(): EMU registration
    - get_emu(), change_emu_lifecycle(): EMU management
    - ack(): Activation acknowledgment
    - get_trace(): Trace retrieval

    Example:
        ```python
        client = AMIClient(
            api_key="ami_...",
            org="acme-corp",
            team="client-team",
            workspace="production",
            project="api-backend"
        )

        response = client.invoke(
            atoms=[state("user.tier", "premium")],
            options=InvokeOptions(top_k=3)
        )

        for item in response.selected:
            print(f"EMU: {item.emu_key}, Score: {item.score}")
        ```
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: str = "https://api.memrail.com",
        org: Optional[str] = None,
        team: Optional[str] = None,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        org_id: Optional[str] = None,
        team_id: Optional[str] = None,
        workspace_id: Optional[str] = None,
        project_id: Optional[str] = None,
        timeout_s: float = 10.0,
        max_retries: int = 3,
        use_env: bool = True,
    ):
        """
        Initialize AMI client.

        Args:
            api_key: API key (falls back to AMI_API_KEY env var if use_env=True)
            base_url: API base URL
            org: Organization slug
            team: Team slug
            workspace: Workspace slug (can be overridden per-method)
            project: Project slug (can be overridden per-method)
            org_id: Organization ID (overrides org slug)
            team_id: Team ID (overrides team slug)
            workspace_id: Workspace ID (overrides workspace slug)
            project_id: Project ID (overrides project slug)
            timeout_s: Request timeout in seconds
            max_retries: Maximum number of retries for failed requests
            use_env: Load configuration from environment variables

        Raises:
            ValueError: If required configuration is missing
        """
        # Create immutable config
        self.config = AMIConfig.create(
            api_key=api_key,
            base_url=base_url,
            org=org,
            team=team,
            workspace=workspace,
            project=project,
            org_id=org_id,
            team_id=team_id,
            workspace_id=workspace_id,
            project_id=project_id,
            timeout_s=timeout_s,
            max_retries=max_retries,
            use_env=use_env,
        )

        # Create transport layer
        self.transport = HTTPTransport(self.config)

        # Initialize Tool Registry and Executor
        self._registry = ToolRegistry()
        self._executor = ActionExecutor(self._registry)
        self._claude_bridge: Optional[ClaudeBridge] = None

    def __enter__(self):
        """Context manager entry."""
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Context manager exit - cleanup resources."""
        self.close()

    def close(self):
        """Close transport and cleanup resources."""
        self.transport.close()

    def decide(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        atoms: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        context: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        decision_point: Optional[str] = None,
        idempotency_key: Optional[str] = None,
        options: Optional[InvokeOptions] = None,
        trace: Union[bool, TraceOptions] = True,
        context_ts: Optional[datetime] = None,
        correlation_trace_id: Optional[str] = None,
    ) -> InvokeResponse:
        """
        Run the AMI decision engine for deterministic EMU selection.

        This is the preferred method for EMU selection. Use ``decision_point``
        to name this invocation site for per-point analytics and topology mapping.

        Args:
            workspace: Workspace slug or ID (overrides config)
            project: Project slug or ID (overrides config)
            atoms: Context atoms for EMU evaluation
            context: Alias for atoms. Provide one or the other, not both.
            decision_point: Named decision point for analytics and topology mapping
            idempotency_key: Idempotency key for deduplication (max 256 bytes UTF-8)
            options: Invoke options (dry_run, top_k, etc.)
            trace: Enable tracing (True or TraceOptions instance)
            context_ts: Context timestamp (defaults to now)
            correlation_trace_id: Correlation trace ID for distributed tracing

        Returns:
            InvokeResponse with selected EMUs, trace data, and idempotency metadata

        Raises:
            AMIBadRequest: If idempotency key exceeds 256 bytes or validation fails
            AMINotFound: If workspace or project not found
            AMIUnauthorized: If API key is invalid
            AMIForbidden: If API key lacks required permissions
            AMIServerError: If server error occurs

        Example:
            ```python
            response = client.decide(
                decision_point="post-checkout",
                context=[
                    state("user.id", "U-123"),
                    state("user.tier", "premium"),
                    tag("intent", "upgrade")
                ],
                options=InvokeOptions(top_k=3, dry_run=False),
                trace=True
            )
            ```
        """
        return self._decide_core(
            workspace=workspace,
            project=project,
            atoms=atoms,
            context=context,
            decision_point=decision_point,
            idempotency_key=idempotency_key,
            options=options,
            trace=trace,
            context_ts=context_ts,
            correlation_trace_id=correlation_trace_id,
        )

    def invoke(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        atoms: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        context: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        decision_point: Optional[str] = None,
        idempotency_key: Optional[str] = None,
        options: Optional[InvokeOptions] = None,
        trace: Union[bool, TraceOptions] = True,
        context_ts: Optional[datetime] = None,
        correlation_trace_id: Optional[str] = None,
    ) -> InvokeResponse:
        """
        Backward-compatible alias for :meth:`decide`.

        .. deprecated::
            Use :meth:`decide` instead. This method will be removed in a future release.
        """
        return self._decide_core(
            workspace=workspace,
            project=project,
            atoms=atoms,
            context=context,
            decision_point=decision_point,
            idempotency_key=idempotency_key,
            options=options,
            trace=trace,
            context_ts=context_ts,
            correlation_trace_id=correlation_trace_id,
        )

    def _decide_core(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        atoms: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        context: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        decision_point: Optional[str] = None,
        idempotency_key: Optional[str] = None,
        options: Optional[InvokeOptions] = None,
        trace: Union[bool, TraceOptions] = False,
        context_ts: Optional[datetime] = None,
        correlation_trace_id: Optional[str] = None,
    ) -> InvokeResponse:
        """Core implementation shared by decide() and invoke()."""
        # Resolve context/atoms alias
        if context is not None and atoms is not None:
            raise AMIBadRequest("Provide either 'atoms' or 'context', not both")
        if context is not None:
            atoms = context
        if atoms is None:
            raise AMIBadRequest("Either 'atoms' or 'context' is required")

        # Resolve workspace and project (method args override config)
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("Workspace is required (config.workspace or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("Project is required (config.project or method parameter)")

        # Validate idempotency key length (max 256 bytes UTF-8)
        if idempotency_key:
            key_bytes = idempotency_key.encode("utf-8")
            if len(key_bytes) > 256:
                raise AMIBadRequest(
                    f"Idempotency key must be ≤256 bytes (got {len(key_bytes)} bytes)"
                )

        # Convert trace bool to TraceOptions
        if isinstance(trace, bool):
            trace_options = TraceOptions(enable=trace)
        else:
            trace_options = trace

        # Build correlation metadata
        correlation = None
        if correlation_trace_id:
            correlation = EventCorrelation(trace_id=correlation_trace_id)

        # Build invoke request
        invoke_request = InvokeRequest(
            context_ts=context_ts,
            correlation=correlation,
            context_atoms=atoms,
            options=options or InvokeOptions(),
            trace=trace_options,
            decision_point=decision_point,
        )

        # Serialize request payload
        payload = {
            "context_atoms": serialize_atoms(invoke_request.context_atoms, project_id=project_resolved),
            "options": {
                "dry_run": invoke_request.options.dry_run,
                "top_k": invoke_request.options.top_k,
            },
            "trace": {
                "enable": invoke_request.trace.enable,
            },
        }

        # Add optional fields
        if invoke_request.decision_point:
            payload["decision_point"] = invoke_request.decision_point
        if invoke_request.context_ts:
            payload["context_ts"] = invoke_request.context_ts.isoformat()
        if invoke_request.correlation:
            payload["correlation"] = {}
            if invoke_request.correlation.trace_id:
                payload["correlation"]["trace_id"] = invoke_request.correlation.trace_id
        if invoke_request.options.store_read_cutoff_ts:
            payload["options"]["store_read_cutoff_ts"] = invoke_request.options.store_read_cutoff_ts.isoformat()
        if invoke_request.options.pin_registry_version:
            payload["options"]["pin_registry_version"] = invoke_request.options.pin_registry_version
        if invoke_request.options.pin_policy_version:
            payload["options"]["pin_policy_version"] = invoke_request.options.pin_policy_version

        # Build request path — prefer /decide (primary endpoint)
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/ami/decide"

        # Build headers
        headers = {}
        if idempotency_key:
            headers["X-AMI-Idempotency-Key"] = idempotency_key

        # Make request with IDEMPOTENT retry policy (decide is safe to retry with idempotency key)
        retry_policy = RetryPolicy.IDEMPOTENT if idempotency_key else RetryPolicy.NEVER
        response_data = self.transport.post(
            path,
            json=payload,
            headers=headers,
            retry_policy=retry_policy,
        )

        # Deserialize response
        return deserialize_invoke_response(response_data)

    def emit_event(
        self,
        event: EventAtom,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Ingest a single event into the event stream.

        Args:
            event: Event atom to ingest
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with {"accepted": 1, "message": "..."}

        Raises:
            AMIBadRequest: If event is invalid or workspace/project missing
            AMINotFound: If workspace or project doesn't exist
            AMIRateLimited: If rate limit exceeded

        Example:
            ```python
            from memrail.atoms import event
            from datetime import datetime, timezone

            response = client.emit_event(
                event("user.login.success", ts=datetime.now(timezone.utc)),
                workspace="production",
                project="api-backend"
            )
            print(f"Accepted: {response['accepted']}")
            ```
        """
        # Resolve workspace and project (method args override config)
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Serialize event to wire format (event ingestion format, without "type" field)
        from memrail.serializers import serialize_event_for_ingestion
        event_data = serialize_event_for_ingestion(event)

        # Build request payload (API expects {"events": [...]})
        payload = {"events": [event_data]}

        # Build request path
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/events"

        # Make request (events are NOT idempotent, use NEVER retry policy)
        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

    def emit_events(
        self,
        events: List[EventAtom],
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Batch event ingestion (max 1000 events per request).

        Args:
            events: List of event atoms to ingest (max 1000)
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with {"accepted": count, "message": "..."}

        Raises:
            AMIBadRequest: If events list is empty, exceeds 1000, or contains invalid events
            AMINotFound: If workspace or project doesn't exist
            AMIRateLimited: If rate limit exceeded

        Example:
            ```python
            from memrail.atoms import event
            from datetime import datetime, timezone

            events = [
                event("user.login.success", ts=datetime.now(timezone.utc)),
                event("user.viewed.dashboard", ts=datetime.now(timezone.utc)),
                event("user.clicked.button", ts=datetime.now(timezone.utc)),
            ]

            response = client.emit_events(
                events,
                workspace="production",
                project="api-backend"
            )
            print(f"Accepted: {response['accepted']} events")
            ```
        """
        # Validate batch size
        if not events:
            raise AMIBadRequest("events list cannot be empty")
        if len(events) > 1000:
            raise AMIBadRequest(f"Maximum 1000 events per batch (got {len(events)})")

        # Resolve workspace and project (method args override config)
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Serialize events to wire format (event ingestion format, without "type" field)
        from memrail.serializers import serialize_event_for_ingestion
        events_data = [serialize_event_for_ingestion(event) for event in events]

        # Build request payload (API expects {"events": [...]})
        payload = {"events": events_data}

        # Build request path
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/events"

        # Make request (events are NOT idempotent, use NEVER retry policy)
        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

    def register_emu(
        self,
        *,
        emu_key: str,
        trigger: str,
        action: Optional[dict] = None,
        expected_utility: float = 0.8,
        confidence: float = 0.9,
        intent: Optional[str] = None,
        policy: Optional[dict] = None,
        state: str = "draft",
        metadata: Optional[dict] = None,
        decision_point: Optional[str] = None,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        validate: bool = False,
    ) -> dict:
        """
        Register an EMU (Executable Memory Unit).

        Args:
            emu_key: EMU key (slug identifier, 3-128 chars, lowercase, alphanumeric + :_.- allowed)
            trigger: Trigger DSL expression (e.g., 'state.user.tier == "premium"')
            action: Optional action definition (tool_call, decision_prompt, route, context_directive)
            expected_utility: Expected utility score (0.0-1.0, default 0.8)
            confidence: Confidence score (0.0-1.0, default 0.9)
            intent: Optional human-readable intent description
            policy: Optional policy configuration (cooldown, idempotency)
            state: EMU state (DRAFT, ACTIVE, PAUSED, DEPRECATED, default DRAFT)
            metadata: Optional metadata dictionary
            decision_point: Optional named decision point (e.g., 'triage', 'post-checkout')
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with EMU version view (emu_id, emu_key, version, created_at, etc.)

        Raises:
            AMIBadRequest: If EMU key invalid, trigger DSL syntax error, or validation fails
            AMIConflict: If EMU key already exists in this workspace (in any project)
            AMINotFound: If workspace or project doesn't exist

        Example:
            ```python
            response = client.register_emu(
                emu_key="welcome_premium_users",
                trigger='state.user.tier == "premium"',
                action={
                    "type": "tool_call",
                    "intent": "Send welcome email",
                    "tool": {
                        "tool_id": "mailer",
                        "version": "1.0.0",
                        "args": {"template": "premium_welcome"}
                    }
                },
                expected_utility=0.9,
                confidence=0.95,
                state="ACTIVE"
            )
            print(f"Registered EMU: {response['emu_id']} v{response['version']}")
            ```
        """
        # Resolve workspace and project (method args override config)
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Build policy (use default if not provided)
        if policy is None:
            policy = {
                "mode": "auto",
                "priority": 5,
                "cooldown": {
                    "seconds": 0,
                    "gate": "ack"
                },
                "idempotency": {
                    "enabled": False,
                    "boundary": "workspace",
                    "roles": "auto",
                    "scope": []
                }
            }

        # Build registration payload
        payload = {
            "emu_key": emu_key,
            "trigger": trigger,
            "expected_utility": expected_utility,
            "confidence": confidence,
            "state": state,
            "policy": policy
        }

        # Add optional fields
        if action:
            payload["action"] = action
        if intent:
            payload["intent"] = intent
        if metadata:
            payload["metadata"] = metadata
        if decision_point:
            payload["decision_point"] = decision_point

        # Build request path
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/emus"
        if validate:
            path += "?validate=true"

        # Make request (EMU registration is idempotent for same emu_key)
        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def update_emu(
        self,
        *,
        emu_key: str,
        trigger: str,
        expected_utility: float,
        confidence: float,
        policy: Optional[dict] = None,
        action: Optional[dict] = None,
        intent: Optional[str] = None,
        metadata: Optional[dict] = None,
        decision_point: Optional[str] = None,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        validate: bool = False,
    ) -> dict:
        """
        Update an existing EMU.

        Args:
            emu_key: EMU key to update
            trigger: Trigger DSL expression
            expected_utility: Expected utility (0.0-1.0)
            confidence: Confidence level (0.0-1.0)
            policy: Policy configuration (defaults provided if None)
            action: Optional action definition
            intent: Optional human-readable description
            metadata: Optional metadata dict
            decision_point: Optional named decision point (e.g., 'triage', 'post-checkout')
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with status ("updated" or "skipped"), emu details, and message

        Raises:
            AMIBadRequest: If required parameters are missing
            AMINotFound: If EMU, workspace, or project doesn't exist

        Example:
            ```python
            response = client.update_emu(
                emu_key="welcome.new_user",
                trigger="state.user.is_new == true",
                expected_utility=0.9,
                confidence=0.85,
                workspace="prod",
                project="api",
            )
            ```
        """
        # Validate required inputs
        if not emu_key:
            raise AMIBadRequest("emu_key is required")
        if not trigger:
            raise AMIBadRequest("trigger is required")
        if expected_utility is None:
            raise AMIBadRequest("expected_utility is required")
        if confidence is None:
            raise AMIBadRequest("confidence is required")

        # Resolve workspace and project
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Build payload with required fields
        payload = {
            "trigger": trigger,
            "expected_utility": expected_utility,
            "confidence": confidence,
            "policy": policy or {
                "mode": "advisory",
                "priority": 5,
                "cooldown": {"seconds": 0, "gate": "activation"}
            },
        }

        # Add optional fields
        if action:
            payload["action"] = action
        if intent:
            payload["intent"] = intent
        if metadata:
            payload["metadata"] = metadata
        if decision_point:
            payload["decision_point"] = decision_point

        # Build request path - PUT to update endpoint
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/emus/{emu_key}"
        if validate:
            path += "?validate=true"

        # Make request (EMU update is idempotent)
        response_data = self.transport.put(
            path,
            json=payload,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def propose_emu(
        self,
        emu: dict,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Propose a single EMU for registration.

        Sends EMU to POST /v1/emus/propose for validation and draft creation.

        Args:
            emu: EMU definition dict with emu_key, trigger, action, policy, metadata
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with proposal result and validation report

        Raises:
            AMIBadRequest: If EMU is invalid
            AMINotFound: If workspace or project doesn't exist

        Example:
            ```python
            emu = {"emu_key": "welcome", "trigger": "...", "action": {...}}
            response = client.propose_emu(emu, workspace="prod", project="api")
            ```
        """
        # Validate input
        if not emu:
            raise AMIBadRequest("emu cannot be empty")

        # Resolve workspace and project
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Build request path
        path = "/v1/emus/propose"

        # Build payload - single EMU object
        payload = {
            "emu_key": emu.get("emu_key"),
            "trigger": emu.get("trigger"),
        }
        if emu.get("action"):
            payload["action"] = emu["action"]
        if emu.get("policy"):
            payload["policy"] = emu["policy"]
        if emu.get("metadata"):
            payload["metadata"] = emu["metadata"]
        if emu.get("decision_point"):
            payload["decision_point"] = emu["decision_point"]

        # Make request
        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def ack(
        self,
        activation_id: str,
        status: str,
        *,
        code: Optional[str] = None,
        message: Optional[str] = None,
        metadata: Optional[dict] = None,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Acknowledge or report failure of EMU activation.

        Controls when ACK-gated cooldowns begin and creates audit events.

        Args:
            activation_id: Activation ID from invoke response
            status: Acknowledgment status ("acknowledged" or "failed")
            code: Optional status code (e.g., "SUCCESS", "TIMEOUT")
            message: Optional human-readable message
            metadata: Optional metadata dictionary (primitives only)
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with acknowledgment details (status, activation_id, emu_id, trace_id, cooldown_created)

        Raises:
            AMIBadRequest: If status is invalid or validation fails
            AMINotFound: If activation not found or workspace/project doesn't exist

        Example:
            ```python
            response = client.ack(
                activation_id="act_01H2X3Y4Z5A6B7C8D9E0F1G2H4",
                status="acknowledged",
                code="SUCCESS",
                message="Email sent successfully"
            )
            print(f"Cooldown created: {response['cooldown_created']}")
            ```
        """
        # Resolve workspace and project (method args override config)
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Build acknowledgment payload
        payload = {
            "activation_id": activation_id,
            "status": status
        }

        # Add optional fields
        if code:
            payload["code"] = code
        if message:
            payload["message"] = message
        if metadata:
            payload["metadata"] = metadata

        # Build request path
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/ami/ack"

        # Make request (ACK is NOT idempotent, use NEVER retry policy)
        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

    def get_trace(
        self,
        trace_id: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Retrieve a decision trace by trace ID.

        Args:
            trace_id: Trace ID from invoke response
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with trace details (trace_id, candidates, selected, inputs, etc.)

        Raises:
            AMINotFound: If trace not found or workspace/project doesn't exist

        Example:
            ```python
            trace = client.get_trace(
                trace_id="trc_01H2X3Y4Z5A6B7C8",
                workspace="production",
                project="api-backend"
            )
            print(f"Selected EMUs: {trace['selected']}")
            print(f"Candidates: {len(trace['candidates'])}")
            ```
        """
        # Resolve workspace and project (method args override config)
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Build request path
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/traces/{trace_id}"

        # Make request (GET is idempotent)
        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def list_workspaces(
        self,
        *,
        limit: int = 100,
        offset: int = 0,
    ) -> dict:
        """
        List workspaces for the current team with pagination.

        Args:
            limit: Maximum number of workspaces to return (default: 100)
            offset: Number of workspaces to skip (default: 0)

        Returns:
            Dict with {"workspaces": [...], "total": N}

        Example:
            ```python
            # Get first page
            result = client.list_workspaces(limit=10, offset=0)
            print(f"Total: {result['total']}, Page: {len(result['workspaces'])}")

            # Get next page
            result = client.list_workspaces(limit=10, offset=10)
            ```
        """
        # Build request path with query parameters
        path = f"/v1/workspaces?limit={limit}&offset={offset}"

        # Make request
        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def purge_workspace(
        self,
        *,
        workspace: Optional[str] = None,
        confirm: Optional[str] = None,
        targets: Optional[List[str]] = None,
    ) -> dict:
        """
        Purge all data within a workspace without deleting the workspace itself.

        Use cases: dev/staging environment resets, demo cleanup, post-experimentation cleanup.
        Requires an org-level API key.

        Args:
            workspace: Workspace slug or ID (overrides config). Defaults to config workspace.
            confirm: Safety confirmation string (must match workspace slug). Defaults to workspace slug.
            targets: Optional list of target categories to purge. None = purge all.
                     Valid: "emus", "traces", "events", "asr", "tools", "cooldowns",
                            "prompts", "hindsight", "decision_points"

        Returns:
            Dict with {"workspace": str, "purged": {target: count}, "total": int}

        Example:
            ```python
            # Purge everything in dev workspace
            result = client.purge_workspace(workspace="development", confirm="development")
            print(f"Purged {result['total']} records")

            # Selective purge — only traces and events
            result = client.purge_workspace(
                workspace="staging",
                confirm="staging",
                targets=["traces", "events"]
            )
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")

        payload: dict = {"confirm": confirm or workspace_resolved}
        if targets is not None:
            payload["targets"] = targets

        return self.transport.post(
            f"/v1/workspaces/{workspace_resolved}/purge",
            json=payload,
        )

    def list_projects(
        self,
        workspace: str,
        *,
        limit: int = 100,
        offset: int = 0,
    ) -> dict:
        """
        List projects in workspace with pagination.

        Args:
            workspace: Workspace slug or ID
            limit: Maximum number of projects to return (default: 100)
            offset: Number of projects to skip (default: 0)

        Returns:
            Dict with {"projects": [...], "total": N}

        Example:
            ```python
            # Get all projects in workspace
            result = client.list_projects("production")
            print(f"Total projects: {result['total']}")

            # Paginated access
            result = client.list_projects("production", limit=10, offset=0)
            ```
        """
        # Build request path with query parameters
        path = f"/v1/workspaces/{workspace}/projects?limit={limit}&offset={offset}"

        # Make request
        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def create_workspace(
        self,
        slug: str,
        name: str,
    ) -> dict:
        """
        Create workspace within current team context.

        Args:
            slug: Workspace slug (URL-safe identifier, unique within team)
            name: Human-readable workspace name

        Returns:
            Dict with workspace details (workspace_id, slug, name, org_id, team_id, created_at, status)

        Raises:
            AMIConflict: If workspace with same slug already exists
            AMIBadRequest: If slug or name is invalid

        Example:
            ```python
            workspace = client.create_workspace(
                slug="production",
                name="Production Environment"
            )
            print(f"Created workspace: {workspace['workspace_id']}")
            ```
        """
        # Build request payload
        payload = {
            "slug": slug,
            "name": name
        }

        # Build request path (uses team context from config)
        path = "/v1/workspaces"

        # Make request
        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def get_workspace(
        self,
        workspace: str,
    ) -> dict:
        """
        Get workspace details by slug or ID.

        Args:
            workspace: Workspace slug or ID

        Returns:
            Dict with workspace details (workspace_id, slug, name, org_id, team_id, created_at, status)

        Raises:
            AMINotFound: If workspace doesn't exist

        Example:
            ```python
            workspace = client.get_workspace("production")
            print(f"Workspace: {workspace['name']}")
            ```
        """
        # Build request path
        path = f"/v1/workspaces/{workspace}"

        # Make request
        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data
    def create_project(
        self,
        workspace: str,
        slug: str,
        name: str,
    ) -> dict:
        """
        Create project within workspace.

        Args:
            workspace: Workspace slug or ID
            slug: Project slug (URL-safe identifier, unique within workspace)
            name: Human-readable project name

        Returns:
            Dict with project details (project_id, slug, name, workspace_id, team_id, org_id, created_at, status)

        Raises:
            AMIConflict: If project with same slug already exists
            AMINotFound: If workspace doesn't exist
            AMIBadRequest: If slug or name is invalid

        Example:
            ```python
            project = client.create_project(
                workspace="production",
                slug="api-backend",
                name="API Backend"
            )
            print(f"Created project: {project['project_id']}")
            ```
        """
        # Build request payload
        payload = {
            "slug": slug,
            "name": name
        }

        # Build request path
        path = f"/v1/workspaces/{workspace}/projects"

        # Make request
        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def get_project(
        self,
        workspace: str,
        project: str,
    ) -> dict:
        """
        Get project details by slug or ID.

        Args:
            workspace: Workspace slug or ID
            project: Project slug or ID

        Returns:
            Dict with project details (project_id, slug, name, workspace_id, team_id, org_id, created_at, status)

        Raises:
            AMINotFound: If project or workspace doesn't exist

        Example:
            ```python
            project = client.get_project("production", "api-backend")
            print(f"Project: {project['name']}")
            ```
        """
        # Build request path
        path = f"/v1/workspaces/{workspace}/projects/{project}"

        # Make request
        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def list_prompts(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        limit: int = 50,
        offset: int = 0,
        category: Optional[str] = None,
        model: Optional[str] = None,
        tags: Optional[List[str]] = None,
    ) -> dict:
        """
        List prompts in a project with optional filtering.

        Args:
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)
            limit: Maximum number of prompts to return (default: 50, max: 100)
            offset: Pagination offset (default: 0)
            category: Filter by category
            model: Filter by model
            tags: Filter by tags (list of tag strings)

        Returns:
            Dict with {"items": [...], "folders": [...], "total": N}

        Raises:
            AMINotFound: If workspace or project doesn't exist
            AMIBadRequest: If workspace or project is not specified

        Example:
            ```python
            # List all prompts in a project
            result = client.list_prompts(
                workspace="development",
                project="hindsight-prompts"
            )
            for prompt in result["items"]:
                print(f"Prompt: {prompt['slug']} - {prompt['name']}")

            # Filter by category
            result = client.list_prompts(
                workspace="development",
                project="hindsight-prompts",
                category="hindsight"
            )
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Build query parameters
        params = [f"limit={limit}", f"offset={offset}"]
        if category:
            params.append(f"category={category}")
        if model:
            params.append(f"model={model}")
        if tags:
            params.append(f"tags={','.join(tags)}")

        query_string = "&".join(params)
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts?{query_string}"

        # Make request (GET is idempotent)
        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def get_prompt(
        self,
        slug: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        version: Optional[int] = None,
    ) -> dict:
        """
        Get a prompt by slug.

        Args:
            slug: Prompt slug
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)
            version: Specific version to retrieve (default: active version)

        Returns:
            Dict with prompt details including content, variables, version info

        Raises:
            AMINotFound: If prompt, workspace, or project doesn't exist
            AMIBadRequest: If workspace or project is not specified

        Example:
            ```python
            # Get active version
            prompt = client.get_prompt(
                "trace-analysis",
                workspace="development",
                project="hindsight-prompts"
            )
            print(f"Content: {prompt['content']}")

            # Get specific version
            prompt = client.get_prompt(
                "trace-analysis",
                workspace="development",
                project="hindsight-prompts",
                version=2
            )
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Build request path
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/{slug}"
        if version is not None:
            path = f"{path}?version={version}"

        # Make request (GET is idempotent)
        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def create_prompt(
        self,
        data: Union["PromptCreate", dict],
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Create a new prompt with initial version.

        Args:
            data: PromptCreate object or dict with prompt data
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with created prompt details (prompt_id, slug, version, etc.)

        Raises:
            AMIConflict: If prompt slug already exists
            AMIBadRequest: If validation fails
            AMINotFound: If workspace or project doesn't exist

        Example:
            ```python
            from memrail import PromptCreate

            prompt = client.create_prompt(
                PromptCreate(
                    slug="welcome-email",
                    name="Welcome Email",
                    content="Hello {{user_name}}!",
                    category="system",
                ),
                workspace="development",
                project="my-prompts"
            )
            print(f"Created: {prompt['slug']} v{prompt['latest_version']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Convert dataclass to dict if needed
        if hasattr(data, "to_dict"):
            payload = data.to_dict()
        elif hasattr(data, "__dataclass_fields__"):
            from dataclasses import asdict
            payload = asdict(data)
        else:
            payload = data

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts"

        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

    def update_prompt(
        self,
        slug: str,
        data: Union["PromptUpdate", dict],
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Update a prompt, creating a new version.

        Args:
            slug: Prompt slug to update
            data: PromptUpdate object or dict with fields to update
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with updated prompt details and new version number

        Raises:
            AMINotFound: If prompt doesn't exist
            AMIBadRequest: If validation fails

        Example:
            ```python
            from memrail import PromptUpdate

            result = client.update_prompt(
                "welcome-email",
                PromptUpdate(
                    content="Updated: Hello {{user_name}}!",
                    change_reason="Simplified greeting",
                ),
                workspace="development",
                project="my-prompts"
            )
            print(f"Now at v{result['latest_version']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Convert dataclass to dict if needed
        if hasattr(data, "to_dict"):
            payload = data.to_dict()
        elif hasattr(data, "__dataclass_fields__"):
            from dataclasses import asdict
            payload = asdict(data)
        else:
            payload = data

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/{slug}"

        response_data = self.transport.put(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

    def delete_prompt(
        self,
        slug: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Delete a prompt (soft delete).

        Args:
            slug: Prompt slug to delete
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with deleted prompt info and deletion timestamp

        Raises:
            AMINotFound: If prompt doesn't exist

        Example:
            ```python
            result = client.delete_prompt(
                "old-prompt",
                workspace="development",
                project="my-prompts"
            )
            print(f"Deleted at {result['deleted_at']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/{slug}"

        response_data = self.transport.delete(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def list_prompt_versions(
        self,
        slug: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        limit: int = 20,
        offset: int = 0,
    ) -> dict:
        """
        List all versions of a prompt.

        Args:
            slug: Prompt slug
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)
            limit: Maximum versions to return (default: 20)
            offset: Pagination offset (default: 0)

        Returns:
            Dict with {"items": [...], "total": N, "active_version": N}

        Raises:
            AMINotFound: If prompt doesn't exist

        Example:
            ```python
            versions = client.list_prompt_versions(
                "welcome-email",
                workspace="development",
                project="my-prompts"
            )
            for v in versions["items"]:
                active = " (active)" if v["is_active"] else ""
                print(f"v{v['version']}{active} - {v['created_at']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/{slug}/versions?limit={limit}&offset={offset}"

        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def get_prompt_version(
        self,
        slug: str,
        version: int,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Get a specific version of a prompt.

        Args:
            slug: Prompt slug
            version: Version number to retrieve
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with version details including content

        Raises:
            AMINotFound: If prompt or version doesn't exist

        Example:
            ```python
            v2 = client.get_prompt_version(
                "welcome-email",
                version=2,
                workspace="development",
                project="my-prompts"
            )
            print(f"v{v2['version']} content: {v2['content']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/{slug}/versions/{version}"

        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def activate_prompt_version(
        self,
        slug: str,
        version: int,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Activate a specific version of a prompt (rollback).

        Changes which version is returned by default without creating a new version.

        Args:
            slug: Prompt slug
            version: Version number to activate
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with updated active_version and latest_version

        Raises:
            AMINotFound: If prompt or version doesn't exist

        Example:
            ```python
            # Rollback to version 2
            result = client.activate_prompt_version(
                "welcome-email",
                version=2,
                workspace="development",
                project="my-prompts"
            )
            print(f"Active: v{result['active_version']}, Latest: v{result['latest_version']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/{slug}/activate?version={version}"

        response_data = self.transport.post(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def create_prompt_folder(
        self,
        data: Union["FolderCreate", dict],
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Create a prompt folder.

        Parent folders are created automatically if they don't exist.

        Args:
            data: FolderCreate object or dict with folder data
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with created folder details

        Raises:
            AMIConflict: If folder already exists
            AMIBadRequest: If validation fails

        Example:
            ```python
            from memrail import FolderCreate

            folder = client.create_prompt_folder(
                FolderCreate(name="tier-1", path="support/tier-1"),
                workspace="development",
                project="my-prompts"
            )
            print(f"Created folder: {folder['path']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Convert dataclass to dict if needed
        if hasattr(data, "to_dict"):
            payload = data.to_dict()
        elif hasattr(data, "__dataclass_fields__"):
            from dataclasses import asdict
            payload = asdict(data)
        else:
            payload = data

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/folders"

        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

    def list_prompt_folders(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        parent_path: Optional[str] = None,
    ) -> List[dict]:
        """
        List prompt folders.

        Args:
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)
            parent_path: Optional parent path to list children of

        Returns:
            List of folder dicts with path, name, and prompt_count

        Example:
            ```python
            folders = client.list_prompt_folders(
                workspace="development",
                project="my-prompts"
            )
            for folder in folders:
                print(f"{folder['path']} ({folder['prompt_count']} prompts)")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/folders"
        if parent_path:
            path = f"{path}?parent_path={parent_path}"

        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def delete_prompt_folder(
        self,
        folder_path: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        force: bool = False,
    ) -> dict:
        """
        Delete a prompt folder.

        Args:
            folder_path: Folder path to delete
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)
            force: If True, move contained prompts to root instead of failing

        Returns:
            Dict with deleted folder info

        Raises:
            AMINotFound: If folder doesn't exist
            AMIBadRequest: If folder contains prompts and force=False

        Example:
            ```python
            # Delete empty folder
            client.delete_prompt_folder(
                "old-folder",
                workspace="development",
                project="my-prompts"
            )

            # Force delete, moving prompts to root
            client.delete_prompt_folder(
                "old-folder",
                workspace="development",
                project="my-prompts",
                force=True
            )
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/folders/{folder_path}"
        if force:
            path = f"{path}?force=true"

        response_data = self.transport.delete(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def move_prompt(
        self,
        slug: str,
        data: Union["PromptMove", dict],
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Move a prompt to a different folder.

        Args:
            slug: Prompt slug to move
            data: PromptMove object or dict with target folder_path
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with updated prompt details

        Raises:
            AMINotFound: If prompt or target folder doesn't exist

        Example:
            ```python
            from memrail import PromptMove

            # Move to a folder
            result = client.move_prompt(
                "welcome-email",
                PromptMove(folder_path="archive/2024"),
                workspace="development",
                project="my-prompts"
            )

            # Move to root
            result = client.move_prompt(
                "welcome-email",
                PromptMove(folder_path=None),
                workspace="development",
                project="my-prompts"
            )
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Convert dataclass to dict if needed
        if hasattr(data, "to_dict"):
            payload = data.to_dict()
        elif hasattr(data, "__dataclass_fields__"):
            from dataclasses import asdict
            payload = asdict(data)
        else:
            payload = data

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/{slug}/move"

        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

    def get_prompt_analytics(
        self,
        slug: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        period: str = "7d",
    ) -> dict:
        """
        Get usage analytics for a prompt.

        Args:
            slug: Prompt slug
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)
            period: Time period - "24h", "7d", "30d", or "all" (default: "7d")

        Returns:
            Dict with analytics including total_fetches, version_breakdown, sdk_breakdown

        Example:
            ```python
            analytics = client.get_prompt_analytics(
                "welcome-email",
                workspace="development",
                project="my-prompts",
                period="30d"
            )
            print(f"Total fetches: {analytics['total_fetches']}")
            for v in analytics["version_breakdown"]:
                print(f"  v{v['version']}: {v['fetches']} ({v['percentage']}%)")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/prompts/{slug}/analytics?period={period}"

        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    # =========================================================================
    # Tool Registry API Methods
    # =========================================================================

    def list_tool_schemas(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        List executor schemas for a project.

        Args:
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with {"schemas": [...]}

        Example:
            ```python
            result = client.list_tool_schemas(
                workspace="development",
                project="my-agent"
            )
            for schema in result["schemas"]:
                print(f"Schema: {schema['name']} - {schema['version']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/executor-schema"

        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def get_tool_schema(
        self,
        schema_id: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Get a specific executor schema by ID.

        Note: Currently returns from list filtered by ID since there's no
        direct endpoint. Use list_tool_schemas and filter if needed.

        Args:
            schema_id: Schema ID to retrieve
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with schema details including tools

        Raises:
            AMINotFound: If schema not found
        """
        result = self.list_tool_schemas(workspace=workspace, project=project)
        for schema in result.get("schemas", []):
            if schema.get("schema_id") == schema_id:
                return schema
        from memrail.errors import AMINotFound
        raise AMINotFound(f"Schema '{schema_id}' not found")

    def delete_tool_schema(
        self,
        schema_id: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> None:
        """
        Delete an executor schema.

        Args:
            schema_id: Schema ID to delete
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Raises:
            AMINotFound: If schema not found
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/executor-schema/{schema_id}"

        self.transport.delete(
            path,
            retry_policy=RetryPolicy.NEVER,
        )

    def list_server_tools(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        List all tools from the latest executor schema for a project.

        Args:
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with {"tools": [...], "total": N, "schema_id": "...", "schema_version": "..."}

        Example:
            ```python
            result = client.list_server_tools(
                workspace="development",
                project="my-agent"
            )
            for tool in result["tools"]:
                print(f"Tool: {tool['name']} - {tool['description']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/tools"

        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def get_server_tool(
        self,
        tool_name: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Get a single tool by name from the latest executor schema.

        Args:
            tool_name: Tool name to retrieve
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with tool details including input_schema, capabilities, etc.

        Raises:
            AMINotFound: If tool not found
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/tools/{tool_name}"

        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def check_action_connectivity(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        schema_id: Optional[str] = None,
    ) -> dict:
        """
        Check action connectivity for all EMUs in a project.

        Validates that all EMU actions have corresponding handlers registered
        in the executor schema.

        Args:
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)
            schema_id: Optional specific schema ID to check against

        Returns:
            Dict with validation_results, missing_tools, orphaned_tools, etc.

        Example:
            ```python
            result = client.check_action_connectivity(
                workspace="development",
                project="my-agent"
            )
            if result["missing_tools"]:
                print(f"Missing tools: {result['missing_tools']}")
            for issue in result["validation_results"]:
                print(f"{issue['severity']}: {issue['message']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/action-connectivity"

        payload = {}
        if schema_id:
            payload["schema_id"] = schema_id

        response_data = self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    def list_emus(
        self,
        *,
        org: Optional[str] = None,
        team: Optional[str] = None,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> dict:
        """
        List EMUs with hierarchical filtering via /v1/emus endpoint.

        Can filter at any level: org, team, workspace, or project.
        Supports both slugs and IDs for all filter parameters.
        Uses X-AMI-Org and X-AMI-Team headers for context.

        Args:
            org: Filter by organization slug or ID
            team: Filter by team slug or ID
            workspace: Filter by workspace slug or ID
            project: Filter by project slug or ID
            limit: Maximum number of EMUs to return (default: 100, max: 500)
            offset: Number of EMUs to skip (default: 0)

        Returns:
            Dict with pagination info:
            - emus: List of EMU dicts with emu_key, state, trigger, action, org_slug, etc.
            - total: Total number of EMUs matching the filter
            - count: Number of EMUs returned in this page
            - limit: Limit used for this request
            - offset: Offset used for this request
            - has_more: True if there are more EMUs to fetch

        Example:
            ```python
            # List all EMUs for the configured org/team
            result = client.list_emus()
            for emu in result["emus"]:
                print(f"EMU: {emu['emu_key']} - {emu['state']}")

            # Pagination
            result = client.list_emus(limit=50, offset=0)
            print(f"Showing {result['count']} of {result['total']} EMUs")
            if result["has_more"]:
                next_page = client.list_emus(limit=50, offset=50)
            ```
        """
        # Build query parameters - use slug-based parameter names
        params = []
        if org:
            params.append(f"org={org}")
        if team:
            params.append(f"team={team}")
        if workspace:
            params.append(f"workspace={workspace}")
        if project:
            params.append(f"project={project}")
        params.append(f"limit={limit}")
        params.append(f"offset={offset}")

        query_string = "&".join(params)
        path = f"/v1/emus?{query_string}"

        # Make request (GET is idempotent)
        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        # Return full response dict with pagination info
        # Response format: {emus: [...], total: N, count: N, limit: N, offset: N, has_more: bool}
        if isinstance(response_data, dict) and "emus" in response_data:
            return response_data
        elif isinstance(response_data, list):
            # Legacy format - wrap in dict
            return {"emus": response_data, "total": len(response_data), "count": len(response_data), "has_more": False}
        elif isinstance(response_data, dict) and "data" in response_data:
            return {"emus": response_data["data"], "total": len(response_data["data"]), "count": len(response_data["data"]), "has_more": False}
        else:
            emus = [response_data] if response_data else []
            return {"emus": emus, "total": len(emus), "count": len(emus), "has_more": False}

    def get_emu(
        self,
        emu_key: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """
        Get latest EMU version by key.

        Args:
            emu_key: EMU key (slug identifier)
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            Dict with EMU version details (emu_id, emu_key, version, trigger, action, policy, etc.)

        Raises:
            AMINotFound: If EMU not found or workspace/project doesn't exist
            AMIBadRequest: If emu_key is invalid

        Example:
            ```python
            emu = client.get_emu(
                emu_key="welcome_premium_users",
                workspace="production",
                project="api-backend"
            )
            print(f"EMU: {emu['emu_key']} v{emu['version']}")
            print(f"Trigger: {emu['trigger']}")
            print(f"State: {emu['state']}")
            ```
        """
        # Resolve workspace and project (method args override config)
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required (config or method parameter)")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required (config or method parameter)")

        # Build request path
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/emus/{emu_key}"

        # Make request (GET is idempotent)
        response_data = self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data
    def get_config(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """Get effective configuration with 4-level cascade."""
        params = []
        if workspace:
            params.append(f"workspace={workspace}")
        if project:
            params.append(f"project={project}")
        query_string = "&".join(params) if params else ""
        path = f"/v1/config?{query_string}" if query_string else "/v1/config"
        response_data = self.transport.get(path, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

    def update_config(self, config: dict) -> dict:
        """Update team-level configuration."""
        path = "/v1/config"
        response_data = self.transport.put(path, json=config, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

    def update_emu_lifecycle(
        self,
        emu_key: str,
        state: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """Change EMU lifecycle state (shadow/canary/active/inactive/archived)."""
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id
        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required")
        path = f"/v1/workspaces/{workspace_resolved}/projects/{project_resolved}/emus/{emu_key}/lifecycle"
        payload = {"state": state}
        response_data = self.transport.post(path, json=payload, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

    def get_emu_validator(
        self,
        emu_key: str,
        *,
        workspace: Optional[str] = None,
    ) -> dict:
        """
        Get validation report for a single EMU.

        Returns reachability analysis, warning taxonomy, action variable validation,
        and alias suggestions. Uses cached reports when fresh (<1 hour), otherwise
        runs on-demand validation.

        Args:
            emu_key: EMU key to validate
            workspace: Workspace slug/ID (sent as X-AMI-Workspace header)

        Returns:
            Dict with validation report including:
            - passed: bool (True if no errors)
            - warnings: List of warning objects with code, severity, remediation
            - reachability: {total_dependencies, reachable_dependencies, unreachable_dependencies}
            - action_variables: Variable validation details
            - alias_suggestions: Suggested fixes for unknown entities

        Example:
            ```python
            report = client.get_emu_validator("welcome-email", workspace="production")
            if not report["passed"]:
                for w in report["warnings"]:
                    print(f"[{w['severity']}] {w['code']}: {w['message']}")
                    if w.get("remediation"):
                        print(f"  Fix: {w['remediation']}")
            ```
        """
        path = f"/v1/emus/{emu_key}/validator"
        headers = {}
        if workspace:
            headers["X-AMI-Workspace"] = workspace

        response_data = self.transport.get(
            path,
            headers=headers,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )
        return response_data

    def validate_workspace_emus(
        self,
        *,
        workspace: Optional[str] = None,
    ) -> dict:
        """
        Get validation reports for all EMUs in a workspace.

        Returns a bulk validation report covering every EMU in the workspace,
        useful for identifying which EMUs need updates, have reachability issues,
        or reference unregistered tools.

        Args:
            workspace: Workspace slug/ID (sent as X-AMI-Workspace header).
                       Falls back to config workspace if not provided.

        Returns:
            Dict with:
            - workspace_id: The resolved workspace ID
            - total: Number of EMUs validated
            - reports: List of {emu_key, state, report} where report is a full
              validation report (same schema as get_emu_validator)

        Example:
            ```python
            result = client.validate_workspace_emus(workspace="production")
            print(f"Validated {result['total']} EMUs")

            for item in result["reports"]:
                report = item["report"]
                status = "PASS" if report["passed"] else "FAIL"
                print(f"  [{status}] {item['emu_key']} ({item['state']})")
                for w in report.get("warnings", []):
                    if w["severity"] == "error":
                        print(f"    ERROR: {w['message']}")
            ```
        """
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        path = "/v1/emus/validator"
        headers = {}
        if workspace_resolved:
            headers["X-AMI-Workspace"] = workspace_resolved

        response_data = self.transport.get(
            path,
            headers=headers,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )
        return response_data

    # =========================================================================
    # Tool Registry Methods
    # =========================================================================

    def tool(
        self,
        name: str,
        description: str,
        schema: dict[str, Any],
        projects: Optional[list[str]] = None,
        *,
        capabilities: Optional[list[str]] = None,
        constraints: Optional[list[str]] = None,
        examples: Optional[list[dict]] = None,
        idempotency_key: Optional[str] = None,
        rate_limit: Optional[str] = None,
        timeout_ms: Optional[int] = None,
    ) -> Callable:
        """
        Decorator for registering a tool.

        Args:
            name: Tool identifier (matches tool_id in EMU actions)
            description: Human-readable description
            schema: Input schema (Python types or JSON Schema)
            projects: List of project IDs this tool belongs to (defaults to client's project)
            capabilities: List of capability tags
            constraints: List of constraint descriptions
            examples: List of example inputs/outputs
            idempotency_key: Template for generating idempotency keys
            rate_limit: Rate limit declaration
            timeout_ms: Execution timeout in milliseconds

        Returns:
            Decorator function

        Example:
            ```python
            @client.tool(
                name="send_email",
                description="Send an email",
                schema={"to": str, "subject": str, "body": str},
                capabilities=["communication"],
            )
            async def send_email(args: dict) -> dict:
                return {"sent": True, "message_id": "msg_123"}
            ```
        """
        # Default to client's project if not specified
        resolved_projects = projects
        if resolved_projects is None:
            project_id = self.config.project_id or self.config.project
            resolved_projects = [project_id] if project_id else ["_default"]

        return self._registry.tool(
            name=name,
            description=description,
            schema=schema,
            projects=resolved_projects,
            capabilities=capabilities,
            constraints=constraints,
            examples=examples,
            idempotency_key=idempotency_key,
            rate_limit=rate_limit,
            timeout_ms=timeout_ms,
        )

    def register_tool(
        self,
        name: str,
        description: str,
        schema: dict[str, Any],
        projects: Optional[list[str]] = None,
        handler: Optional[Callable[[dict], Awaitable[dict[str, Any]]]] = None,
        *,
        capabilities: Optional[list[str]] = None,
        constraints: Optional[list[str]] = None,
        examples: Optional[list[dict]] = None,
        idempotency_key: Optional[str] = None,
        rate_limit: Optional[str] = None,
        timeout_ms: Optional[int] = None,
    ) -> ToolDefinition:
        """
        Programmatically register a tool.

        Args:
            name: Tool identifier
            description: Human-readable description
            schema: Input schema
            projects: List of project IDs this tool belongs to (defaults to client's project)
            handler: Async function to execute the tool
            capabilities: List of capability tags
            constraints: List of constraint descriptions
            examples: List of example inputs/outputs
            idempotency_key: Template for generating idempotency keys
            rate_limit: Rate limit declaration
            timeout_ms: Execution timeout in milliseconds

        Returns:
            The registered ToolDefinition
        """
        # Default to client's project if not specified
        resolved_projects = projects
        if resolved_projects is None:
            project_id = self.config.project_id or self.config.project
            resolved_projects = [project_id] if project_id else ["_default"]

        return self._registry.register_tool(
            name=name,
            description=description,
            schema=schema,
            projects=resolved_projects,
            handler=handler,
            capabilities=capabilities,
            constraints=constraints,
            examples=examples,
            idempotency_key=idempotency_key,
            rate_limit=rate_limit,
            timeout_ms=timeout_ms,
        )

    @property
    def on_route(self) -> Callable:
        """
        Decorator to register a route action handler.

        Example:
            ```python
            @client.on_route
            async def handle_route(destination: str, metadata: dict) -> dict:
                await queue.enqueue(destination, metadata)
                return {"queued": True}
            ```
        """
        return self._registry.on_route

    @property
    def on_decision_prompt(self) -> Callable:
        """
        Decorator to register a decision prompt handler.

        Example:
            ```python
            @client.on_decision_prompt
            async def handle_prompt(prompt: str, options: list) -> dict:
                response = await llm.complete(prompt)
                return {"response": response}
            ```
        """
        return self._registry.on_decision_prompt

    def get_tool(self, name: str) -> Optional[ToolDefinition]:
        """
        Get a tool by name.

        Args:
            name: Tool identifier

        Returns:
            ToolDefinition or None if not found
        """
        return self._registry.get_tool(name)

    def list_tools(self) -> list[ToolDefinition]:
        """
        List all registered tools.

        Returns:
            List of all ToolDefinition objects
        """
        return self._registry.list_tools()

    def get_action_handlers(self) -> ActionHandlers:
        """
        Get the action handlers registry.

        Returns:
            ActionHandlers instance with handler status
        """
        return self._registry.get_action_handlers()

    # =========================================================================
    # Executor Configuration
    # =========================================================================

    def configure_shadow(
        self,
        mode: ShadowMode,
        handler: Optional[Callable] = None,
    ) -> None:
        """
        Configure shadow execution behavior.

        Args:
            mode: Shadow execution mode (SKIP, DRY_RUN, EXECUTE_AND_COMPARE)
            handler: Optional comparison handler for EXECUTE_AND_COMPARE mode
                     Signature: async (decision, active_result, shadow_result) -> None
        """
        self._executor.configure_shadow(mode, handler)

    def configure_canary(
        self,
        decider: Optional[Callable[[Decision, dict], bool]] = None,
        metrics_handler: Optional[Callable] = None,
    ) -> None:
        """
        Configure canary execution behavior.

        Args:
            decider: Function to decide if canary should execute
                     Signature: (decision, context) -> bool
            metrics_handler: Optional handler for canary metrics
                     Signature: async (decision, result, is_canary) -> None
        """
        self._executor.configure_canary(decider, metrics_handler)

    def configure_executor(
        self,
        missing_tool_behavior: Optional[Union[MissingToolBehavior, str]] = None,
        execution_timeout_ms: Optional[int] = None,
        max_retries: Optional[int] = None,
        retry_backoff_ms: Optional[int] = None,
    ) -> None:
        """
        Configure executor behavior.

        Args:
            missing_tool_behavior: Behavior when tool not found ("error", "skip", "warn")
            execution_timeout_ms: Default timeout per tool
            max_retries: Maximum retries for failed executions
            retry_backoff_ms: Backoff between retries
        """
        if isinstance(missing_tool_behavior, str):
            missing_tool_behavior = MissingToolBehavior(missing_tool_behavior)

        self._executor.configure(
            missing_tool_behavior=missing_tool_behavior,
            execution_timeout_ms=execution_timeout_ms,
            max_retries=max_retries,
            retry_backoff_ms=retry_backoff_ms,
        )

    # =========================================================================
    # Claude Integration
    # =========================================================================

    def to_mcp_server(
        self,
        name: Optional[str] = None,
        description: Optional[str] = None,
    ) -> dict[str, Any]:
        """
        Export tools as an MCP server configuration for Claude Agent SDK.

        Args:
            name: Server name
            description: Server description

        Returns:
            MCP server configuration dict
        """
        if self._claude_bridge is None:
            self._claude_bridge = ClaudeBridge(self._registry)
        return self._claude_bridge.to_mcp_server(name, description)

    def to_claude_options(
        self,
        server_alias: str = "ami",
        include_mcp_server: bool = True,
    ) -> dict[str, Any]:
        """
        Export full ClaudeAgentOptions-compatible configuration.

        Args:
            server_alias: Alias for the MCP server
            include_mcp_server: Whether to include mcp_servers configuration

        Returns:
            Dict with mcp_servers and allowed_tools
        """
        if self._claude_bridge is None:
            self._claude_bridge = ClaudeBridge(self._registry)
        return self._claude_bridge.to_claude_options(server_alias, include_mcp_server)

    # =========================================================================
    # Schema Export
    # =========================================================================

    def export_executor_schema(
        self,
        path: Optional[Union[str, Path]] = None,
        format: str = "json",
    ) -> dict[str, Any]:
        """
        Export the executor schema for static analysis.

        Args:
            path: Optional file path to write schema to
            format: Output format ("json" or "yaml")

        Returns:
            Schema dictionary

        Example:
            ```python
            # Get schema dict
            schema = client.export_executor_schema()

            # Export to file
            client.export_executor_schema(".ami/executor_schema.json")
            ```
        """
        schema = self._registry.export_schema()

        # Update lifecycle config from executor
        schema.lifecycle = {
            "shadow_mode": self._executor._shadow_config.mode.value,
            "canary_decider": self._executor._canary_config.decider is not None,
        }

        if path:
            self._registry.export_schema_to_file(path, format)

        return schema.to_dict()

    # =========================================================================
    # Action Execution (Sync wrappers - prefer AsyncAMIClient for async usage)
    # =========================================================================

    def execute_decisions_sync(
        self,
        decisions: list[Decision],
        executor_context: Optional[dict] = None,
        atoms: Optional[list] = None,
        decision_point: Optional[str] = None,
    ) -> list[ExecutionResult]:
        """
        Execute decisions synchronously (blocking).

        Note: For async execution, use AsyncAMIClient.execute_decisions()

        Args:
            decisions: List of Decision objects to execute
            executor_context: Additional context for canary decisions
            atoms: Optional list of context atoms for template resolution in action args.
                   When provided, {{state.key}} templates in tool args are resolved
                   using the atom values.
            decision_point: Named decision point for context_directive routing

        Returns:
            List of ExecutionResult objects
        """
        import asyncio

        # Check if we're already in an event loop
        try:
            loop = asyncio.get_running_loop()
            # We're in an event loop - can't use run_until_complete
            raise RuntimeError(
                "Cannot use execute_decisions_sync() from async context. "
                "Use AsyncAMIClient.execute_decisions() instead."
            )
        except RuntimeError:
            # No running event loop - safe to create one
            pass

        return asyncio.run(self._executor.execute_decisions(decisions, executor_context, atoms, decision_point=decision_point))

    def ack_decisions_sync(
        self,
        execution_results: list[ExecutionResult],
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> list[AckResult]:
        """
        Acknowledge successful executions synchronously (blocking).

        Note: For async execution, use AsyncAMIClient.ack_decisions()

        Args:
            execution_results: List of ExecutionResult objects to ack
            workspace: Workspace slug/ID (overrides config)
            project: Project slug/ID (overrides config)

        Returns:
            List of AckResult objects
        """
        # Resolve workspace and project
        workspace_resolved = workspace or self.config.workspace or self.config.workspace_id
        project_resolved = project or self.config.project or self.config.project_id

        if not workspace_resolved:
            raise AMIBadRequest("workspace slug or ID is required")
        if not project_resolved:
            raise AMIBadRequest("project slug or ID is required")

        results = []
        for exec_result in execution_results:
            # Only ack successful, executed, active executions
            if not exec_result.success:
                continue
            if not exec_result.was_executed:
                continue
            if exec_result.execution_mode not in ("live", "canary"):
                continue
            if not exec_result.decision.activation_id:
                continue

            try:
                ack_response = self.ack(
                    activation_id=exec_result.decision.activation_id,
                    status="acknowledged",
                    code="SUCCESS",
                    workspace=workspace_resolved,
                    project=project_resolved,
                )
                results.append(AckResult(
                    activation_id=exec_result.decision.activation_id,
                    success=True,
                    cooldown_created=ack_response.get("cooldown_created", False),
                ))
            except Exception as e:
                results.append(AckResult(
                    activation_id=exec_result.decision.activation_id,
                    success=False,
                    error=str(e),
                ))

        return results

    def decide_and_execute_sync(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        atoms: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        context: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        decision_point: Optional[str] = None,
        idempotency_key: Optional[str] = None,
        options: Optional[InvokeOptions] = None,
        trace: Union[bool, TraceOptions] = False,
        context_ts: Optional[datetime] = None,
        executor_context: Optional[dict] = None,
        auto_ack: bool = False,
    ) -> InvokeAndExecuteResult:
        """
        Run AMI decision engine and execute returned decisions synchronously.

        This is the recommended all-in-one method for using AMI with the Tool Registry.

        Note: For async execution, use AsyncAMIClient.decide_and_execute()

        Args:
            workspace: Workspace slug or ID (overrides config)
            project: Project slug or ID (overrides config)
            atoms: Context atoms for EMU evaluation
            context: Alias for atoms (provide one or the other, not both)
            decision_point: Named decision point for analytics and topology mapping
            idempotency_key: Idempotency key for deduplication (max 256 bytes UTF-8)
            options: Invoke options (dry_run, top_k, etc.)
            trace: Enable tracing (True or TraceOptions instance)
            context_ts: Context timestamp (defaults to now)
            executor_context: Additional context for canary decisions (e.g., {"user_id": "..."})
            auto_ack: Automatically ACK successful executions (default: False)

        Returns:
            InvokeAndExecuteResult with:
            - invoke_result: InvokeResponse with selected EMUs
            - execution_results: List of ExecutionResult objects
            - ack_results: List of AckResult objects (if auto_ack=True)

        Raises:
            RuntimeError: If called from async context
            AMIBadRequest: If idempotency key exceeds 256 bytes or validation fails
            AMIBadRequest: If both atoms and context are provided
            AMINotFound: If workspace or project not found

        Example:
            ```python
            result = client.decide_and_execute_sync(
                decision_point="post-checkout",
                atoms=[
                    state("user.id", "U-123"),
                    state("user.tier", "premium"),
                ],
                executor_context={"user_id": "U-123"},
                auto_ack=True,
            )

            for exec_result in result.execution_results:
                if exec_result.success:
                    print(f"OK {exec_result.decision.emu_key}: {exec_result.output}")
                else:
                    print(f"FAIL {exec_result.decision.emu_key}: {exec_result.error}")
            ```
        """
        # Resolve context/atoms alias
        if context is not None and atoms is not None:
            raise AMIBadRequest("Provide either 'atoms' or 'context', not both")
        if context is not None:
            atoms = context
        if atoms is None:
            raise AMIBadRequest("Either 'atoms' or 'context' is required")

        # Step 1: Decide
        invoke_result = self.decide(
            workspace=workspace,
            project=project,
            atoms=atoms,
            decision_point=decision_point,
            idempotency_key=idempotency_key,
            options=options,
            trace=trace,
            context_ts=context_ts,
        )

        # Step 2: Convert selected items to Decision objects
        decisions = self._convert_selected_to_decisions(invoke_result.selected)

        # Step 3: Execute decisions with atoms for template resolution
        execution_results = self.execute_decisions_sync(decisions, executor_context, atoms, decision_point=decision_point)

        # Step 4: ACK if requested
        ack_results = []
        if auto_ack:
            ack_results = self.ack_decisions_sync(
                execution_results,
                workspace=workspace,
                project=project,
            )

        return InvokeAndExecuteResult(
            invoke_result=invoke_result,
            execution_results=execution_results,
            ack_results=ack_results,
        )

    def invoke_and_execute_sync(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        atoms: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        context: Optional[List[Union[StateAtom, TagAtom, EventAtom]]] = None,
        decision_point: Optional[str] = None,
        idempotency_key: Optional[str] = None,
        options: Optional[InvokeOptions] = None,
        trace: Union[bool, TraceOptions] = False,
        context_ts: Optional[datetime] = None,
        executor_context: Optional[dict] = None,
        auto_ack: bool = False,
    ) -> InvokeAndExecuteResult:
        """Backward-compatible alias for :meth:`decide_and_execute_sync`.

        .. deprecated::
            Use :meth:`decide_and_execute_sync` instead.
        """
        return self.decide_and_execute_sync(
            workspace=workspace,
            project=project,
            atoms=atoms,
            context=context,
            decision_point=decision_point,
            idempotency_key=idempotency_key,
            options=options,
            trace=trace,
            context_ts=context_ts,
            executor_context=executor_context,
            auto_ack=auto_ack,
        )

    def _convert_selected_to_decisions(
        self,
        selected: list,
        default_lifecycle_state: str = "active",
    ) -> list[Decision]:
        """Convert InvokeResponse.selected items to Decision objects."""
        decisions = []
        for item in selected:
            decision = Decision.from_selected_item(item, default_lifecycle_state)
            decisions.append(decision)
        return decisions
