"""HTTP clients for wl-apdp API.

This module provides both async and sync HTTP clients for interacting with
the wl-apdp authorization service.

Example (async):
    >>> async with WlApdpClient("http://localhost:8081/api") as client:
    ...     result = await client.authorize(
    ...         principal='Agent::"my-agent"',
    ...         action='Action::"execute"',
    ...         resource='Tool::"web_search"'
    ...     )
    ...     print(result.decision)  # "Allow" or "Deny"

Example (sync):
    >>> client = WlApdpSyncClient("http://localhost:8081/api")
    >>> result = client.authorize(
    ...     principal='Agent::"my-agent"',
    ...     action='Action::"execute"',
    ...     resource='Tool::"web_search"'
    ... )
    >>> client.close()
"""

from __future__ import annotations

import logging
from typing import Any

import httpx

from .exceptions import (
    AgentQuarantined,
    AgentTrustRevoked,
    AgentUnverified,
    AuthorizationDenied,
    ServiceUnavailable,
    ValidationError,
)
from .models import (
    AuthorizationRequest,
    AuthorizationResponse,
    CapabilitiesQuery,
    CapabilitiesResponse,
    ConstraintManifest,
    HealthResponse,
    Policy,
    PolicyAnalysis,
    PreflightRequest,
    PreflightResponse,
)

logger = logging.getLogger("wl_apdp")


def _handle_trust_error(e: httpx.HTTPStatusError, agent_id: str | None = None) -> None:
    """Handle trust-related HTTP errors.

    Trust-gated guidance access control returns HTTP 403 for untrusted agents:
    - AgentTrustRevoked: Agent's trust has been revoked (no access to guidance)
    - AgentQuarantined: Agent is under investigation (restricted guidance)
    - AgentUnverified: Agent hasn't been verified (no capabilities disclosed)

    Args:
        e: The HTTP status error.
        agent_id: The agent ID for context in error messages.

    Raises:
        AgentTrustRevoked: If the error indicates revoked trust.
        AgentQuarantined: If the error indicates quarantine status.
        AgentUnverified: If the error indicates unverified status.
        httpx.HTTPStatusError: If the error is not trust-related.
    """
    if e.response.status_code == 403:
        try:
            error_data = e.response.json()
            error_type = error_data.get("error", {}).get("type", "")
            error_message = error_data.get("error", {}).get("message", str(e))

            if "trust" in error_type.lower() and "revoked" in error_type.lower():
                raise AgentTrustRevoked(error_message, agent_id=agent_id) from e
            elif "quarantined" in error_type.lower():
                raise AgentQuarantined(error_message, agent_id=agent_id) from e
            elif "unverified" in error_type.lower():
                raise AgentUnverified(error_message, agent_id=agent_id) from e

            # Check message content as fallback
            lower_message = error_message.lower()
            if "revoked" in lower_message:
                raise AgentTrustRevoked(error_message, agent_id=agent_id) from e
            elif "quarantine" in lower_message:
                raise AgentQuarantined(error_message, agent_id=agent_id) from e
            elif "unverified" in lower_message or "not verified" in lower_message:
                raise AgentUnverified(error_message, agent_id=agent_id) from e
        except (ValueError, KeyError):
            pass  # JSON parsing failed, re-raise original error

    # Not a trust-related error, re-raise
    raise e


def _extract_agent_id(principal: str) -> str | None:
    """Extract agent ID from a Cedar principal reference.

    Args:
        principal: Cedar principal reference (e.g., 'Agent::"my-agent"').

    Returns:
        The extracted agent ID, or None if extraction fails.
    """
    if '::' in principal:
        parts = principal.split('::', 1)
        if len(parts) == 2:
            # Remove quotes from the ID part
            return parts[1].strip('"').strip("'")
    return None


class WlApdpClient:
    """Async client for wl-apdp API.

    This client uses httpx.AsyncClient for async HTTP operations. It should
    be used as an async context manager.

    Attributes:
        base_url: The base URL of the wl-apdp API.
        timeout: Request timeout in seconds.

    Example:
        >>> async with WlApdpClient() as client:
        ...     if await client.is_allowed(principal, action, resource):
        ...         # do something
        ...         pass
    """

    def __init__(
        self,
        base_url: str = "http://localhost:8081/api",
        token: str | None = None,
        timeout: float = 10.0,
    ) -> None:
        """Initialize the async client.

        Args:
            base_url: The base URL of the wl-apdp API.
            token: Optional bearer token for authentication.
            timeout: Request timeout in seconds.
        """
        self.base_url = base_url.rstrip("/")
        self.token = token
        self.timeout = timeout
        self._client: httpx.AsyncClient | None = None

    async def __aenter__(self) -> WlApdpClient:
        """Enter the async context manager."""
        headers: dict[str, str] = {}
        if self.token:
            headers["Authorization"] = f"Bearer {self.token}"
        self._client = httpx.AsyncClient(
            base_url=self.base_url,
            headers=headers,
            timeout=self.timeout,
        )
        return self

    async def __aexit__(self, *args: Any) -> None:
        """Exit the async context manager."""
        if self._client:
            await self._client.aclose()
            self._client = None

    def _ensure_client(self) -> httpx.AsyncClient:
        """Ensure the client is available."""
        if self._client is None:
            raise RuntimeError(
                "Client not initialized. Use 'async with WlApdpClient() as client:'"
            )
        return self._client

    async def authorize(
        self,
        principal: str,
        action: str,
        resource: str,
        context: dict[str, Any] | None = None,
        raise_on_deny: bool = False,
    ) -> AuthorizationResponse:
        """Check authorization for an action.

        Args:
            principal: Cedar principal reference (e.g., 'Agent::"my-agent"').
            action: Cedar action reference (e.g., 'Action::"execute"').
            resource: Cedar resource reference (e.g., 'Tool::"web_search"').
            context: Optional context for policy evaluation.
            raise_on_deny: If True, raise AuthorizationDenied on deny.

        Returns:
            AuthorizationResponse with the decision.

        Raises:
            AuthorizationDenied: If raise_on_deny is True and access is denied.
            ServiceUnavailable: If the service is unavailable.
        """
        client = self._ensure_client()
        request = AuthorizationRequest(
            principal=principal,
            action=action,
            resource=resource,
            context=context or {},
        )

        try:
            response = await client.post("/authorize", json=request.model_dump_for_api())
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 400:
                error_data = e.response.json()
                raise ValidationError(
                    error_data.get("error", {}).get("message", "Validation error"),
                    errors=error_data.get("error", {}).get("details", []),
                ) from e
            raise

        result = AuthorizationResponse(**response.json())

        if raise_on_deny and not result.is_allowed:
            raise AuthorizationDenied(
                f"Access denied: {principal} cannot {action} on {resource}",
                response=result,
                principal=principal,
                action=action,
                resource=resource,
            )

        return result

    async def is_allowed(
        self,
        principal: str,
        action: str,
        resource: str,
        context: dict[str, Any] | None = None,
    ) -> bool:
        """Simple boolean authorization check.

        Args:
            principal: Cedar principal reference.
            action: Cedar action reference.
            resource: Cedar resource reference.
            context: Optional context for policy evaluation.

        Returns:
            True if allowed, False otherwise.
        """
        result = await self.authorize(principal, action, resource, context)
        return result.is_allowed

    async def analyze(
        self,
        principal: str,
        action: str,
        resource: str,
        context: dict[str, Any] | None = None,
    ) -> PolicyAnalysis:
        """Analyze which policies apply to a request.

        Args:
            principal: Cedar principal reference.
            action: Cedar action reference.
            resource: Cedar resource reference.
            context: Optional context for policy evaluation.

        Returns:
            PolicyAnalysis with applicable policies.
        """
        client = self._ensure_client()
        try:
            response = await client.post(
                "/policies/analyze",
                json={
                    "principal": principal,
                    "action": action,
                    "resource": resource,
                    "context": context or {},
                },
            )
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e

        return PolicyAnalysis(**response.json())

    async def health(self) -> bool:
        """Check if wl-apdp service is healthy.

        Returns:
            True if healthy, False otherwise.
        """
        client = self._ensure_client()
        try:
            response = await client.get("/health")
            return response.status_code == 200
        except Exception:
            return False

    async def get_health(self) -> HealthResponse:
        """Get detailed health information.

        Returns:
            HealthResponse with status details.
        """
        client = self._ensure_client()
        response = await client.get("/health")
        response.raise_for_status()
        return HealthResponse(**response.json())

    async def list_policies(self) -> list[Policy]:
        """List all policies.

        Returns:
            List of Policy objects.
        """
        client = self._ensure_client()
        response = await client.get("/policies")
        response.raise_for_status()
        data = response.json()
        return [Policy(**p) for p in data.get("policies", [])]

    # =========================================================================
    # Constraint Manifest Methods (Agent-Native Constraint Interface)
    # =========================================================================

    async def get_constraint_manifest(
        self,
        agent_id: str,
        goal_id: str | None = None,
        include_guidance: bool = True,
    ) -> ConstraintManifest:
        """Get the constraint manifest for an agent.

        The constraint manifest provides a complete picture of the agent's
        operational boundaries. Agents should fetch this at session start
        and use it for self-governance.

        Args:
            agent_id: The agent's unique identifier.
            goal_id: Optional goal ID to include goal-specific constraints.
            include_guidance: Whether to include natural language guidance.

        Returns:
            ConstraintManifest with capabilities, constraints, and guidance.

        Raises:
            ServiceUnavailable: If the service is unavailable.

        Raises:
            AgentTrustRevoked: If the agent's trust has been revoked.
            AgentQuarantined: If the agent is quarantined (returns restricted manifest).
            AgentUnverified: If the agent has not been verified (returns limited manifest).
            ServiceUnavailable: If the service is unavailable.

        Example:
            >>> manifest = await client.get_constraint_manifest("researcher")
            >>> if manifest.can_perform("read", "Document"):
            ...     # Proceed with read operation
            ...     pass
        """
        client = self._ensure_client()
        request: dict[str, Any] = {
            "agent_id": agent_id,
            "include_guidance": include_guidance,
        }
        if goal_id:
            request["goal_id"] = goal_id

        try:
            response = await client.post("/constraints/manifest", json=request)
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e
        except httpx.HTTPStatusError as e:
            _handle_trust_error(e, agent_id)

        data = response.json()
        return ConstraintManifest(**data.get("manifest", data))

    async def preflight(
        self,
        agent_id: str,
        action: str,
        resource: str,
        resource_type: str | None = None,
        intent_category: str | None = None,
        goal_id: str | None = None,
        context: dict[str, Any] | None = None,
    ) -> PreflightResponse:
        """Check if an action would be allowed without consuming quota.

        Pre-flight checks let agents validate actions before executing them.
        Unlike authorize(), preflight doesn't count against action limits.

        Args:
            agent_id: Agent ID making the request.
            action: Action to check (e.g., "read", "write", "execute").
            resource: Resource to check (e.g., "document-123").
            resource_type: Optional resource type for type-specific checks.
            intent_category: Intent category for this action.
            goal_id: Goal ID if operating under a goal.
            context: Optional additional context.

        Returns:
            PreflightResponse with allowed status and suggestions.

        Raises:
            AgentTrustRevoked: If the agent's trust has been revoked.
            AgentQuarantined: If the agent is quarantined.
            AgentUnverified: If the agent has not been verified.
            ServiceUnavailable: If the service is unavailable.

        Example:
            >>> result = await client.preflight(
            ...     agent_id="researcher",
            ...     action="execute",
            ...     resource="web_search",
            ...     resource_type="Tool",
            ...     intent_category="Research"
            ... )
            >>> if result.allowed:
            ...     # Safe to proceed with actual authorization
            ...     pass
            >>> else:
            ...     print(f"Would be denied: {result.explanation}")
            ...     for suggestion in result.suggestions:
            ...         print(f"Suggestion: {suggestion.description}")
        """
        client = self._ensure_client()

        # Build intent if category provided
        intent = None
        if intent_category:
            intent = {
                "objective": f"Preflight check for {action} operation",
                "category": intent_category,
                "risk_level": "low",
            }

        request = PreflightRequest(
            agent_id=agent_id,
            action=action,
            resource=resource,
            resource_type=resource_type,
            intent=intent,
            goal_id=goal_id,
            context=context or {},
        )

        try:
            response = await client.post("/constraints/preflight", json=request.model_dump(exclude_none=True))
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e
        except httpx.HTTPStatusError as e:
            _handle_trust_error(e, agent_id)

        data = response.json()
        return PreflightResponse(**data.get("preflight", data))

    async def query_capabilities(
        self,
        agent_id: str,
        resource_type: str | None = None,
        action: str | None = None,
        intent_category: str | None = None,
    ) -> CapabilitiesResponse:
        """Query what capabilities are available.

        Use this to discover what actions an agent can perform on what
        resource types, optionally filtered by various criteria.

        Args:
            agent_id: The agent's unique identifier.
            resource_type: Filter by resource type.
            action: Filter by action.
            intent_category: Filter by intent category.

        Returns:
            CapabilitiesResponse with matching capabilities.

        Raises:
            ServiceUnavailable: If the service is unavailable.

        Example:
            >>> result = await client.query_capabilities(
            ...     agent_id="researcher",
            ...     resource_type="Document"
            ... )
            >>> for cap in result.capabilities:
            ...     print(f"Can {cap.action} on {cap.resource_types}")
        """
        client = self._ensure_client()
        query = CapabilitiesQuery(
            agent_id=agent_id,
            resource_type=resource_type,
            action=action,
            intent_category=intent_category,
        )

        try:
            response = await client.post("/constraints/capabilities", json=query.model_dump())
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e

        return CapabilitiesResponse(**response.json())

    async def refresh_manifest(
        self,
        manifest_id: str,
    ) -> ConstraintManifest:
        """Refresh an existing constraint manifest.

        Use this for long-running sessions to get updated constraints
        without re-fetching the full manifest.

        Args:
            manifest_id: ID of the manifest to refresh.

        Returns:
            Updated ConstraintManifest.

        Raises:
            ServiceUnavailable: If the service is unavailable.
        """
        client = self._ensure_client()
        try:
            response = await client.post(
                "/constraints/refresh",
                json={"manifest_id": manifest_id},
            )
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e

        data = response.json()
        return ConstraintManifest(**data.get("manifest", data))


class WlApdpSyncClient:
    """Synchronous client for wl-apdp API.

    This client uses httpx.Client for sync HTTP operations. It can be used
    directly or as a context manager.

    Example:
        >>> client = WlApdpSyncClient()
        >>> try:
        ...     result = client.authorize(principal, action, resource)
        ... finally:
        ...     client.close()

        # Or as context manager:
        >>> with WlApdpSyncClient() as client:
        ...     result = client.authorize(principal, action, resource)
    """

    def __init__(
        self,
        base_url: str = "http://localhost:8081/api",
        token: str | None = None,
        timeout: float = 10.0,
    ) -> None:
        """Initialize the sync client.

        Args:
            base_url: The base URL of the wl-apdp API.
            token: Optional bearer token for authentication.
            timeout: Request timeout in seconds.
        """
        self.base_url = base_url.rstrip("/")
        headers: dict[str, str] = {}
        if token:
            headers["Authorization"] = f"Bearer {token}"
        self._client = httpx.Client(
            base_url=self.base_url,
            headers=headers,
            timeout=timeout,
        )

    def __enter__(self) -> WlApdpSyncClient:
        """Enter the context manager."""
        return self

    def __exit__(self, *args: Any) -> None:
        """Exit the context manager."""
        self.close()

    def close(self) -> None:
        """Close the HTTP client."""
        self._client.close()

    def authorize(
        self,
        principal: str,
        action: str,
        resource: str,
        context: dict[str, Any] | None = None,
        raise_on_deny: bool = False,
    ) -> AuthorizationResponse:
        """Check authorization for an action.

        Args:
            principal: Cedar principal reference.
            action: Cedar action reference.
            resource: Cedar resource reference.
            context: Optional context for policy evaluation.
            raise_on_deny: If True, raise AuthorizationDenied on deny.

        Returns:
            AuthorizationResponse with the decision.

        Raises:
            AuthorizationDenied: If raise_on_deny is True and access is denied.
        """
        request = AuthorizationRequest(
            principal=principal,
            action=action,
            resource=resource,
            context=context or {},
        )

        try:
            response = self._client.post("/authorize", json=request.model_dump_for_api())
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 400:
                error_data = e.response.json()
                raise ValidationError(
                    error_data.get("error", {}).get("message", "Validation error"),
                    errors=error_data.get("error", {}).get("details", []),
                ) from e
            raise

        result = AuthorizationResponse(**response.json())

        if raise_on_deny and not result.is_allowed:
            raise AuthorizationDenied(
                f"Access denied: {principal} cannot {action} on {resource}",
                response=result,
                principal=principal,
                action=action,
                resource=resource,
            )

        return result

    def is_allowed(
        self,
        principal: str,
        action: str,
        resource: str,
        context: dict[str, Any] | None = None,
    ) -> bool:
        """Simple boolean authorization check.

        Args:
            principal: Cedar principal reference.
            action: Cedar action reference.
            resource: Cedar resource reference.
            context: Optional context for policy evaluation.

        Returns:
            True if allowed, False otherwise.
        """
        result = self.authorize(principal, action, resource, context)
        return result.is_allowed

    def analyze(
        self,
        principal: str,
        action: str,
        resource: str,
        context: dict[str, Any] | None = None,
    ) -> PolicyAnalysis:
        """Analyze which policies apply to a request.

        Args:
            principal: Cedar principal reference.
            action: Cedar action reference.
            resource: Cedar resource reference.
            context: Optional context for policy evaluation.

        Returns:
            PolicyAnalysis with applicable policies.
        """
        try:
            response = self._client.post(
                "/policies/analyze",
                json={
                    "principal": principal,
                    "action": action,
                    "resource": resource,
                    "context": context or {},
                },
            )
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e

        return PolicyAnalysis(**response.json())

    def health(self) -> bool:
        """Check if wl-apdp service is healthy.

        Returns:
            True if healthy, False otherwise.
        """
        try:
            response = self._client.get("/health")
            return response.status_code == 200
        except Exception:
            return False

    def get_health(self) -> HealthResponse:
        """Get detailed health information.

        Returns:
            HealthResponse with status details.
        """
        response = self._client.get("/health")
        response.raise_for_status()
        return HealthResponse(**response.json())

    def list_policies(self) -> list[Policy]:
        """List all policies.

        Returns:
            List of Policy objects.
        """
        response = self._client.get("/policies")
        response.raise_for_status()
        data = response.json()
        return [Policy(**p) for p in data.get("policies", [])]

    # =========================================================================
    # Constraint Manifest Methods (Agent-Native Constraint Interface)
    # =========================================================================

    def get_constraint_manifest(
        self,
        agent_id: str,
        goal_id: str | None = None,
        include_guidance: bool = True,
    ) -> ConstraintManifest:
        """Get the constraint manifest for an agent.

        The constraint manifest provides a complete picture of the agent's
        operational boundaries. Agents should fetch this at session start
        and use it for self-governance.

        Trust-gated access control:
        - Trusted agents receive full manifests with all capabilities
        - Unverified agents receive limited manifests (no capabilities disclosed)
        - Quarantined agents receive restricted manifests (minimal capabilities)
        - Revoked agents receive an error (no access to guidance)

        Args:
            agent_id: The agent's unique identifier.
            goal_id: Optional goal ID to include goal-specific constraints.
            include_guidance: Whether to include natural language guidance.

        Returns:
            ConstraintManifest with capabilities, constraints, and guidance.

        Raises:
            AgentTrustRevoked: If the agent's trust has been revoked.
            AgentQuarantined: If the agent is quarantined (returns restricted manifest).
            AgentUnverified: If the agent has not been verified (returns limited manifest).
            ServiceUnavailable: If the service is unavailable.

        Example:
            >>> manifest = client.get_constraint_manifest("researcher")
            >>> if manifest.can_perform("read", "Document"):
            ...     # Proceed with read operation
            ...     pass
        """
        request: dict[str, Any] = {
            "agent_id": agent_id,
            "include_guidance": include_guidance,
        }
        if goal_id:
            request["goal_id"] = goal_id

        try:
            response = self._client.post("/constraints/manifest", json=request)
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e
        except httpx.HTTPStatusError as e:
            _handle_trust_error(e, agent_id)

        data = response.json()
        return ConstraintManifest(**data.get("manifest", data))

    def preflight(
        self,
        agent_id: str,
        action: str,
        resource: str,
        resource_type: str | None = None,
        intent_category: str | None = None,
        goal_id: str | None = None,
        context: dict[str, Any] | None = None,
    ) -> PreflightResponse:
        """Check if an action would be allowed without consuming quota.

        Pre-flight checks let agents validate actions before executing them.
        Unlike authorize(), preflight doesn't count against action limits.

        Trust-gated access control:
        - Trusted agents can perform pre-flight checks
        - Unverified and quarantined agents are denied (no probing allowed)
        - Revoked agents receive an error

        Args:
            agent_id: Agent ID making the request.
            action: Action to check (e.g., "read", "write", "execute").
            resource: Resource to check (e.g., "document-123").
            resource_type: Optional resource type for type-specific checks.
            intent_category: Intent category for this action.
            goal_id: Goal ID if operating under a goal.
            context: Optional additional context.

        Returns:
            PreflightResponse with allowed status and suggestions.

        Raises:
            AgentTrustRevoked: If the agent's trust has been revoked.
            AgentQuarantined: If the agent is quarantined.
            AgentUnverified: If the agent has not been verified.
            ServiceUnavailable: If the service is unavailable.

        Example:
            >>> result = client.preflight(
            ...     agent_id="researcher",
            ...     action="execute",
            ...     resource="web_search",
            ...     resource_type="Tool",
            ...     intent_category="Research"
            ... )
            >>> if result.allowed:
            ...     # Safe to proceed with actual authorization
            ...     pass
            >>> else:
            ...     print(f"Would be denied: {result.explanation}")
        """
        # Build intent if category provided
        intent = None
        if intent_category:
            intent = {
                "objective": f"Preflight check for {action} operation",
                "category": intent_category,
                "risk_level": "low",
            }

        request = PreflightRequest(
            agent_id=agent_id,
            action=action,
            resource=resource,
            resource_type=resource_type,
            intent=intent,
            goal_id=goal_id,
            context=context or {},
        )

        try:
            response = self._client.post("/constraints/preflight", json=request.model_dump(exclude_none=True))
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e
        except httpx.HTTPStatusError as e:
            _handle_trust_error(e, agent_id)

        data = response.json()
        return PreflightResponse(**data.get("preflight", data))

    def query_capabilities(
        self,
        agent_id: str,
        resource_type: str | None = None,
        action: str | None = None,
        intent_category: str | None = None,
    ) -> CapabilitiesResponse:
        """Query what capabilities are available.

        Use this to discover what actions an agent can perform on what
        resource types, optionally filtered by various criteria.

        Args:
            agent_id: The agent's unique identifier.
            resource_type: Filter by resource type.
            action: Filter by action.
            intent_category: Filter by intent category.

        Returns:
            CapabilitiesResponse with matching capabilities.

        Raises:
            ServiceUnavailable: If the service is unavailable.

        Example:
            >>> result = client.query_capabilities(
            ...     agent_id="researcher",
            ...     resource_type="Document"
            ... )
            >>> for cap in result.capabilities:
            ...     print(f"Can {cap.action} on {cap.resource_types}")
        """
        query = CapabilitiesQuery(
            agent_id=agent_id,
            resource_type=resource_type,
            action=action,
            intent_category=intent_category,
        )

        try:
            response = self._client.post("/constraints/capabilities", json=query.model_dump())
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e

        return CapabilitiesResponse(**response.json())

    def refresh_manifest(
        self,
        manifest_id: str,
    ) -> ConstraintManifest:
        """Refresh an existing constraint manifest.

        Use this for long-running sessions to get updated constraints
        without re-fetching the full manifest.

        Args:
            manifest_id: ID of the manifest to refresh.

        Returns:
            Updated ConstraintManifest.

        Raises:
            ServiceUnavailable: If the service is unavailable.
        """
        try:
            response = self._client.post(
                "/constraints/refresh",
                json={"manifest_id": manifest_id},
            )
            response.raise_for_status()
        except httpx.ConnectError as e:
            raise ServiceUnavailable(url=self.base_url) from e

        data = response.json()
        return ConstraintManifest(**data.get("manifest", data))
