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

Provides async 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 AsyncHTTPTransport, 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 AsyncAMIClient:
    """
    Async SDK client for AMI API interactions.

    Provides async 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
        async with AsyncAMIClient(
            api_key="ami_...",
            org="acme-corp",
            team="client-team",
            workspace="production",
            project="api-backend"
        ) as client:
            response = await 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,
        transport: Optional[AsyncHTTPTransport] = None,
    ):
        """
        Initialize async 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
            transport: Optional pre-configured AsyncHTTPTransport (for testing)

        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,
        )

        # Use provided transport or create async transport layer
        if transport:
            self.transport = transport
            self._owns_transport = False
        else:
            self.transport = AsyncHTTPTransport(self.config)
            self._owns_transport = True

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

    async def __aenter__(self):
        """Async context manager entry."""
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """Async context manager exit - cleanup resources."""
        await self.close()

    async def close(self):
        """Close transport and cleanup resources."""
        if self._owns_transport:
            await self.transport.close()

    async 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 = await 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 await 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,
        )

    async 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 await 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,
        )

    async 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 = await self.transport.post(
            path,
            json=payload,
            headers=headers,
            retry_policy=retry_policy,
        )

        # Deserialize response
        return deserialize_invoke_response(response_data)

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

        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 = await 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 = await self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

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

        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 = await 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 = await self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

    async 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,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        validate: bool = False,
    ) -> dict:
        """
        Register an EMU (Executable Memory Unit) - async variant.

        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
            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 = await 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

        # 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 = await self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    async 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 (async).

        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 = await 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 = await self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )

        return response_data

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

        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 = await 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 = await self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

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

        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 = await 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 = await self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    async def create_workspace(
        self,
        slug: str,
        name: str,
    ) -> dict:
        """Create workspace within current team context (async)."""
        payload = {"slug": slug, "name": name}
        path = "/v1/workspaces"
        response_data = await self.transport.post(path, json=payload, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

    async def get_workspace(self, workspace: str) -> dict:
        """Get workspace details by slug or ID (async)."""
        path = f"/v1/workspaces/{workspace}"
        response_data = await self.transport.get(path, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

    async def create_project(self, workspace: str, slug: str, name: str) -> dict:
        """Create project within workspace (async)."""
        payload = {"slug": slug, "name": name}
        path = f"/v1/workspaces/{workspace}/projects"
        response_data = await self.transport.post(path, json=payload, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

    async def get_project(self, workspace: str, project: str) -> dict:
        """Get project details by slug or ID (async)."""
        path = f"/v1/workspaces/{workspace}/projects/{project}"
        response_data = await self.transport.get(path, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

    async def list_workspaces(self, *, limit: int = 100, offset: int = 0) -> dict:
        """List workspaces for the current team with pagination (async)."""
        path = f"/v1/workspaces?limit={limit}&offset={offset}"
        response_data = await self.transport.get(path, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

    async 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 (async).

        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.

        Returns:
            Dict with {"workspace": str, "purged": {target: count}, "total": int}
        """
        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 await self.transport.post(
            f"/v1/workspaces/{workspace_resolved}/purge",
            json=payload,
        )

    async def list_projects(self, workspace: str, *, limit: int = 100, offset: int = 0) -> dict:
        """List projects in workspace with pagination (async)."""
        path = f"/v1/workspaces/{workspace}/projects?limit={limit}&offset={offset}"
        response_data = await self.transport.get(path, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

    async def get_config(
        self,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """Get effective configuration with 4-level cascade (async)."""
        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 = await self.transport.get(path, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

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

    async 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,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
        validate: bool = False,
    ) -> dict:
        """
        Update an existing EMU (async).

        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
            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 = await 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

        # 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 = await self.transport.put(
            path,
            json=payload,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )

        return response_data

    async def update_emu_lifecycle(
        self,
        emu_key: str,
        state: str,
        *,
        workspace: Optional[str] = None,
        project: Optional[str] = None,
    ) -> dict:
        """Change EMU lifecycle state (async)."""
        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 = await self.transport.post(path, json=payload, retry_policy=RetryPolicy.IDEMPOTENT)
        return response_data

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

        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 = await client.propose_emu(emu, workspace="prod", project="api")
            ```
        """
        if not emu:
            raise AMIBadRequest("emu cannot be empty")

        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 = "/v1/emus/propose"
        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"]

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

    async 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 (async).

        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
            result = await client.list_prompts(
                workspace="development",
                project="hindsight-prompts"
            )
            for prompt in result["items"]:
                print(f"Prompt: {prompt['slug']} - {prompt['name']}")
            ```
        """
        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)")

        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}"

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

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

        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
            prompt = await client.get_prompt(
                "trace-analysis",
                workspace="development",
                project="hindsight-prompts"
            )
            print(f"Content: {prompt['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}"
        if version is not None:
            path = f"{path}?version={version}"

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

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

        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
        """
        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)")

        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 = await self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )
        return response_data

    async 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 (async).

        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
        """
        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)")

        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 = await self.transport.put(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )
        return response_data

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

        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
        """
        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 = await self.transport.delete(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )
        return response_data

    async 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 (async).

        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
        """
        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 = await self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )
        return response_data

    async 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 (async).

        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
        """
        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 = await self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )
        return response_data

    async 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, async).

        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
        """
        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 = await self.transport.post(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )
        return response_data

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

        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
        """
        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)")

        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 = await self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )
        return response_data

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

        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
        """
        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 = await self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )
        return response_data

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

        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
        """
        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 = await self.transport.delete(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )
        return response_data

    async 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 (async).

        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
        """
        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)")

        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 = await self.transport.post(
            path,
            json=payload,
            retry_policy=RetryPolicy.NEVER,
        )
        return response_data

    async 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 (async).

        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
        """
        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 = await self.transport.get(
            path,
            retry_policy=RetryPolicy.IDEMPOTENT,
        )
        return response_data

    async 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 (async).

        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
            - total: Total number of EMUs matching the filter
            - count: Number of EMUs returned in this page
            - has_more: True if there are more EMUs to fetch

        Example:
            ```python
            result = await client.list_emus()
            for emu in result["emus"]:
                print(f"EMU: {emu['emu_key']} - {emu['state']}")
            ```
        """
        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}"

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

        if isinstance(response_data, dict) and "emus" in response_data:
            return response_data
        elif isinstance(response_data, list):
            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}

    # =========================================================================
    # 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},
            )
            async def send_email(args: dict) -> dict:
                return {"sent": True}
            ```
        """
        # 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."""
        return self._registry.on_route

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

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

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

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

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

    def configure_shadow(
        self,
        mode: ShadowMode,
        handler: Optional[Callable] = None,
    ) -> None:
        """Configure shadow execution behavior."""
        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."""
        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."""
        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 MCP server configuration for Claude Agent SDK."""
        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."""
        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."""
        schema = self._registry.export_schema()
        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
    # =========================================================================

    async def execute_decisions(
        self,
        decisions: list[Decision],
        executor_context: Optional[dict] = None,
        atoms: Optional[list] = None,
        decision_point: Optional[str] = None,
    ) -> list[ExecutionResult]:
        """
        Execute decisions asynchronously with lifecycle awareness.

        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
        """
        return await self._executor.execute_decisions(decisions, executor_context, atoms, decision_point=decision_point)

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

        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
        """
        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 = await 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

    async def decide_and_execute(
        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 in one call.

        This is the recommended way to use AMI with the Tool Registry.

        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
            options: Invoke options (dry_run, top_k, etc.)
            trace: Enable tracing
            context_ts: Context timestamp
            executor_context: Additional context for canary decisions
            auto_ack: Automatically ACK successful executions

        Returns:
            InvokeAndExecuteResult with invoke_result, execution_results, and ack_results

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

            for r in result.execution_results:
                if r.success:
                    print(f"OK {r.decision.emu_key}: {r.output}")
            ```
        """
        # 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")

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

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

        # Execute with atoms for template resolution
        execution_results = await self.execute_decisions(decisions, executor_context, atoms, decision_point=decision_point)

        # ACK if requested
        ack_results = []
        if auto_ack:
            ack_results = await self.ack_decisions(
                execution_results,
                workspace=workspace,
                project=project,
            )

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

    async def invoke_and_execute(
        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`.

        .. deprecated::
            Use :meth:`decide_and_execute` instead.
        """
        return await self.decide_and_execute(
            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
