"""
Resource managers for Cyberwave API entities.

This module provides manager classes that wrap the auto-generated REST API
stubs with cleaner, more Pythonic interfaces.

Architecture:
    rest/           # Auto-generated by OpenAPI (NEVER edit manually)
    managers/       # Re-exports these managers (for future modular structure)
    resources.py    # The actual manager implementations (this file)

The managers are safe to edit - they wrap the auto-generated stubs and won't
be overwritten by regenerating the SDK.

Usage:
    These managers are typically accessed via the Cyberwave client:

    >>> client = Cyberwave(token="...")
    >>> workspaces = client.workspaces.list()
    >>> twin = client.twins.get("uuid")
"""

import uuid as uuid_lib
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

from cyberwave.rest import (
    DefaultApi,
    WorkspaceSchema,
    WorkspaceResponseSchema,
    WorkspaceUpdateSchema,
    WorkspaceCreateSchema,
    ProjectSchema,
    ProjectCreateSchema,
    EnvironmentSchema,
    EnvironmentCreateSchema,
    AssetSchema,
    AssetCreateSchema,
    AssetUpdateSchema,
    TwinSchema,
    TwinCreateSchema,
    TwinStateUpdateSchema,
    JointStatesSchema,
    JointStateUpdateSchema,
    JointStateSchema,
    EdgeSchema,
)
from cyberwave.rest.models.twin_joint_calibration_schema import TwinJointCalibrationSchema

from .exceptions import CyberwaveAPIError

EDGE_CONFIGS_KEY = "edge_configs"
EDGE_CONFIG_INTERNAL_KEYS = {
    "device_info",
    "registered_at",
    "last_sync",
    "edge_uuid",
    "edge_fingerprint",
}
EDGE_DEVICE_UUID_NAMESPACE = uuid_lib.UUID("7f09e16f-ef6f-410d-9d5d-c071d8fbd1ad")


class BaseResourceManager:
    """Base class for resource managers"""

    def __init__(self, api_client: DefaultApi):
        self.api = api_client

    def _handle_error(self, e: Exception, operation: str):
        """Handle API errors consistently"""
        if hasattr(e, "status"):
            body = getattr(e, "body", None)
            response_dict = body if isinstance(body, dict) else None

            # Try to extract request headers if available
            request_headers = None
            if hasattr(e, "request_headers"):
                request_headers = e.request_headers

            raise CyberwaveAPIError(
                f"Failed to {operation}: {str(e)}",
                status_code=int(e.status) if hasattr(e.status, "__int__") else None,
                response_data=response_dict,
                request_headers=request_headers,
            )
        raise CyberwaveAPIError(f"Failed to {operation}: {str(e)}")


class WorkspaceManager(BaseResourceManager):
    """Manager for workspace operations"""

    def list(self) -> List[WorkspaceSchema]:
        """List all workspaces"""
        try:
            response = self.api.src_users_api_workspaces_list_workspaces()
            return response
        except Exception as e:
            self._handle_error(e, "list workspaces")
            raise  # For type checker

    def get(self, workspace_id: str) -> WorkspaceResponseSchema:
        """Get workspace by ID"""
        try:
            return self.api.src_users_api_workspaces_get_workspace(workspace_id)
        except Exception as e:
            self._handle_error(e, f"get workspace {workspace_id}")
            raise  # For type checker

    def update(self, workspace_id: str, data: Dict[str, Any]) -> WorkspaceSchema:
        """Update workspace"""
        try:
            update_schema = WorkspaceUpdateSchema(**data)
            result = self.api.src_users_api_workspaces_update_workspace(
                workspace_id, update_schema
            )
            return result
        except Exception as e:
            self._handle_error(e, f"update workspace {workspace_id}")
            raise  # For type checker

    def create(self, name: str, description: str = "", **kwargs) -> WorkspaceSchema:
        """Create a new workspace"""
        try:
            create_schema = WorkspaceCreateSchema(
                name=name, description=description, **kwargs
            )
            return self.api.src_users_api_workspaces_create_workspace(create_schema)
        except Exception as e:
            self._handle_error(e, "create workspace")
            raise  # For type checker


class ProjectManager(BaseResourceManager):
    """Manager for project operations"""

    def list(self, workspace_id: Optional[str] = None) -> List[ProjectSchema]:
        """List all projects, optionally filtered by workspace"""
        try:
            return self.api.src_app_api_projects_list_projects()
        except Exception as e:
            self._handle_error(e, "list projects")
            raise  # For type checker

    def get(self, project_id: str) -> ProjectSchema:
        """Get project by ID"""
        try:
            return self.api.src_app_api_projects_get_project(project_id)
        except Exception as e:
            self._handle_error(e, f"get project {project_id}")
            raise  # For type checker

    def create(
        self, name: str, workspace_id: str, description: str = "", **kwargs
    ) -> ProjectSchema:
        """Create a new project"""
        try:
            create_schema = ProjectCreateSchema(
                name=name,
                description=description,
                workspace_uuid=workspace_id,
                **kwargs,
            )
            return self.api.src_app_api_projects_create_project(create_schema)
        except Exception as e:
            self._handle_error(e, "create project")
            raise  # For type checker

    def delete(self, project_id: str) -> None:
        """Delete a project"""
        try:
            self.api.src_app_api_projects_delete_project(project_id)
        except Exception as e:
            self._handle_error(e, f"delete project {project_id}")


class EnvironmentManager(BaseResourceManager):
    """Manager for environment operations"""

    def list(self, project_id: Optional[str] = None) -> List[EnvironmentSchema]:
        """List all environments, optionally filtered by project"""
        try:
            if project_id:
                return self.api.src_app_api_environments_list_environments_for_project(
                    project_id
                )
            else:
                return self.api.src_app_api_environments_list_all_environments()
        except Exception as e:
            self._handle_error(e, "list environments")
            raise  # For type checker

    def get(self, environment_id: str) -> EnvironmentSchema:
        """Get environment by ID"""
        try:
            return self.api.src_app_api_environments_get_environment(environment_id)
        except Exception as e:
            self._handle_error(e, f"get environment {environment_id}")
            raise  # For type checker

    def create(
        self, name: str, project_id: str | None = None, description: str = "", **kwargs
    ) -> EnvironmentSchema:
        """Create a new environment"""
        try:
            # If no project is specified, use the first one. If none exist, create
            # a default folder-backed project.
            if not project_id:
                project_id = self.api.src_app_api_projects_list_projects()[0].uuid
            if not project_id:
                project = self.api.src_app_api_projects_create_project(
                    ProjectCreateSchema(
                        name="Default Folder",
                        description="Default Folder Description",
                    )
                )
                project_id = project.uuid
            create_schema = EnvironmentCreateSchema(
                name=name, description=description, **kwargs
            )
            return self.api.src_app_api_environments_create_environment_for_project(
                project_id, create_schema
            )
        except Exception as e:
            self._handle_error(e, "create environment")
            raise  # For type checker

    def delete(self, environment_id: str, project_id: str) -> None:
        """Delete an environment"""
        try:
            self.api.src_app_api_environments_delete_environment_for_project(
                project_id, environment_id
            )
        except Exception as e:
            self._handle_error(e, f"delete environment {environment_id}")

    def get_twins(self, environment_id: str) -> List["TwinSchema"]:
        """Get all twins in an environment"""
        try:
            return self.api.src_app_api_environments_get_environment_twins(
                environment_id
            )
        except Exception as e:
            self._handle_error(e, f"get twins for environment {environment_id}")
            raise  # For type checker

    def get_first_or_none(self) -> Optional["EnvironmentSchema"]:
        """Get the first available environment, or None if no environments exist.

        Useful for CLI commands that need to select a default environment.

        Returns:
            First EnvironmentSchema or None if no environments exist.
        """
        try:
            environments = self.list()
            return environments[0] if environments else None
        except Exception:
            return None

    # =========================================================================
    # Universal Schema Export APIs
    # =========================================================================

    def get_universal_schema_json(self, environment_id: str) -> Dict[str, Any]:
        """Get the composed universal schema JSON for an environment (direct download).
        
        Composes schema from all twins' universal_schemas in the environment.
        
        Args:
            environment_id: UUID of the environment
            
        Returns:
            Dict containing the composed CommonSchema as JSON.
            
        Example:
            schema = cw.environments.get_universal_schema_json(env_id)
            print(schema['links'])  # Access links in the schema
        """
        import json
        try:
            # The OpenAPI generator doesn't handle generic dict responses well
            # Use the with_http_info variant to get the raw response
            response = self.api.src_app_api_environments_get_environment_universal_schema_json_with_http_info(
                environment_id
            )
            # Parse the response body as JSON
            if response and hasattr(response, 'data') and response.data:
                # If data is bytes, decode it
                if isinstance(response.data, bytes):
                    return json.loads(response.data.decode('utf-8'))
                # If it's already parsed, return it
                return response.data
            # Fallback: try to get raw response
            if response and hasattr(response, 'raw_data'):
                return json.loads(response.raw_data.decode('utf-8'))
            return None
        except Exception as e:
            self._handle_error(e, f"Get universal schema for environment {environment_id}")
            raise

    def export_urdf_scene(self, environment_id: str, output_path: Optional[str] = None) -> bytes:
        """
        Export environment as URDF project ZIP file.
        
        Downloads a ZIP containing:
        - scene.urdf: The complete URDF scene
        - meshes/: Directory with all required mesh files
        
        Args:
            environment_id: UUID of the environment
            output_path: Optional path to save the ZIP file. If None, returns bytes.
            
        Returns:
            ZIP file contents as bytes (if output_path is None)
            
        Example:
            # Save to file
            cw.environments.export_urdf_scene(env_id, "urdf_project.zip")
            
            # Get bytes
            zip_data = cw.environments.export_urdf_scene(env_id)
        """
        try:
            # Use with_http_info to get raw response data
            response = self.api.src_app_api_environments_get_environment_urdf_scene_zip_direct_with_http_info(
                environment_id
            )
            
            # Extract bytes from response - use raw_data for binary content
            zip_bytes = response.raw_data
            
            if output_path:
                with open(output_path, 'wb') as f:
                    f.write(zip_bytes)
                return zip_bytes
            return zip_bytes
        except Exception as e:
            self._handle_error(e, f"export URDF scene for environment {environment_id}")
            raise

    def export_mujoco_scene(self, environment_id: str, output_path: Optional[str] = None) -> bytes:
        """
        Export environment as MuJoCo scene ZIP file.
        
        Downloads a ZIP containing:
        - scene.xml: The complete MuJoCo scene
        - meshes/: Directory with all required mesh files
        
        Args:
            environment_id: UUID of the environment
            output_path: Optional path to save the ZIP file. If None, returns bytes.
            
        Returns:
            ZIP file contents as bytes (if output_path is None)
            
        Example:
            # Save to file
            cw.environments.export_mujoco_scene(env_id, "mujoco_scene.zip")
            
            # Get bytes
            zip_data = cw.environments.export_mujoco_scene(env_id)
        """
        try:
            # Use with_http_info to get raw response data
            response = self.api.src_app_api_environments_get_environment_mujoco_scene_zip_direct_with_http_info(
                environment_id
            )
            
            # Extract bytes from response - use raw_data for binary content
            zip_bytes = response.raw_data
            
            if output_path:
                with open(output_path, 'wb') as f:
                    f.write(zip_bytes)
                return zip_bytes
            return zip_bytes
        except Exception as e:
            self._handle_error(e, f"export MuJoCo scene for environment {environment_id}")
            raise


class AssetManager(BaseResourceManager):
    """Manager for asset operations"""

    def __init__(self, api_client: DefaultApi):
        super().__init__(api_client)
        # Instance-level cache for full asset data to avoid repeated fetches
        # Using instance-level prevents cross-client contamination when multiple
        # Cyberwave clients are instantiated
        self._asset_cache: Dict[str, AssetSchema] = {}
        self._cache_timestamps: Dict[str, float] = {}
        self._cache_ttl: float = 60.0  # Cache TTL in seconds

    def _get_cached(self, asset_uuid: str) -> Optional[AssetSchema]:
        """Get asset from cache if not expired."""
        import time

        if asset_uuid in self._asset_cache:
            if (
                time.time() - self._cache_timestamps.get(asset_uuid, 0)
                < self._cache_ttl
            ):
                return self._asset_cache[asset_uuid]
        return None

    def _set_cache(self, asset_uuid: str, asset: AssetSchema) -> None:
        """Add asset to cache."""
        import time

        self._asset_cache[asset_uuid] = asset
        self._cache_timestamps[asset_uuid] = time.time()

    def list(self, workspace_id: Optional[str] = None) -> List[AssetSchema]:
        """List all assets, optionally filtered by workspace"""
        try:
            return self.api.src_app_api_assets_list_assets()
        except Exception as e:
            self._handle_error(e, "list assets")
            raise  # For type checker

    def get(self, asset_id: str) -> AssetSchema:
        """Get asset by ID"""
        try:
            return self.api.src_app_api_assets_get_asset(asset_id)
        except Exception as e:
            self._handle_error(e, f"get asset {asset_id}")
            raise  # For type checker

    def create(self, name: str, description: str = "", **kwargs) -> AssetSchema:
        """Create a new asset"""
        try:
            create_schema = AssetCreateSchema(
                name=name, description=description, **kwargs
            )
            return self.api.src_app_api_assets_create_asset(create_schema)
        except Exception as e:
            self._handle_error(e, "create asset")
            raise  # For type checker

    def update(self, asset_id: str, data: Dict[str, Any]) -> AssetUpdateSchema:
        """Update an asset"""
        try:
            update_schema = AssetUpdateSchema(**data)
            return self.api.src_app_api_assets_update_asset(asset_id, update_schema)
        except Exception as e:
            self._handle_error(e, f"update asset {asset_id}")
            raise  # For type checker

    def delete(self, asset_id: str) -> None:
        """Delete an asset"""
        try:
            self.api.src_app_api_assets_delete_asset(asset_id)
        except Exception as e:
            self._handle_error(e, f"delete asset {asset_id}")

    # =========================================================================
    # Universal Schema APIs
    # =========================================================================

    def get_universal_schema(self, asset_id: str) -> Dict[str, Any]:
        """
        Get the asset's universal schema as JSON.
        
        Args:
            asset_id: UUID of the asset
            
        Returns:
            Dict containing the CommonSchema as JSON
            
        Example:
            schema = cw.assets.get_universal_schema(asset_id)
            print(schema['links'])  # Access links in the schema
            print(schema['extensions']['cyberwave']['capabilities'])
        """
        try:
            return self.api.src_app_api_assets_get_asset_universal_schema(asset_id)
        except Exception as e:
            self._handle_error(e, f"get universal schema for asset {asset_id}")
            raise

    def patch_universal_schema(
        self, 
        asset_id: str, 
        path: str, 
        value: Any, 
        op: str = "replace"
    ) -> Dict[str, Any]:
        """
        Update the asset's universal schema using JSON Pointer operations.
        
        This updates the authoritative schema and increments the schema hash.
        All twins created from this asset after the update will use the new schema.
        
        Args:
            asset_id: UUID of the asset
            path: JSON Pointer path to update (e.g., "/links/0/name")
            value: Value to set at the path
            op: Operation type - "add" or "replace" (default: "replace")
            
        Returns:
            Dict with keys:
                - schema: The updated full schema
                - hash: The new schema hash
                - updated: Dict with op and path that were applied
                
        Example:
            # Update a link name
            result = cw.assets.patch_universal_schema(
                asset_id,
                path="/links/0/name",
                value="base_link_v2",
                op="replace"
            )
            
            # Add a new capability
            result = cw.assets.patch_universal_schema(
                asset_id,
                path="/extensions/cyberwave/capabilities/can_swim",
                value=True,
                op="add"
            )
            
            print(f"New schema hash: {result['hash']}")
        """
        from cyberwave.rest.models.universal_schema_patch_schema import (
            UniversalSchemaPatchSchema
        )
        
        try:
            payload = UniversalSchemaPatchSchema(
                op=op,
                path=path,
                value=value
            )
            return self.api.src_app_api_assets_patch_asset_universal_schema(
                asset_id, payload
            )
        except Exception as e:
            self._handle_error(e, f"patch universal schema for asset {asset_id}")
            raise

    def search(self, query: str) -> List[AssetSchema]:
        """Search for assets by name or tags"""
        try:
            _param = self.api.api_client.param_serialize(
                method="GET",
                resource_path="/api/v1/assets",
                query_params=[("search", query)],
            )

            _response_types_map = {
                "200": "List[AssetSchema]",
            }

            response_data = self.api.api_client.call_api(*_param)
            response_data.read()

            return self.api.api_client.response_deserialize(
                response_data=response_data,
                response_types_map=_response_types_map,
            ).data
        except Exception as e:
            self._handle_error(e, "search assets")
            raise  # For type checker

    def get_by_registry_id(self, registry_id: str) -> Optional[AssetSchema]:
        """Get asset by registry ID (e.g., 'cyberwave/ip-camera').

        Uses search API first for efficiency, falls back to list scan.
        """
        try:
            # Try search API first (more efficient)
            search_results = self.search(registry_id)
            for asset in search_results:
                if getattr(asset, "registry_id", None) == registry_id:
                    return asset

            # Fall back to full list scan if not found in search
            assets = self.list()
            for asset in assets:
                if getattr(asset, "registry_id", None) == registry_id:
                    return asset
            return None
        except Exception:
            return None

    def get_by_alias(self, alias: str) -> Optional[AssetSchema]:
        """Get asset by alias defined in metadata.aliases.

        Optimized to minimize API calls:
        1. First checks for exact name match (no extra queries)
        2. Uses search API to find candidates
        3. Only fetches full details for top candidates (max 5)
        4. Uses caching to avoid repeated fetches

        Args:
            alias: Alias to search for (case-insensitive)

        Returns:
            AssetSchema if found, None otherwise
        """
        try:
            alias_lower = alias.lower()

            # Step 1: Try search API - often returns relevant results
            search_results = self.search(alias)

            # Check search results for exact name match first (no extra query)
            for asset in search_results:
                name = getattr(asset, "name", "").lower()
                if name == alias_lower:
                    # Get full asset to ensure we have complete data
                    asset_uuid = str(getattr(asset, "uuid", ""))
                    cached = self._get_cached(asset_uuid)
                    if cached:
                        return cached
                    full_asset = self.get(asset_uuid)
                    self._set_cache(asset_uuid, full_asset)
                    return full_asset

            # Step 2: Check aliases in search results (limit to top 5 to avoid N+1)
            candidates = search_results[:5]
            for asset in candidates:
                asset_uuid = str(getattr(asset, "uuid", ""))
                if not asset_uuid:
                    continue

                # Check cache first
                cached = self._get_cached(asset_uuid)
                if cached:
                    full_metadata = getattr(cached, "metadata", {}) or {}
                    aliases = full_metadata.get("aliases", [])
                    if any(a.lower() == alias_lower for a in aliases):
                        return cached
                    continue

                # Fetch full asset to check aliases
                full_asset = self.get(asset_uuid)
                self._set_cache(asset_uuid, full_asset)
                full_metadata = getattr(full_asset, "metadata", {}) or {}
                aliases = full_metadata.get("aliases", [])
                if any(a.lower() == alias_lower for a in aliases):
                    return full_asset

            # Step 3: If not found in search results, scan full list for name match
            # This handles cases where search didn't return the asset
            assets = self.list()
            for asset in assets:
                name = getattr(asset, "name", "").lower()
                if name == alias_lower:
                    asset_uuid = str(getattr(asset, "uuid", ""))
                    cached = self._get_cached(asset_uuid)
                    if cached:
                        return cached
                    full_asset = self.get(asset_uuid)
                    self._set_cache(asset_uuid, full_asset)
                    return full_asset

            return None
        except Exception:
            return None


class EdgeManager(BaseResourceManager):
    """Manager for edge operations."""

    def list(self) -> List[EdgeSchema]:
        """List all edges available to the authenticated user."""
        try:
            _param = self.api.api_client.param_serialize(
                method="GET",
                resource_path="/api/v1/edges",
                auth_settings=["CustomTokenAuthentication"],
            )
            response_data = self.api.api_client.call_api(*_param)
            response_data.read()

            return self.api.api_client.response_deserialize(
                response_data=response_data,
                response_types_map={"200": "List[EdgeSchema]"},
            ).data
        except Exception as e:
            self._handle_error(e, "list edges")
            raise

    def get(self, edge_id: str) -> EdgeSchema:
        """Get edge by ID."""
        try:
            _param = self.api.api_client.param_serialize(
                method="GET",
                resource_path="/api/v1/edges/{uuid}",
                path_params={"uuid": edge_id},
                auth_settings=["CustomTokenAuthentication"],
            )
            response_data = self.api.api_client.call_api(*_param)
            response_data.read()

            return self.api.api_client.response_deserialize(
                response_data=response_data,
                response_types_map={"200": "EdgeSchema"},
            ).data
        except Exception as e:
            self._handle_error(e, f"get edge {edge_id}")
            raise

    def create(
        self,
        fingerprint: str,
        name: Optional[str] = None,
        workspace_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> EdgeSchema:
        """Create a new edge."""
        try:
            payload: Dict[str, Any] = {"fingerprint": fingerprint}
            if name is not None:
                payload["name"] = name
            if workspace_id is not None:
                payload["workspace_uuid"] = workspace_id
            if metadata is not None:
                payload["metadata"] = metadata
            _param = self.api.api_client.param_serialize(
                method="POST",
                resource_path="/api/v1/edges",
                body=payload,
                auth_settings=["CustomTokenAuthentication"],
            )
            response_data = self.api.api_client.call_api(*_param)
            response_data.read()

            return self.api.api_client.response_deserialize(
                response_data=response_data,
                response_types_map={"200": "EdgeSchema"},
            ).data
        except Exception as e:
            self._handle_error(e, "create edge")
            raise

    def update(self, edge_id: str, data: Dict[str, Any]) -> EdgeSchema:
        """Update edge metadata and name."""
        try:
            _param = self.api.api_client.param_serialize(
                method="PUT",
                resource_path="/api/v1/edges/{uuid}",
                path_params={"uuid": edge_id},
                body=data,
                auth_settings=["CustomTokenAuthentication"],
            )
            response_data = self.api.api_client.call_api(*_param)
            response_data.read()

            return self.api.api_client.response_deserialize(
                response_data=response_data,
                response_types_map={"200": "EdgeSchema"},
            ).data
        except Exception as e:
            self._handle_error(e, f"update edge {edge_id}")
            raise

    def delete(self, edge_id: str) -> Dict[str, bool]:
        """Delete an edge."""
        try:
            _param = self.api.api_client.param_serialize(
                method="DELETE",
                resource_path="/api/v1/edges/{uuid}",
                path_params={"uuid": edge_id},
                auth_settings=["CustomTokenAuthentication"],
            )
            response_data = self.api.api_client.call_api(*_param)
            response_data.read()

            return self.api.api_client.response_deserialize(
                response_data=response_data,
                response_types_map={"200": "Dict[str, bool]"},
            ).data
        except Exception as e:
            self._handle_error(e, f"delete edge {edge_id}")
            raise


class TwinManager(BaseResourceManager):
    """Manager for digital twin operations"""

    def __init__(self, api_client, client=None):
        super().__init__(api_client)
        self._client = client

    def list(self, environment_id: Optional[str] = None) -> List[TwinSchema]:
        """List all twins, optionally filtered by environment"""
        try:
            if environment_id:
                return self.api.src_app_api_environments_get_environment_twins(
                    environment_id
                )
            else:
                return self.api.src_app_api_twins_list_all_twins()
        except Exception as e:
            self._handle_error(e, "list twins")
            raise  # For type checker

    def get(self, twin_id: str):
        """Get twin by ID. Returns a Twin object with motion/navigation handles."""
        try:
            twin_data = self.api.src_app_api_twins_get_twin(twin_id)
            if self._client:
                from .twin import create_twin

                return create_twin(self._client, twin_data)
            return twin_data
        except Exception as e:
            self._handle_error(e, f"get twin {twin_id}")
            raise  # For type checker

    def get_raw(self, twin_id: str) -> TwinSchema:
        """Get raw twin data by ID (returns TwinSchema)"""
        try:
            return self.api.src_app_api_twins_get_twin(twin_id)
        except Exception as e:
            self._handle_error(e, f"get twin {twin_id}")
            raise  # For type checker

    def create(self, asset_id: str, environment_id: str, **kwargs) -> TwinSchema:
        """Create a new twin instance"""
        try:
            create_schema = TwinCreateSchema(
                asset_uuid=asset_id, environment_uuid=environment_id, **kwargs
            )
            return self.api.src_app_api_twins_create_twin(create_schema)
        except Exception as e:
            self._handle_error(e, "create twin")
            raise  # For type checker

    def update_state(self, twin_id: str, data: Dict[str, Any]) -> TwinSchema:
        """Update twin state (position, rotation, scale)"""
        try:
            update_schema = TwinStateUpdateSchema(**data)
            return self.api.src_app_api_twins_update_twin_state(twin_id, update_schema)
        except Exception as e:
            self._handle_error(e, f"update twin state {twin_id}")
            raise  # For type checker

    def update(self, twin_id: str, **kwargs) -> TwinSchema:
        """Update twin metadata and other fields.

        Uses the auto-generated REST API method for PUT /api/v1/twins/{uuid}.
        The backend accepts TwinCreateSchema for updates.

        Args:
            twin_id: UUID of the twin to update
            **kwargs: Fields to update (name, description, metadata, capabilities, etc.)

        Returns:
            Updated TwinSchema
        """
        try:
            # The backend uses TwinCreateSchema for PUT /twins/{uuid}
            update_schema = TwinCreateSchema(**kwargs)
            return self.api.src_app_api_twins_update_twin(twin_id, update_schema)
        except Exception as e:
            self._handle_error(e, f"update twin {twin_id}")
            raise  # For type checker

    def delete(self, twin_id: str) -> None:
        """Delete a twin"""
        try:
            self.api.src_app_api_twins_delete_twin(twin_id)
        except Exception as e:
            self._handle_error(e, f"delete twin {twin_id}")

    def get_joint_states(self, twin_id: str) -> JointStatesSchema:
        """Get current joint states for a twin"""
        try:
            return self.api.src_app_api_urdf_get_twin_joint_states(twin_id)
        except Exception as e:
            self._handle_error(e, f"get joint states for twin {twin_id}")
            raise  # For type checker

    def update_joint_state(
        self,
        twin_id: str,
        joint_name: str,
        position: float,
        velocity: Optional[float] = None,
        effort: Optional[float] = None,
    ) -> JointStateSchema:
        """Update a single joint state"""
        try:
            update_schema = JointStateUpdateSchema(
                position=position, velocity=velocity, effort=effort
            )
            return self.api.src_app_api_urdf_update_twin_joint_state(
                twin_id, joint_name, update_schema
            )
        except Exception as e:
            self._handle_error(e, f"update joint state for twin {twin_id}")
            raise  # For type checker

    # =========================================================================
    # Universal Schema APIs
    # =========================================================================

    def get_universal_schema_at_path(self, twin_id: str, path: str = "") -> Dict[str, Any]:
        """
        Get value at a specific JSON Pointer path in the twin's universal schema.
        
        Args:
            twin_id: UUID of the twin
            path: JSON Pointer path (e.g., "/sensors/0", "/extensions/cyberwave/capabilities")
                 Empty string returns the entire schema
            
        Returns:
            Dict with keys:
                - path: The JSON Pointer path
                - value: The value at that path (can be any JSON type)
                
        Example:
            # Get entire schema
            result = cw.twins.get_universal_schema_at_path(twin_id)
            schema = result['value']
            
            # Get specific path
            result = cw.twins.get_universal_schema_at_path(twin_id, "/sensors/0")
            sensor = result['value']
            
            # Get capabilities
            result = cw.twins.get_universal_schema_at_path(
                twin_id, 
                "/extensions/cyberwave/capabilities"
            )
            capabilities = result['value']
        """
        try:
            return self.api.src_app_api_twins_get_twin_universal_schema_at_path(
                twin_id, path=path
            )
        except Exception as e:
            self._handle_error(e, f"get universal schema at path for twin {twin_id}")
            raise

    def patch_universal_schema(
        self, 
        twin_id: str, 
        path: str, 
        value: Any, 
        op: str = "replace"
    ) -> Dict[str, Any]:
        """
        Update the twin's universal schema using JSON Pointer operations.
        
        This allows editing twin-specific schema overrides, such as:
        - /sensors (array of sensor objects)
        - /extensions/cyberwave/capabilities
        
        Args:
            twin_id: UUID of the twin
            path: JSON Pointer path to update (e.g., "/sensors/0/parameters/id")
            value: Value to set at the path
            op: Operation type - "add" or "replace" (default: "replace")
            
        Returns:
            Dict with keys:
                - schema: The updated full schema
                - updated: Dict with op and path that were applied
                
        Example:
            # Update a sensor ID
            result = cw.twins.patch_universal_schema(
                twin_id,
                path="/sensors/0/parameters/id",
                value="my_camera",
                op="replace"
            )
            
            # Add a new capability
            result = cw.twins.patch_universal_schema(
                twin_id,
                path="/extensions/cyberwave/capabilities/can_fly",
                value=True,
                op="add"
            )
        """
        from cyberwave.rest.models.twin_universal_schema_patch_schema import (
            TwinUniversalSchemaPatchSchema
        )
        
        try:
            payload = TwinUniversalSchemaPatchSchema(
                op=op,
                path=path,
                value=value
            )
            return self.api.src_app_api_twins_patch_twin_universal_schema(
                twin_id, payload
            )
        except Exception as e:
            self._handle_error(e, f"patch universal schema for twin {twin_id}")
            raise

    # =========================================================================
    # Edge Device Pairing
    # =========================================================================

    @staticmethod
    def _extract_camera_config_from_binding(binding: Dict[str, Any]) -> Dict[str, Any]:
        if not isinstance(binding, dict):
            return {}

        camera_config = binding.get("camera_config")
        if isinstance(camera_config, dict):
            return dict(camera_config)

        cameras = binding.get("cameras")
        if isinstance(cameras, list):
            for camera in cameras:
                if isinstance(camera, dict):
                    return dict(camera)

        # Legacy shape: top-level config fields stored directly under fingerprint key.
        return {
            key: value
            for key, value in binding.items()
            if key not in EDGE_CONFIG_INTERNAL_KEYS
        }

    @staticmethod
    def _device_uuid_for_fingerprint(twin_id: str, fingerprint: str) -> str:
        key = f"{twin_id}:{fingerprint}"
        return str(uuid_lib.uuid5(EDGE_DEVICE_UUID_NAMESPACE, key))

    def _get_twin_edge_configs(
        self, twin_id: str
    ) -> tuple[TwinSchema, Dict[str, Any], Dict[str, Any]]:
        twin = self.api.src_app_api_twins_get_twin(twin_id)
        metadata = getattr(twin, "metadata", {}) or {}
        if not isinstance(metadata, dict):
            metadata = {}
        edge_configs = metadata.get(EDGE_CONFIGS_KEY, {})
        if not isinstance(edge_configs, dict):
            edge_configs = {}
        return twin, metadata, edge_configs

    @staticmethod
    def _is_legacy_edge_configs_map(edge_configs: Dict[str, Any]) -> bool:
        if not isinstance(edge_configs, dict) or not edge_configs:
            return False
        if "edge_fingerprint" in edge_configs or "camera_config" in edge_configs:
            return False
        return all(isinstance(value, dict) for value in edge_configs.values())

    def _resolve_binding_for_fingerprint(
        self, edge_configs: Dict[str, Any], fingerprint: str
    ) -> Optional[Dict[str, Any]]:
        if not isinstance(edge_configs, dict) or not fingerprint:
            return None

        if self._is_legacy_edge_configs_map(edge_configs):
            binding = edge_configs.get(fingerprint)
            return binding if isinstance(binding, dict) else None

        candidate_fingerprint = edge_configs.get("edge_fingerprint")
        if isinstance(candidate_fingerprint, str):
            if candidate_fingerprint != fingerprint:
                return None
            return edge_configs
        return None

    def _iter_edge_bindings(
        self, edge_configs: Dict[str, Any]
    ) -> List[tuple[str, Dict[str, Any]]]:
        if not isinstance(edge_configs, dict) or not edge_configs:
            return []

        if self._is_legacy_edge_configs_map(edge_configs):
            bindings: List[tuple[str, Dict[str, Any]]] = []
            for fingerprint, binding in edge_configs.items():
                if isinstance(fingerprint, str) and isinstance(binding, dict):
                    bindings.append((fingerprint, binding))
            return bindings

        fingerprint = edge_configs.get("edge_fingerprint")
        if isinstance(fingerprint, str) and fingerprint:
            return [(fingerprint, edge_configs)]
        return []

    def _build_device_dict(
        self, twin_id: str, fingerprint: str, binding: Dict[str, Any]
    ) -> Dict[str, Any]:
        binding = binding if isinstance(binding, dict) else {}
        device_info = binding.get("device_info")
        if not isinstance(device_info, dict):
            device_info = {}

        last_sync = binding.get("last_sync")
        last_ip_address = binding.get("last_ip_address")

        status_data = binding.get("status_data")
        status = "online" if isinstance(status_data, dict) else "offline"

        return {
            "uuid": self._device_uuid_for_fingerprint(twin_id, fingerprint),
            "fingerprint": fingerprint,
            "twin_uuid": twin_id,
            "hostname": device_info.get("hostname", ""),
            "platform": device_info.get("platform", ""),
            "status": status,
            "last_heartbeat": last_sync,
            "last_ip_address": last_ip_address,
            "edge_config": self._extract_camera_config_from_binding(binding),
            "paired_at": binding.get("registered_at") or last_sync,
            "paired_by_uuid": None,
            "updated_at": last_sync,
        }

    def _update_twin_edge_configs(
        self, twin_id: str, metadata: Dict[str, Any], edge_configs: Dict[str, Any]
    ) -> TwinSchema:
        metadata[EDGE_CONFIGS_KEY] = edge_configs
        return self.update(twin_id, metadata=metadata)

    def pair_device(
        self,
        twin_id: str,
        fingerprint: str,
        hostname: str = "",
        platform: str = "",
        edge_config: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Pair an edge device to this twin.

        Persists the pairing in twin.metadata["edge_configs"] as a single
        binding object.
        This method no longer calls legacy /twins/{uuid}/devices endpoints.

        Args:
            twin_id: UUID of the twin to pair with
            fingerprint: Stable device fingerprint (from generate_fingerprint())
            hostname: Device hostname (optional)
            platform: Device platform (e.g., 'Darwin-arm64')
            edge_config: Device configuration (cameras, models, etc.)

        Returns:
            Dict with device details

        Raises:
            CyberwaveAPIError: If fingerprint is paired to a different twin
        """
        try:
            if not fingerprint:
                raise CyberwaveAPIError("Fingerprint is required", status_code=400)

            # Preserve old one-device-to-one-twin behavior.
            all_twins = self.api.src_app_api_twins_list_all_twins()
            for twin in all_twins:
                candidate_uuid = str(getattr(twin, "uuid", ""))
                if candidate_uuid == str(twin_id):
                    continue

                candidate_metadata = getattr(twin, "metadata", {}) or {}
                if not isinstance(candidate_metadata, dict):
                    continue
                candidate_edge_configs = candidate_metadata.get(EDGE_CONFIGS_KEY, {})
                if self._resolve_binding_for_fingerprint(candidate_edge_configs, fingerprint):
                    raise CyberwaveAPIError(
                        (
                            f"Device already paired to twin '{candidate_uuid}'. "
                            "Unpair it first."
                        ),
                        status_code=409,
                    )

            _, metadata, edge_configs = self._get_twin_edge_configs(twin_id)

            existing = self._resolve_binding_for_fingerprint(edge_configs, fingerprint) or {}
            if not isinstance(existing, dict):
                existing = {}

            device_info = existing.get("device_info")
            if not isinstance(device_info, dict):
                device_info = {}
            if hostname:
                device_info["hostname"] = hostname
            if platform:
                device_info["platform"] = platform

            now_iso = datetime.now(timezone.utc).isoformat()
            existing["device_info"] = device_info
            existing["camera_config"] = edge_config or {}
            existing["registered_at"] = existing.get("registered_at") or now_iso
            existing["last_sync"] = now_iso
            existing["edge_fingerprint"] = fingerprint

            self._update_twin_edge_configs(twin_id, metadata, existing)
            return self._build_device_dict(twin_id, fingerprint, existing)
        except Exception as e:
            self._handle_error(e, f"pair device to twin {twin_id}")
            raise

    def list_devices(self, twin_id: str) -> List[Dict[str, Any]]:
        """
        List all edge devices paired to this twin.

        Args:
            twin_id: UUID of the twin

        Returns:
            List of device dicts
        """
        try:
            _, _, edge_configs = self._get_twin_edge_configs(twin_id)
            devices: List[Dict[str, Any]] = []
            for fingerprint, binding in self._iter_edge_bindings(edge_configs):
                devices.append(self._build_device_dict(twin_id, fingerprint, binding))
            return devices
        except Exception as e:
            self._handle_error(e, f"list devices for twin {twin_id}")
            raise

    def get_device(self, twin_id: str, device_uuid: str) -> Dict[str, Any]:
        """
        Get a specific edge device by UUID (derived from fingerprint).

        Args:
            twin_id: UUID of the twin
            device_uuid: Deterministic UUID derived from (twin_id, fingerprint)

        Returns:
            Device details dict
        """
        try:
            devices = self.list_devices(twin_id)
            for device in devices:
                if str(device.get("uuid")) == str(device_uuid):
                    return device
            raise CyberwaveAPIError("Device not found", status_code=404)
        except Exception as e:
            self._handle_error(e, f"get device {device_uuid} for twin {twin_id}")
            raise

    def unpair_device(self, twin_id: str, device_uuid: str) -> Dict[str, str]:
        """
        Unpair (remove) an edge device from this twin.

        Args:
            twin_id: UUID of the twin
            device_uuid: UUID of the EdgeDevice to unpair

        Returns:
            Status message
        """
        try:
            _, metadata, edge_configs = self._get_twin_edge_configs(twin_id)
            if self._is_legacy_edge_configs_map(edge_configs):
                fingerprint_to_remove = None
                for fingerprint, _binding in self._iter_edge_bindings(edge_configs):
                    if (
                        self._device_uuid_for_fingerprint(twin_id, fingerprint)
                        == str(device_uuid)
                    ):
                        fingerprint_to_remove = fingerprint
                        break

                if not fingerprint_to_remove:
                    raise CyberwaveAPIError("Device not found", status_code=404)

                edge_configs.pop(fingerprint_to_remove, None)
                if edge_configs:
                    self._update_twin_edge_configs(twin_id, metadata, edge_configs)
                else:
                    metadata.pop(EDGE_CONFIGS_KEY, None)
                    self.update(twin_id, metadata=metadata)
                return {
                    "status": "ok",
                    "message": f"Device {fingerprint_to_remove} unpaired",
                }

            binding_fingerprint = edge_configs.get("edge_fingerprint")
            if (
                not isinstance(binding_fingerprint, str)
                or self._device_uuid_for_fingerprint(twin_id, binding_fingerprint)
                != str(device_uuid)
            ):
                raise CyberwaveAPIError("Device not found", status_code=404)

            metadata.pop(EDGE_CONFIGS_KEY, None)
            self.update(twin_id, metadata=metadata)
            return {"status": "ok", "message": f"Device {binding_fingerprint} unpaired"}
        except Exception as e:
            self._handle_error(e, f"unpair device {device_uuid} from twin {twin_id}")
            raise

    def device_heartbeat(
        self,
        twin_id: str,
        fingerprint: str,
        status_data: Optional[Dict[str, Any]] = None,
        ip_address: Optional[str] = None,
    ) -> Dict[str, str]:
        """
        Send a heartbeat for an edge device.

        Called periodically by edge devices to report they are online.

        Args:
            twin_id: UUID of the twin
            fingerprint: Device fingerprint
            status_data: Optional status data (streams, uptime, etc.)
            ip_address: Device IP address (optional)

        Returns:
            Status message
        """
        try:
            _, metadata, edge_configs = self._get_twin_edge_configs(twin_id)

            binding = self._resolve_binding_for_fingerprint(edge_configs, fingerprint)
            if not isinstance(binding, dict):
                raise CyberwaveAPIError(
                    (
                        f"Device {fingerprint} not paired to this twin. "
                        "Pair it first with twins.pair_device(...)"
                    ),
                    status_code=404,
                )

            if status_data is not None:
                binding["status_data"] = status_data
            if ip_address:
                binding["last_ip_address"] = ip_address
            binding["last_sync"] = datetime.now(timezone.utc).isoformat()
            binding["edge_fingerprint"] = fingerprint

            self._update_twin_edge_configs(twin_id, metadata, binding)
            return {"status": "ok", "message": "Heartbeat received"}
        except Exception as e:
            self._handle_error(e, f"device heartbeat for twin {twin_id}")
            raise

    # =========================================================================
    # Calibration Management
    # =========================================================================

    def get_calibration(
        self,
        twin_id: str,
        robot_type: Optional[str] = None,
    ) -> TwinJointCalibrationSchema:
        """
        Get calibration data for a twin.

        Args:
            twin_id: UUID of the twin
            robot_type: Optional robot type filter ("leader" or "follower").
                       If None, returns all calibration data.

        Returns:
            TwinJointCalibrationSchema containing calibration data

        Note:
            Query parameters for filtering by robot_type are not in the OpenAPI spec,
            so we use manual API calls to support them.
        """
        try:
            # Use auto-generated method if no query params needed
            if robot_type is None:
                return self.api.src_app_api_twins_get_twin_calibration(uuid=twin_id)

            # For query params, use manual API call since they're not in the spec
            query_params = []
            if robot_type == "leader":
                query_params = [("is_leader", True)]
            elif robot_type == "follower":
                query_params = [("is_follower", True)]

            _param = self.api.api_client.param_serialize(
                method="GET",
                resource_path="/api/v1/twins/{uuid}/calibration",
                path_params={"uuid": twin_id},
                query_params=query_params,
                auth_settings=["CustomTokenAuthentication"],
            )
            response_data = self.api.api_client.call_api(*_param)
            response_data.read()

            return self.api.api_client.response_deserialize(
                response_data=response_data,
                response_types_map={"200": "TwinJointCalibrationSchema"},
            ).data
        except Exception as e:
            self._handle_error(e, f"get calibration for twin {twin_id}")
            raise

    def update_calibration(
        self,
        twin_id: str,
        joint_calibration: Dict[str, Dict[str, Any]],
        robot_type: str,
    ) -> TwinJointCalibrationSchema:
        """
        Update calibration data for a twin.

        Args:
            twin_id: UUID of the twin
            joint_calibration: Dictionary mapping joint names to calibration data.
                             Each calibration dict should contain:
                             - range_min: float
                             - range_max: float
                             - homing_offset: float
                             - drive_mode: int or str
                             - id: int or str (motor ID)
            robot_type: Robot type ("leader" or "follower")

        Returns:
            Updated TwinJointCalibrationSchema

        Example:
            >>> calibration = {
            ...     "shoulder_pan": {
            ...         "range_min": 0.0,
            ...         "range_max": 4095.0,
            ...         "homing_offset": 2047.5,
            ...         "drive_mode": 0,
            ...         "id": 1
            ...     },
            ...     # ... more joints
            ... }
            >>> manager.update_calibration(twin_id, calibration, "leader")
        """
        if robot_type not in ["leader", "follower"]:
            raise ValueError(f"robot_type must be 'leader' or 'follower', got '{robot_type}'")

        try:
            # Build calibration dicts (not model objects) for robustness across SDK versions
            joint_calibration_dicts = {}
            for joint_name, calib_dict in joint_calibration.items():
                # Only include lower/upper if they are valid numbers (avoids validation errors)
                base = {
                    "range_min": calib_dict["range_min"],
                    "range_max": calib_dict["range_max"],
                    "homing_offset": calib_dict["homing_offset"],
                    "drive_mode": str(calib_dict["drive_mode"]),
                    "id": str(calib_dict["id"]),
                }
                for key in ("lower", "upper"):
                    val = calib_dict.get(key)
                    if val is not None and isinstance(val, (int, float)):
                        try:
                            float(val)
                            base[key] = float(val)
                        except (TypeError, ValueError):
                            pass
                joint_calibration_dicts[joint_name] = base

            schema = TwinJointCalibrationSchema.model_validate({
                "joint_calibration": joint_calibration_dicts,
                "robot_type": robot_type,
            })

            return self.api.src_app_api_twins_update_twin_calibration(
                uuid=twin_id,
                twin_joint_calibration_schema=schema,
            )
        except Exception as e:
            self._handle_error(e, f"update calibration for twin {twin_id}")
            raise
