"""
Novyx SDK - Unified API Client

Persistent memory + rollback + audit trail for AI agents.
"""

import requests
from dataclasses import dataclass, field
from typing import Any, Dict, Iterator, List, Optional

from .exceptions import (
    NovyxError,
    NovyxAuthError,
    NovyxForbiddenError,
    NovyxRateLimitError,
    NovyxNotFoundError,
    NovyxSecurityError,
)


# ============================================================================
# Rich Return Types
# ============================================================================

@dataclass
class Memory:
    """Memory object with attribute access."""
    uuid: str
    observation: str
    context: Optional[str] = None
    agent_id: Optional[str] = None
    tags: List[str] = field(default_factory=list)
    importance: int = 5
    confidence: float = 1.0
    created_at: str = ""
    score: Optional[float] = None
    similarity: Optional[float] = None
    vector_clock: Optional[Dict[str, int]] = None
    conflict_metadata: Optional[Dict] = None

    @property
    def id(self) -> str:
        """Alias for uuid."""
        return self.uuid

    @staticmethod
    def from_dict(data: Dict[str, Any]) -> "Memory":
        """Create Memory from API response dict."""
        return Memory(
            uuid=data.get("uuid", ""),
            observation=data.get("observation", ""),
            context=data.get("context"),
            agent_id=data.get("agent_id"),
            tags=data.get("tags", []),
            importance=data.get("importance", 5),
            confidence=data.get("confidence", 1.0),
            created_at=data.get("created_at", ""),
            score=data.get("score"),
            similarity=data.get("similarity"),
            vector_clock=data.get("vector_clock"),
            conflict_metadata=data.get("conflict_metadata"),
        )


@dataclass
class SearchResult:
    """Result of searching memories."""
    query: str
    total_results: int
    memories: List[Memory]

    def __iter__(self) -> Iterator[Memory]:
        return iter(self.memories)

    def __len__(self) -> int:
        return len(self.memories)


@dataclass
class ListResult:
    """Result of listing memories."""
    total_count: int
    memories: List[Memory]
    filters: Dict[str, Any] = field(default_factory=dict)

    def __iter__(self) -> Iterator[Memory]:
        return iter(self.memories)

    def __len__(self) -> int:
        return len(self.memories)


class Novyx:
    """
    Novyx SDK client - Unified memory, rollback, and audit API.

    Core features:
    - **Memory**: remember(), recall(), memories(), memory(), forget()
    - **Rollback**: rollback(), rollback_preview(), rollback_history()
    - **Audit**: audit(), audit_export(), audit_verify()
    - **Traces**: trace_create(), trace_step(), trace_complete(), trace_verify()
    - **Usage**: usage(), plans()

    Example:
        >>> from novyx import Novyx
        >>> nx = Novyx(api_key="nram_your_key_here")
        >>>
        >>> # Store memories
        >>> nx.remember("User prefers dark mode", tags=["ui"])
        >>>
        >>> # Search semantically
        >>> memories = nx.recall("user preferences")
        >>>
        >>> # Check audit trail
        >>> audit = nx.audit(limit=10)
        >>>
        >>> # Rollback if needed
        >>> preview = nx.rollback_preview("2 hours ago")
        >>> nx.rollback("2 hours ago")
        >>>
        >>> # Trace agent actions (Pro+)
        >>> trace = nx.trace_create("my-agent")
        >>> nx.trace_step(trace["trace_id"], "thought", {"content": "Planning..."})
    """

    def __init__(
        self,
        api_key: str,
        api_url: str = "https://novyx-ram-api.fly.dev",
        timeout: int = 30,
        agent_id: Optional[str] = None,
    ):
        """
        Initialize Novyx client.

        Args:
            api_key: Your Novyx API key (format: nram_tenant_signature)
            api_url: Base URL for the API (default: production)
            timeout: Request timeout in seconds
            agent_id: Default agent identifier for memory operations
        """
        self.api_key = api_key
        self.api_url = api_url.rstrip("/")
        self.timeout = timeout
        self.agent_id = agent_id
        self._validate_key()

    def _headers(self) -> Dict[str, str]:
        """Get request headers with auth."""
        return {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }

    def _validate_key(self) -> None:
        """Validate API key format."""
        if not self.api_key or not self.api_key.startswith("nram_"):
            raise NovyxAuthError("Invalid API key format. Expected: nram_<tenant>_<signature>")

    def _request(
        self,
        method: str,
        endpoint: str,
        params: Optional[Dict] = None,
        json_data: Optional[Dict] = None,
        raw_response: bool = False,
    ) -> Any:
        """
        Make an API request with error handling.

        Args:
            method: HTTP method
            endpoint: API endpoint path
            params: Query parameters
            json_data: JSON request body
            raw_response: If True, return raw response object (for binary data)

        Returns:
            Parsed JSON response or raw response object

        Raises:
            NovyxAuthError: 401 - Invalid/expired API key
            NovyxForbiddenError: 403 - Feature not available on current plan
            NovyxNotFoundError: 404 - Resource not found
            NovyxRateLimitError: 429 - Rate/usage limit exceeded
            NovyxError: Other errors
        """
        url = f"{self.api_url}{endpoint}"

        try:
            response = requests.request(
                method=method,
                url=url,
                headers=self._headers(),
                params=params,
                json=json_data,
                timeout=self.timeout,
            )

            # Handle specific error codes
            if response.status_code == 401:
                raise NovyxAuthError("Invalid API key or unauthorized")

            if response.status_code == 403:
                data = response.json() if response.text else {}
                error_msg = data.get("error", "Forbidden")
                raise NovyxForbiddenError(error_msg, data)

            if response.status_code == 404:
                data = response.json() if response.text else {}
                error_msg = data.get("error", "Not found")
                raise NovyxNotFoundError(error_msg)

            if response.status_code == 429:
                data = response.json() if response.text else {}
                error_msg = data.get("error", "Rate limit exceeded")
                raise NovyxRateLimitError(error_msg, data)

            # Handle empty responses
            if response.status_code == 204 or not response.text:
                return {} if not raw_response else response

            # Return raw response for binary data
            if raw_response:
                response.raise_for_status()
                return response

            # Try to parse JSON response
            try:
                response_data = response.json()
            except ValueError:
                # Invalid JSON - for error status codes, raise with text
                if response.status_code >= 400:
                    raise NovyxError(f"HTTP {response.status_code}: {response.text[:200]}")
                # For success codes with invalid JSON, raise parse error
                raise NovyxError(f"Invalid JSON response: {response.text[:200]}")

            # Now check status and raise for errors
            try:
                response.raise_for_status()
            except requests.exceptions.HTTPError as e:
                # Convert HTTP errors to NovyxError
                raise NovyxError(f"HTTP {response.status_code}: {str(e)}")

            return response_data

        except requests.exceptions.Timeout:
            raise NovyxError(f"Request timed out after {self.timeout}s")
        except requests.exceptions.ConnectionError:
            raise NovyxError(f"Failed to connect to {self.api_url}")
        except requests.exceptions.RequestException as e:
            raise NovyxError(f"Request failed: {e}")

    # ========================================================================
    # Memory Methods
    # ========================================================================

    def remember(
        self,
        observation: str,
        *,
        tags: Optional[List[str]] = None,
        context: Optional[str] = None,
        importance: int = 5,
        agent_id: Optional[str] = None,
        space_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
        **kwargs,
    ) -> Dict[str, Any]:
        """
        Store a memory observation.

        Args:
            observation: The memory content to store
            tags: Optional list of tags for categorization
            context: Optional context string
            importance: Importance score 1-10 (default: 5)
            agent_id: Agent identifier (falls back to constructor agent_id)
            space_id: Context space to store the memory in
            metadata: Optional metadata dict (stored in context if provided)

        Returns:
            Dict with uuid, message, and conflict info

        Raises:
            NovyxRateLimitError: If memory limit exceeded

        Example:
            >>> result = nx.remember("User prefers async communication", tags=["prefs"], importance=8)
            >>> print(result["uuid"])
        """
        resolved_agent_id = agent_id or self.agent_id

        payload = {
            "observation": observation,
            "tags": tags or [],
            "importance": importance,
        }

        if resolved_agent_id:
            payload["agent_id"] = resolved_agent_id
        if space_id:
            payload["space_id"] = space_id

        if context:
            payload["context"] = context
        elif metadata:
            # If metadata provided but no context, JSON-encode metadata as context
            import json
            payload["context"] = json.dumps(metadata)

        return self._request(
            "POST",
            "/v1/memories",
            params={"conflict_strategy": "lww"},
            json_data=payload,
        )

    def recall(
        self,
        query: str,
        *,
        limit: int = 5,
        tags: Optional[List[str]] = None,
        min_score: float = 0.0,
        scope: str = "own",
        agents: Optional[List[str]] = None,
        include_shared: bool = False,
        space_id: Optional[str] = None,
        **kwargs,
    ) -> SearchResult:
        """
        Search memories semantically.

        Args:
            query: Natural language search query
            limit: Maximum results to return (default: 5, max: 100)
            tags: Optional tag filter
            min_score: Minimum similarity score 0-1 (default: 0)
            scope: Search scope (default: "own")
            agents: Filter by agent IDs
            include_shared: Include shared space memories
            space_id: Filter by context space

        Returns:
            SearchResult with Memory objects (iterable)

        Example:
            >>> result = nx.recall("communication preferences", limit=3)
            >>> for mem in result:
            ...     print(f"{mem.observation} (score: {mem.score:.2f})")
        """
        params = {"q": query, "limit": limit, "min_score": min_score}
        if tags:
            params["tags"] = ",".join(tags)
        if agents:
            params["agents"] = ",".join(agents)
        if space_id:
            params["space_id"] = space_id

        result = self._request("GET", "/v1/memories/search", params=params)
        raw = result if isinstance(result, list) else result.get("memories", [])
        memories = [Memory.from_dict(m) for m in raw]
        return SearchResult(
            query=query,
            total_results=len(memories),
            memories=memories,
        )

    def memories(
        self,
        *,
        limit: int = 100,
        offset: int = 0,
        tags: Optional[List[str]] = None,
        min_importance: Optional[int] = None,
        include_shared: bool = False,
        agent_id: Optional[str] = None,
        space_id: Optional[str] = None,
        **kwargs,
    ) -> List[Dict[str, Any]]:
        """
        List all memories with optional filtering.

        Args:
            limit: Maximum results per page (default: 100, max: 1000)
            offset: Pagination offset
            tags: Optional tag filter
            min_importance: Filter by minimum importance (1-10)
            include_shared: Include shared space memories
            agent_id: Filter by agent ID
            space_id: Filter by context space

        Returns:
            List of memory dictionaries

        Example:
            >>> all_memories = nx.memories(limit=50, min_importance=7)
            >>> print(f"Found {len(all_memories)} high-importance memories")
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if tags:
            params["tags"] = ",".join(tags)
        if min_importance is not None:
            params["min_importance"] = min_importance
        if include_shared:
            params["include_shared"] = "true"
        if agent_id:
            params["agent_id"] = agent_id
        if space_id:
            params["space_id"] = space_id

        result = self._request("GET", "/v1/memories", params=params)
        return result.get("memories", []) if isinstance(result, dict) else result

    def memory(self, memory_id: str) -> Dict[str, Any]:
        """
        Get a specific memory by ID.

        Args:
            memory_id: The memory UUID

        Returns:
            Memory details dictionary

        Raises:
            NovyxNotFoundError: If memory not found

        Example:
            >>> mem = nx.memory("urn:uuid:abc123...")
            >>> print(mem["observation"])
        """
        return self._request("GET", f"/v1/memories/{memory_id}")

    def forget(self, memory_id: str) -> bool:
        """
        Delete a specific memory.

        Args:
            memory_id: The memory UUID

        Returns:
            True if deleted successfully

        Raises:
            NovyxNotFoundError: If memory not found

        Example:
            >>> nx.forget("urn:uuid:abc123...")
            True
        """
        result = self._request("DELETE", f"/v1/memories/{memory_id}")
        return result.get("success", True)

    def list(
        self,
        *,
        limit: int = 100,
        offset: int = 0,
        tags: Optional[List[str]] = None,
        min_importance: Optional[int] = None,
        include_shared: bool = False,
        agent_id: Optional[str] = None,
        space_id: Optional[str] = None,
        **kwargs,
    ) -> ListResult:
        """
        List memories with optional filtering. Returns a ListResult with Memory objects.

        Alias for memories() with rich return type.

        Args:
            limit: Maximum results per page (default: 100, max: 1000)
            offset: Pagination offset
            tags: Optional tag filter
            min_importance: Filter by minimum importance (1-10)
            include_shared: Include shared space memories
            agent_id: Filter by agent ID
            space_id: Filter by context space

        Returns:
            ListResult with Memory objects (iterable)

        Example:
            >>> result = nx.list(min_importance=7)
            >>> for mem in result:
            ...     print(mem.observation)
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if tags:
            params["tags"] = ",".join(tags)
        if min_importance is not None:
            params["min_importance"] = min_importance
        if include_shared:
            params["include_shared"] = "true"
        if agent_id:
            params["agent_id"] = agent_id
        if space_id:
            params["space_id"] = space_id

        result = self._request("GET", "/v1/memories", params=params)
        raw_memories = result.get("memories", []) if isinstance(result, dict) else result

        return ListResult(
            total_count=result.get("total_count", len(raw_memories)) if isinstance(result, dict) else len(raw_memories),
            memories=[Memory.from_dict(m) for m in raw_memories],
            filters=result.get("filters", {}) if isinstance(result, dict) else {},
        )

    def delete(self, memory_id: str) -> bool:
        """
        Delete a specific memory. Alias for forget().

        Args:
            memory_id: The memory UUID

        Returns:
            True if deleted successfully

        Raises:
            NovyxNotFoundError: If memory not found

        Example:
            >>> nx.delete("urn:uuid:abc123...")
            True
        """
        return self.forget(memory_id)

    def stats(self) -> Dict[str, Any]:
        """
        Get memory statistics for your account.

        Returns:
            Dict with total_memories, avg_importance, tag distribution, etc.

        Example:
            >>> stats = nx.stats()
            >>> print(f"Total memories: {stats['total_memories']}")
        """
        return self._request("GET", "/v1/memories/stats")

    # ========================================================================
    # Context Space & Sharing Methods
    # ========================================================================

    def share_context(
        self,
        tag: str,
        to_email: str,
        permission: str = "read",
    ) -> Dict[str, Any]:
        """
        Share a tag/space with another user via email.

        Args:
            tag: Tag or space name to share
            to_email: Recipient's email address
            permission: "read" or "write" (default: "read")

        Returns:
            Dict with token, share_url, tag, permission info

        Example:
            >>> result = nx.share_context("project-notes", "colleague@example.com")
            >>> print(result["share_url"])
        """
        return self._request(
            "POST",
            "/v1/spaces/share",
            json_data={
                "tag": tag,
                "email": to_email,
                "permission": permission,
            },
        )

    def accept_shared_context(self, token: str) -> Dict[str, Any]:
        """
        Accept a share invitation and join a shared space.

        Args:
            token: Share token from the invitation

        Returns:
            Dict with success, tag, owner_tenant_id, permission

        Example:
            >>> result = nx.accept_shared_context("share_abc123...")
            >>> print(f"Joined space '{result['tag']}'")
        """
        return self._request(
            "POST",
            "/v1/spaces/join",
            json_data={"token": token},
        )

    def shared_contexts(self) -> Dict[str, Any]:
        """
        List spaces shared by and with the current user.

        Returns:
            Dict with shared_by_me and shared_with_me lists

        Example:
            >>> ctx = nx.shared_contexts()
            >>> for s in ctx["shared_by_me"]:
            ...     print(f"Shared '{s['tag']}' with {s.get('shared_with_email')}")
        """
        return self._request("GET", "/v1/spaces/shared")

    def revoke_shared_context(self, token: str) -> Dict[str, Any]:
        """
        Revoke a share invitation (owner only).

        Args:
            token: The share token to revoke

        Returns:
            Empty dict on success (204 response)

        Example:
            >>> nx.revoke_shared_context("share_abc123...")
        """
        return self._request("DELETE", f"/v1/spaces/share/{token}")

    def graph(
        self,
        *,
        include_shared: bool = False,
        agents: Optional[List[str]] = None,
    ) -> Dict[str, Any]:
        """
        Get memory graph with nodes and edges.

        Args:
            include_shared: Include shared space memories in graph
            agents: Filter by agent IDs

        Returns:
            Dict with nodes, edges, agents, total_nodes, total_edges

        Example:
            >>> g = nx.graph(include_shared=True)
            >>> print(f"{g['total_nodes']} nodes, {g['total_edges']} edges")
        """
        params: Dict[str, Any] = {}
        if include_shared:
            params["include_shared"] = "true"
        if agents:
            params["agents"] = ",".join(agents)
        return self._request("GET", "/v1/memories/graph", params=params)

    # ========================================================================
    # Rollback Methods (Pro+ only)
    # ========================================================================

    def rollback(
        self,
        target: str,
        dry_run: bool = False,
        preserve_evidence: bool = True,
    ) -> Dict[str, Any]:
        """
        Rollback memory to a point in time.

        Supports timestamps and relative time expressions.

        Args:
            target: ISO timestamp (e.g., "2026-01-15T10:00:00Z") or relative time (e.g., "2 hours ago")
            dry_run: If True, preview changes without applying (default: False)
            preserve_evidence: If True, quarantine rolled-back data (default: True)

        Returns:
            Dict with rollback results:
                - success: bool
                - rolled_back_to: timestamp
                - artifacts_restored: int
                - operations_undone: int
                - quarantine_path: str (if preserve_evidence=True)

        Raises:
            NovyxForbiddenError: If on Free tier (rollback requires Pro+)
            NovyxRateLimitError: If rollback limit exceeded (Free: 10/month)

        Example:
            >>> # Preview rollback first
            >>> preview = nx.rollback("2 hours ago", dry_run=True)
            >>> print(f"Will restore {preview['artifacts_restored']} artifacts")
            >>>
            >>> # Execute rollback
            >>> result = nx.rollback("2 hours ago")
            >>> print(f"Rolled back to {result['rolled_back_to']}")
        """
        return self._request(
            "POST",
            "/v1/rollback",
            json_data={
                "target": target,
                "dry_run": dry_run,
                "preserve_evidence": preserve_evidence,
            },
        )

    def rollback_preview(self, target: str) -> Dict[str, Any]:
        """
        Preview what will change before executing rollback.

        Args:
            target: ISO timestamp or relative time (e.g., "2 hours ago")

        Returns:
            Dict with preview:
                - target_timestamp: str
                - artifacts_modified: int
                - artifacts_deleted: int
                - size_bytes: int
                - safe_rollback: bool
                - warnings: List[str]

        Raises:
            NovyxForbiddenError: If on Free tier

        Example:
            >>> preview = nx.rollback_preview("2 hours ago")
            >>> if preview["safe_rollback"]:
            ...     nx.rollback("2 hours ago")
        """
        return self._request("GET", "/v1/rollback/preview", params={"target": target})

    def rollback_history(self, limit: int = 50) -> List[Dict[str, Any]]:
        """
        List past rollbacks for this tenant.

        Args:
            limit: Maximum results to return (default: 50, max: 200)

        Returns:
            List of rollback records with:
                - rollback_id: str
                - executed_at: timestamp
                - target_timestamp: timestamp
                - artifacts_restored: int
                - operations_undone: int

        Raises:
            NovyxForbiddenError: If on Free tier

        Example:
            >>> history = nx.rollback_history(limit=10)
            >>> for rb in history:
            ...     print(f"Rollback to {rb['target_timestamp']} at {rb['executed_at']}")
        """
        result = self._request("GET", "/v1/rollback/history", params={"limit": limit})
        return result if isinstance(result, list) else []

    # ========================================================================
    # Audit Methods
    # ========================================================================

    def audit(
        self,
        limit: int = 50,
        since: Optional[str] = None,
        until: Optional[str] = None,
        operation: Optional[str] = None,
        artifact_id: Optional[str] = None,
    ) -> List[Dict[str, Any]]:
        """
        Get audit trail entries with filtering.

        Args:
            limit: Maximum entries to return (default: 50, max: 1000)
            since: Start timestamp (ISO format)
            until: End timestamp (ISO format)
            operation: Filter by operation type (CREATE, UPDATE, DELETE, ROLLBACK)
            artifact_id: Filter by specific artifact ID

        Returns:
            List of audit entries with:
                - timestamp: str
                - operation: str
                - agent_id: str
                - artifact_id: str
                - content_hash: str

        Example:
            >>> # Get recent audit entries
            >>> audit = nx.audit(limit=10)
            >>>
            >>> # Filter by time range
            >>> from datetime import datetime, timedelta
            >>> since = (datetime.now() - timedelta(hours=24)).isoformat()
            >>> audit = nx.audit(since=since, operation="CREATE")
        """
        params = {"limit": limit}
        if since:
            params["start_time"] = since
        if until:
            params["end_time"] = until
        if operation:
            params["operation"] = operation
        if artifact_id:
            params["artifact_id"] = artifact_id

        result = self._request("GET", "/v1/audit", params=params)
        return result.get("entries", []) if isinstance(result, dict) else result

    def audit_export(self, format: str = "csv") -> bytes:
        """
        Export audit log in CSV or JSON format.

        Args:
            format: Export format - "csv", "json", or "jsonl" (default: "csv")

        Returns:
            Raw bytes of exported audit log

        Raises:
            NovyxForbiddenError: If on Free tier (export requires Pro+)

        Example:
            >>> # Export as CSV
            >>> data = nx.audit_export(format="csv")
            >>> with open("audit.csv", "wb") as f:
            ...     f.write(data)
            >>>
            >>> # Export as JSON
            >>> data = nx.audit_export(format="json")
            >>> with open("audit.json", "wb") as f:
            ...     f.write(data)
        """
        response = self._request(
            "GET",
            "/v1/audit/export",
            params={"format": format},
            raw_response=True,
        )
        return response.content

    def audit_verify(self) -> Dict[str, Any]:
        """
        Verify audit trail integrity chain.

        Returns:
            Dict with verification results:
                - valid: bool
                - errors: List[str]
                - total_entries: int
                - chain_head: str

        Example:
            >>> verification = nx.audit_verify()
            >>> if verification["valid"]:
            ...     print("Audit trail integrity verified!")
            >>> else:
            ...     print(f"Errors: {verification['errors']}")
        """
        return self._request("GET", "/v1/audit/verify")

    # ========================================================================
    # Trace Methods (Pro+ only)
    # ========================================================================

    def trace_create(
        self,
        agent_id: str,
        session_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Create a new trace session for agent action tracking.

        Args:
            agent_id: Identifier for the agent
            session_id: Optional session identifier
            metadata: Optional metadata dictionary

        Returns:
            Dict with trace information:
                - trace_id: str
                - agent_id: str
                - session_id: str
                - created_at: timestamp

        Raises:
            NovyxForbiddenError: If on Free tier (traces require Pro+)

        Example:
            >>> trace = nx.trace_create("my-agent", session_id="session-123")
            >>> trace_id = trace["trace_id"]
        """
        return self._request(
            "POST",
            "/v1/traces",
            json_data={
                "agent_id": agent_id,
                "session_id": session_id,
                "metadata": metadata or {},
            },
        )

    def trace_step(
        self,
        trace_id: str,
        step_type: str,
        name: str,
        content: Optional[str] = None,
        attributes: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Add a step to an existing trace with real-time policy evaluation.

        Args:
            trace_id: The trace ID from trace_create()
            step_type: Type of step (e.g., "thought", "action", "observation")
            name: Step name/description
            content: Optional step content
            attributes: Optional attributes dictionary

        Returns:
            Dict with step information and policy status:
                - step_id: str
                - circuit_breaker_triggered: bool
                - action: str (allow/block)

        Raises:
            NovyxForbiddenError: If on Free tier
            NovyxSecurityError: If circuit breaker triggered

        Example:
            >>> nx.trace_step(trace_id, "thought", "Planning email", content="User prefers async")
            >>> nx.trace_step(trace_id, "action", "send_email", attributes={"to": "user@example.com"})
        """
        return self._request(
            "POST",
            f"/v1/traces/{trace_id}/steps",
            json_data={
                "step_type": step_type,
                "name": name,
                "content": content,
                "attributes": attributes or {},
            },
        )

    def trace_complete(self, trace_id: str) -> Dict[str, Any]:
        """
        Finalize a trace with RSA signature for integrity.

        Args:
            trace_id: The trace ID to complete

        Returns:
            Dict with completion information:
                - trace_id: str
                - status: str
                - signature: str (RSA signature)
                - public_key_id: str
                - signed_at: timestamp
                - integrity_chain_valid: bool
                - total_steps: int

        Raises:
            NovyxForbiddenError: If on Free tier

        Example:
            >>> result = nx.trace_complete(trace_id)
            >>> print(f"Trace signed with {result['signature'][:16]}...")
        """
        return self._request("POST", f"/v1/traces/{trace_id}/complete")

    def trace_verify(self, trace_id: str) -> Dict[str, Any]:
        """
        Verify trace integrity chain and signature.

        Args:
            trace_id: The trace ID to verify

        Returns:
            Dict with verification results:
                - trace_id: str
                - valid: bool
                - errors: List[str]
                - steps_verified: int

        Raises:
            NovyxForbiddenError: If on Free tier

        Example:
            >>> verification = nx.trace_verify(trace_id)
            >>> if verification["valid"]:
            ...     print("Trace integrity verified!")
        """
        return self._request("GET", f"/v1/traces/{trace_id}/verify")

    # ========================================================================
    # Usage & Plans
    # ========================================================================

    def usage(self) -> Dict[str, Any]:
        """
        Get current usage stats vs limits for your account.

        Returns:
            Dict with usage information:
                - tier: str (free/starter/pro/enterprise)
                - api_calls: Dict with current, limit, unlimited
                - rollbacks: Dict with current, limit, unlimited
                - memories: Dict with current, limit, unlimited
                - audit_retention_days: int
                - features: Dict of enabled features
                - period: str (YYYY-MM)

        Example:
            >>> usage = nx.usage()
            >>> print(f"Tier: {usage['tier']}")
            >>> print(f"API calls: {usage['api_calls']['current']}/{usage['api_calls']['limit']}")
            >>> print(f"Memories: {usage['memories']['current']}")
            >>> if usage['api_calls']['unlimited']:
            ...     print("Unlimited API calls!")
        """
        return self._request("GET", "/v1/usage")

    def plans(self) -> List[Dict[str, Any]]:
        """
        Get available pricing plans and limits.

        Returns:
            List of plan dictionaries with:
                - plan_id: str
                - name: str
                - price_cents: int
                - price_display: str
                - memory_limit: int or None
                - api_calls_limit: int or None
                - rollbacks_limit: int or None
                - audit_retention_days: int
                - features: Dict of features

        Example:
            >>> plans = nx.plans()
            >>> for plan in plans:
            ...     print(f"{plan['name']}: {plan['price_display']}")
            ...     print(f"  Memories: {plan['memory_limit'] or 'Unlimited'}")
            ...     print(f"  Features: {', '.join([k for k, v in plan['features'].items() if v])}")
        """
        return self._request("GET", "/v1/plans")

    def health(self) -> Dict[str, Any]:
        """
        Check API health status.

        Returns:
            Dict with status, version, timestamp

        Example:
            >>> health = nx.health()
            >>> print(f"Status: {health['status']}")
        """
        try:
            response = requests.get(
                f"{self.api_url}/health",
                timeout=self.timeout,
            )
            return response.json()
        except Exception as e:
            return {"status": "error", "error": str(e)}
