import requests
from typing import Dict, Any, List, Optional, Union
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
import uuid
import hmac
import hashlib

@dataclass
class PaymentRequest:
    agent_id: str
    amount: int
    currency: str = "usd"
    description: Optional[str] = None
    payment_method: str = "stripe"  # "stripe" or "braintree"
    recipient_account_id: Optional[str] = None
    recipient_business_name: Optional[str] = None
    recipient_email: Optional[str] = None


@dataclass
class PaymentResponse:
    payment_intent_id: str
    client_secret: str
    status: str
    receipt: Dict[str, Any]
    payment_method: str = "stripe"


@dataclass
class RecipientSearchResult:
    query: str
    results: list
    total_found: int

@dataclass
class Receipt:
    receipt_id: str
    agent_id: str
    amount: int
    currency: str
    timestamp: str
    signature: str
    verification_url: str

@dataclass
class SpendingLimits:
    daily_limit: int
    monthly_limit: int
    daily_spent: int
    monthly_spent: int
    daily_remaining: int
    monthly_remaining: int

# AP2 / x402 models
@dataclass
class IntentMandate:
    id: str
    status: str
    policy: Dict[str, Any]
    subject: Dict[str, Any]
    hash: Optional[str] = None
    metadata: Optional[Dict[str, Any]] = None


@dataclass
class CartMandate:
    id: str
    status: str
    cart: Dict[str, Any]
    links: Dict[str, Any]
    hash: Optional[str] = None
    metadata: Optional[Dict[str, Any]] = None


@dataclass
class AP2PaymentResult:
    status: str
    rail: str
    processor: str
    processor_ref: str
    receipt: Dict[str, Any]
    onchain_txid: Optional[str] = None


@dataclass
class PaymentMethodPayload:
    rail: str
    provider: Optional[str] = None
    payment_reference: Optional[str] = None
    wallet_address: Optional[str] = None
    payment_header: Optional[str] = None  # Pre-signed x402 payment (secure mode)
    extra: Optional[Dict[str, Any]] = None

    def to_dict(self) -> Dict[str, Any]:
        payload: Dict[str, Any] = {"rail": self.rail}
        if self.provider:
            payload["provider"] = self.provider
        if self.payment_reference:
            payload["payment_reference"] = self.payment_reference
        if self.wallet_address:
            payload["wallet_address"] = self.wallet_address
        if self.payment_header:
            payload["payment_header"] = self.payment_header
        if self.extra:
            payload["extra"] = self.extra
        return payload


class ApiError(Exception):
    def __init__(self, message: str, status: int, body: Optional[Dict[str, Any]] = None):
        super().__init__(message)
        self.status = status
        self.body = body


class PolicyDeniedError(ApiError):
    pass


def build_card_payment_method(
    *,
    provider: str = "stripe",
    payment_reference: Optional[str] = None,
    extra: Optional[Dict[str, Any]] = None,
) -> PaymentMethodPayload:
    """Helper for AP2 card payments (Stripe or Braintree)."""

    return PaymentMethodPayload(
        rail="card",
        provider=provider,
        payment_reference=payment_reference,
        extra=extra,
    )


def build_braintree_payment_method(
    *,
    customer_id: Optional[str] = None,
    merchant_account_id: Optional[str] = None,
    supplier_domain: Optional[str] = None,
    descriptor: Optional[Dict[str, Any]] = None,
) -> PaymentMethodPayload:
    """Helper for AP2 Braintree payments using stored customer IDs."""

    extra: Dict[str, Any] = {}
    if customer_id:
        extra["customer_id"] = customer_id
    if merchant_account_id:
        extra["merchant_account_id"] = merchant_account_id
    if supplier_domain:
        extra["supplier_domain"] = supplier_domain
    if descriptor:
        extra.update({
            "descriptor_name": descriptor.get("name"),
            "descriptor_phone": descriptor.get("phone"),
        })

    return PaymentMethodPayload(
        rail="braintree",
        provider="braintree",
        extra=extra or None,
    )


def build_stablecoin_payment_method(
    *,
    payer_private_key: str,
    destination_wallet: str,
    amount_cents: int,
    resource_id: str,
    description: str = "Stablecoin payment",
    network: str = "base-sepolia",
    max_timeout_seconds: int = 600,
) -> PaymentMethodPayload:
    """
    Helper for AP2 **direct** stablecoin payments via Coinbase x402.

    .. deprecated:: 2.7.0
        For usage agreements, use bilateral escrow with
        ``build_bilateral_agreement_signatures()`` from ``escrow_signing.py``
        instead. This function is for AP2 direct one-shot payments only
        (``ap2_pay()``), NOT for agreement creation.

    This function signs the payment CLIENT-SIDE and sends only the signature
    to the server. Your private key never leaves your environment.

    Args:
        payer_private_key: Your agent's private key (stays local)
        destination_wallet: Recipient's wallet address
        amount_cents: Amount in cents
        resource_id: Unique payment identifier (e.g., "payment:pay_123")
        description: Payment description
        network: Blockchain network (default: base-sepolia)
        max_timeout_seconds: Payment validity window

    Returns:
        PaymentMethodPayload with pre-signed payment_header
    """
    try:
        from .x402_signing import create_signed_x402_payment
    except ImportError:
        from x402_signing import create_signed_x402_payment

    # Sign payment locally - private key never sent to server!
    payment_header = create_signed_x402_payment(
        payer_private_key=payer_private_key,
        destination_wallet=destination_wallet,
        amount_cents=amount_cents,
        resource_id=resource_id,
        description=description,
        network=network,
        max_timeout_seconds=max_timeout_seconds,
    )

    return PaymentMethodPayload(
        rail="stablecoin",
        provider="coinbase",
        wallet_address=destination_wallet,
        payment_header=payment_header,  # Pre-signed, secure!
        extra={"network": network},
    )

# A2A Protocol Models
@dataclass
class A2APaymentRequest:
    """Agent-to-Agent payment request following the A2A protocol"""
    supplier: str  # Domain like "acme-corp.com"
    amount: int  # Amount in cents
    description: str
    currency: str = "usd"
    txn_id: Optional[str] = None  # Client-generated transaction ID
    msg: str = "PayRequest"
    version: str = "1.0"

@dataclass 
class A2APaymentResponse:
    """Agent-to-Agent payment response"""
    txn_id: str
    status: str  # "processing" | "supplier_onboarding" | "paid" | "failed"
    msg: str = "PayResponse"
    provisional_key: Optional[str] = None
    next_action_url: Optional[str] = None
    error: Optional[str] = None

@dataclass
class A2AStatusQuery:
    """Query the status of an A2A transaction"""
    txn_id: str
    msg: str = "StatusQuery"

@dataclass
class A2AStatusResponse:
    """Response to A2A status query"""
    txn_id: str
    status: str
    events: list
    msg: str = "StatusResponse"

# Gap 2: Service Catalog Models
@dataclass
class ServiceRegistration:
    """Service catalog registration request"""
    service_name: str
    description: str
    category: str
    price_model: str  # "per_unit" | "subscription" | "tiered"
    base_price_cents: int
    unit: str
    min_quantity: int = 1
    max_quantity: Optional[int] = None
    capabilities: Optional[Dict[str, Any]] = None
    metadata: Optional[Dict[str, Any]] = None

    # Metered proxy configuration (optional, enables automatic usage tracking)
    api_base_url: Optional[str] = None
    api_key: Optional[str] = None
    api_auth_header: str = "X-API-Key"

@dataclass
class ServiceDetails:
    """Service catalog entry details"""
    service_id: str
    agent_id: str
    service_name: str
    description: str
    category: str
    price_model: str
    base_price_cents: int
    unit: str
    min_quantity: int
    max_quantity: Optional[int]
    capabilities: Optional[Dict[str, Any]]
    metadata: Optional[Dict[str, Any]]
    is_active: bool
    created_at: str
    updated_at: str

    # Metered proxy fields (when configured)
    api_base_url: Optional[str] = None
    api_key_masked: Optional[str] = None
    api_auth_header: Optional[str] = None

@dataclass
class CatalogSearchResult:
    """Service catalog search results"""
    total: int
    results: list  # List of ServiceDetails
    query: Dict[str, Any]
    has_more: bool
    next_offset: Optional[int]

# Gap 3: Usage Agreement / Escrow Models

# Shipping & Delivery Types
class DeliveryStatus(str, Enum):
    """Delivery status enum values"""
    PENDING = "pending"
    SHIPPED = "shipped"
    IN_TRANSIT = "in_transit"
    OUT_FOR_DELIVERY = "out_for_delivery"
    DELIVERED = "delivered"
    EXCEPTION = "exception"


class CarrierType(str, Enum):
    """Shipping carrier enum values"""
    UPS = "ups"
    FEDEX = "fedex"
    USPS = "usps"
    DHL = "dhl"
    OTHER = "other"


@dataclass
class ShippingAddress:
    """Shipping address for physical goods"""
    recipient_name: str
    street1: str
    city: str
    state: str
    postal_code: str
    country: str  # ISO 3166-1 alpha-2 (e.g., "US", "CA", "GB")
    street2: Optional[str] = None
    phone: Optional[str] = None


@dataclass
class DeliveryStatusChange:
    """A single status change in delivery history"""
    status: str
    timestamp: str


@dataclass
class DeliveryInfo:
    """Delivery information updated by seller"""
    status: str
    carrier: Optional[str] = None
    tracking_number: Optional[str] = None
    tracking_url: Optional[str] = None
    shipped_at: Optional[str] = None
    estimated_delivery: Optional[str] = None
    delivered_at: Optional[str] = None
    notes: Optional[str] = None
    history: Optional[List[DeliveryStatusChange]] = None


@dataclass
class UpdateDeliveryRequest:
    """Request to update delivery information"""
    status: str  # DeliveryStatus value
    carrier: Optional[str] = None  # CarrierType value
    tracking_number: Optional[str] = None
    tracking_url: Optional[str] = None
    estimated_delivery: Optional[str] = None
    notes: Optional[str] = None


@dataclass
class DeliveryResponse:
    """Response from delivery update"""
    agreement_id: str
    status: str
    delivery: Dict[str, Any]


@dataclass
class UsageAgreementRequest:
    """Create a prepaid bilateral escrow usage agreement.

    Bilateral escrow is the only agreement settlement model. The SDK
    hardcodes ``settlement_model: "bilateral_escrow"`` on every request.

    Required fields for bilateral escrow:
        buyer_wallet_address: Buyer's Ethereum wallet address
        buyer_permit_signature: EIP-2612 USDC permit signature
        buyer_permit_deadline: Unix timestamp for permit expiry
        buyer_deposit_auth_signature: Optional second-step EIP-712 deposit authorization signature
        buyer_deposit_auth_deadline: Optional second-step deposit auth expiry
    """
    seller_agent_id: str
    quantity: int
    unit: str
    price_per_unit_cents: int
    # Bilateral escrow required fields
    buyer_wallet_address: str
    buyer_permit_signature: str
    buyer_permit_deadline: int
    buyer_deposit_auth_signature: Optional[str] = None
    buyer_deposit_auth_deadline: Optional[int] = None
    service_id: Optional[str] = None  # Links to registered service (enables metered proxy)
    service_description: Optional[str] = None
    expires_in_hours: int = 24
    client_proposal_id: Optional[str] = None
    metadata: Optional[Dict[str, Any]] = None
    # Optional infra fee permit (for setup fee)
    buyer_infra_fee_permit_signature: Optional[str] = None
    buyer_infra_fee_permit_deadline: Optional[int] = None
    # Optional cart line items (stored under metadata.cart_items)
    cart_items: Optional[list] = None
    # Shipping address for physical goods (stored under metadata.shipping)
    shipping_address: Optional[ShippingAddress] = None


@dataclass
class UsageAgreement:
    """Usage agreement details"""
    agreement_id: str
    buyer_agent_id: str
    seller_agent_id: str
    quantity: int
    unit: str
    price_per_unit_cents: int
    total_cents: int
    status: str
    expires_at: str
    payment_method: Optional[Dict[str, Any]] = None
    service_description: Optional[str] = None
    created_at: Optional[str] = None
    units_used: Optional[int] = None
    escrow_payment_id: Optional[str] = None
    released_cents: Optional[int] = None
    seller_receipt_id: Optional[str] = None
    buyer_receipt_id: Optional[str] = None
    metadata: Optional[Dict[str, Any]] = None
    # Bilateral escrow fields
    settlement_model: Optional[str] = None
    buyer_wallet_address: Optional[str] = None
    seller_wallet_address: Optional[str] = None
    escrow_status: Optional[str] = None
    onchain_agreement_id: Optional[str] = None
    infra_fee_status: Optional[str] = None
    service_amount_atomic: Optional[int] = None
    infra_fee_amount_atomic: Optional[int] = None
    per_call_fee_amount_atomic: Optional[int] = None
    pricing_version: Optional[str] = None
    settlement_status: Optional[str] = None
    challenge_window_expires_at: Optional[str] = None
    aggregate_earned_atomic: Optional[int] = None
    aggregate_claimed_atomic: Optional[int] = None
    aggregate_withdrawn_atomic: Optional[int] = None

@dataclass
class RecordUsageRequest:
    """Record usage for an agreement"""
    units_used: int
    completed: bool = False
    usage_proof: Optional[Dict[str, Any]] = None
    # Optional x402 payment header for final settlement (stablecoin only)
    payment_header: Optional[str] = None


# ========================================================================
# Escrow Response Types (Bilateral)
# ========================================================================

@dataclass
class TransactionCalldata:
    """Unsigned transaction calldata for agent to sign and submit."""
    to: str
    data: str
    value: str = "0"


@dataclass
class EscrowStatusResponse:
    """Extended escrow status for a bilateral agreement."""
    agreement_id: str
    escrow_status: str
    settlement_model: Optional[str] = None
    status: Optional[str] = None
    settlement_status: Optional[str] = None
    onchain_agreement_id: Optional[str] = None
    infra_fee_status: Optional[str] = None
    onchain: Optional[Dict[str, Any]] = None
    service_amount_atomic: Optional[int] = None
    aggregate_earned_atomic: Optional[int] = None
    aggregate_claimed_atomic: Optional[int] = None
    aggregate_withdrawn_atomic: Optional[int] = None
    seller_claimable_atomic: Optional[int] = None
    buyer_withdrawable_atomic: Optional[int] = None
    challenge_window_expires_at: Optional[str] = None
    buyer_wallet_address: Optional[str] = None
    seller_wallet_address: Optional[str] = None
    chain_id: Optional[int] = None


@dataclass
class DepositParamsResponse:
    """Deposit parameters needed for signing permits."""
    agreement_id: str
    chain_id: int
    usdc_address: str
    escrow_contract_address: str
    service_amount_atomic: int
    onchain_agreement_id: Optional[int] = None
    infra_fee_amount_atomic: Optional[int] = None
    per_call_fee_amount_atomic: Optional[int] = None
    gas_wallet_address: Optional[str] = None
    buyer_wallet_address: Optional[str] = None
    pricing_version: Optional[str] = None


@dataclass
class RefreshPermitRequest:
    """Request payload for refreshing permit signatures."""
    buyer_permit_signature: Optional[str] = None
    buyer_permit_deadline: Optional[int] = None
    buyer_deposit_auth_signature: Optional[str] = None
    buyer_deposit_auth_deadline: Optional[int] = None
    buyer_infra_fee_permit_signature: Optional[str] = None
    buyer_infra_fee_permit_deadline: Optional[int] = None


@dataclass
class SponsoredActionRequest:
    """Signed sponsored-action submission payload."""
    signer_wallet_address: str
    nonce: int
    deadline: int
    signature: str


@dataclass
class SponsoredActionResponse:
    """Relay response for a sponsored escrow action."""
    agreement_id: str
    onchain_agreement_id: int
    action: str
    relay_status: str
    signer_wallet_address: str
    signer_role: str
    nonce: int
    deadline: int
    tx_hash: str
    chain_id: int
    settlement_status: Optional[str] = None
    claimable_atomic: Optional[int] = None
    sponsored: bool = True
    relay_audit_id: Optional[str] = None


@dataclass
class SettlementActionOptionsResponse:
    """Direct-vs-sponsored execution options for a settlement action."""
    agreement_id: str
    onchain_agreement_id: int
    action: str
    signer_wallet_address: str
    signer_role: str
    current_nonce: int
    claimable_atomic: Optional[int] = None
    settlement_status: Optional[str] = None
    chain_id: int = 0
    escrow_contract_address: Optional[str] = None
    gas_wallet_address: Optional[str] = None
    gas_wallet_eth_balance_wei: int = 0
    signer_eth_balance_wei: int = 0
    signer_has_gas: bool = False
    direct_available: bool = False
    sponsored_available: bool = False
    availability_reason: str = "action_not_available"
    sponsored_block_reason: Optional[str] = None
    recommended_mode: str = "unavailable"
    recommendation_reason: str = "action_not_available"
    deadline_window_seconds: int = 0
    suggested_deadline: int = 0
    direct_function: str = ""


@dataclass
class ClaimInstructionsResponse:
    """Unsigned calldata for claim/withdraw after settlement finalization."""
    agreement_id: str
    onchain_agreement_id: int
    function: str
    claimable_atomic: int
    transaction: Optional[TransactionCalldata] = None
    chain_id: Optional[int] = None
    closed: bool = False


@dataclass
class InitiateCloseCalldataResponse:
    """Unsigned calldata for self-service close initiation."""
    onchain_agreement_id: int
    function: str
    transaction: Optional[TransactionCalldata] = None
    chain_id: Optional[int] = None


@dataclass
class CallProofItem:
    """A single call proof in the hash chain."""
    proof_id: str
    agreement_id: str
    call_index: int
    request_hash: str
    response_hash: str
    chain_hash: str
    signature: str
    created_at: str


@dataclass
class CallProofResponse:
    """Paginated call proof response."""
    items: List[CallProofItem]
    cursor: Optional[str] = None
    has_more: bool = False


@dataclass
class CumulativeReceiptItem:
    """A cumulative ECDSA receipt for on-chain verification."""
    receipt_id: str
    agreement_id: str
    cumulative_units: int
    price_per_unit_atomic: int
    nonce: int
    signature: str
    signer: str
    proof_hash: Optional[str] = None
    created_at: Optional[str] = None


@dataclass
class CumulativeReceiptResponse:
    """Paginated cumulative receipt response."""
    items: List[CumulativeReceiptItem]
    cursor: Optional[str] = None
    has_more: bool = False


# Escrow Event Type Constants
class EscrowEventType:
    """Constants for bilateral escrow event types.

    Use with ``list_agent_events(types=[EscrowEventType.CLOSE_FINALIZED])``
    to filter for specific settlement lifecycle events.
    """
    RECEIPT_SUBMITTED = "escrow.receipt_submitted"
    CLOSE_INITIATED = "escrow.close_initiated"
    CLOSE_FINALIZED = "escrow.close_finalized"
    CLAIM_COMPLETED = "escrow.claim_completed"
    WITHDRAW_COMPLETED = "escrow.withdraw_completed"
    FEE_COLLECTED = "escrow.fee_collected"
    FEE_FAILED = "escrow.fee_failed"
    SETTLEMENT_FAILED = "escrow.settlement_failed"

    ALL = [
        RECEIPT_SUBMITTED,
        CLOSE_INITIATED,
        CLOSE_FINALIZED,
        CLAIM_COMPLETED,
        WITHDRAW_COMPLETED,
        FEE_COLLECTED,
        FEE_FAILED,
        SETTLEMENT_FAILED,
    ]


@dataclass
class AgentEvent:
    event_id: str
    type: str
    payload: Dict[str, Any]
    status: str
    attempt_count: int
    created_at: str


def verify_webhook_signature(signature_header: str, raw_body: str, secret: str, tolerance_seconds: int = 300) -> bool:
    """Verify Paegents webhook signature.

    Header format: "t=<unix>, v1=<hex(hmac_sha256(secret, f"{t}.{body}"))>"
    Raises ValueError on failure; returns True on success.
    """
    if not signature_header:
        raise ValueError("Missing Paegents-Signature header")
    try:
        parts = [p.strip() for p in signature_header.split(',')]
        t_part = next(p for p in parts if p.startswith('t='))
        v1_part = next(p for p in parts if p.startswith('v1='))
        t = int(t_part.split('=')[1])
        v1 = v1_part.split('=')[1]
    except Exception:
        raise ValueError("Invalid signature header")

    now = int(datetime.utcnow().timestamp())
    if abs(now - t) > tolerance_seconds:
        raise ValueError("Signature timestamp outside tolerance")

    msg = f"{t}.{raw_body}".encode()
    expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    if not _timing_safe_equal(expected, v1):
        raise ValueError("Signature mismatch")
    return True


def _timing_safe_equal(a: str, b: str) -> bool:
    if len(a) != len(b):
        return False
    result = 0
    for x, y in zip(a.encode(), b.encode()):
        result |= x ^ y
    return result == 0

class AgentPaymentsSDK:
    def __init__(self, api_url: str, agent_id: str, api_key: str):
        self.api_url = api_url.rstrip('/')
        self.agent_id = agent_id
        self.api_key = api_key
        self.session = requests.Session()
        self.session.headers.update({
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {api_key}',
            'X-API-Key': api_key  # For routes that use X-API-Key header
        })

    def _with_jwt(self, jwt_token: Optional[str]) -> Dict[str, str]:
        headers: Dict[str, str] = {}
        if jwt_token:
            headers['Authorization'] = f'Bearer {jwt_token}'
        return headers

    def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
        """Make HTTP request to the API"""
        url = f"{self.api_url}{endpoint}"
        response = self.session.request(method, url, **kwargs)
        
        if not response.ok:
            # Try to extract JSON error
            try:
                body = response.json()
                detail = body.get('detail') if isinstance(body, dict) else None
            except Exception:
                body = None
                detail = None
            msg = detail or response.text
            if isinstance(msg, str) and 'policy_denied' in msg.lower():
                raise PolicyDeniedError(msg, response.status_code, body)
            raise ApiError(f"API request failed: {msg}", response.status_code, body)
        
        return response.json()

    def create_payment(self, request: PaymentRequest) -> PaymentResponse:
        """Create a payment for an agent"""
        data = {
            "agent_id": request.agent_id,
            "amount": request.amount,
            "currency": request.currency,
            "description": request.description,
            "payment_method": request.payment_method
        }
        
        # Add recipient information
        if request.recipient_account_id:
            data["recipient_account_id"] = request.recipient_account_id
        elif request.recipient_business_name:
            data["recipient_business_name"] = request.recipient_business_name
        elif request.recipient_email:
            data["recipient_email"] = request.recipient_email
        
        result = self._make_request("POST", "/mcp/payments", json=data)
        
        return PaymentResponse(
            payment_intent_id=result["payment_intent"]["id"] if "payment_intent" in result else result["payment"]["id"],
            client_secret=result["payment_intent"]["client_secret"] if "payment_intent" in result else "",
            status=result["payment_intent"]["status"] if "payment_intent" in result else result["payment"]["state"],
            receipt=result["receipt"],
            payment_method=result.get("payment_method", "stripe")
        )


    def search_recipients(self, query: str, payment_method: str = "all") -> RecipientSearchResult:
        """Search for recipients across all payment methods"""
        params = {"query": query, "payment_method": payment_method}
        result = self._make_request("POST", "/mcp/search-recipients", params=params)
        
        return RecipientSearchResult(
            query=result["query"],
            results=result["results"],
            total_found=result["total_found"]
        )

    def check_balance(self) -> SpendingLimits:
        """Check spending limits and current balance"""
        result = self._make_request("GET", f"/mcp/balance/{self.agent_id}")
        
        return SpendingLimits(
            daily_limit=result["daily_limit"],
            monthly_limit=result["monthly_limit"],
            daily_spent=result["daily_spent"],
            monthly_spent=result["monthly_spent"],
            daily_remaining=result["daily_remaining"],
            monthly_remaining=result["monthly_remaining"]
        )

    def verify_receipt(self, receipt_id: str) -> Dict[str, Any]:
        """Verify a payment receipt"""
        return self._make_request("GET", f"/receipts/verify/{receipt_id}")

    # ---------------- Owner Spending Limits (JWT) ----------------
    def get_spending_limits_owner(self, jwt_token: str) -> Dict[str, Any]:
        headers = self._with_jwt(jwt_token)
        return self._make_request("GET", "/spending/limits", headers=headers)

    def update_spending_limits_owner(self, daily_limit: float, monthly_limit: float, jwt_token: str) -> Dict[str, Any]:
        headers = self._with_jwt(jwt_token)
        body = {"daily_limit": daily_limit, "monthly_limit": monthly_limit}
        return self._make_request("PUT", "/spending/limits", headers=headers, json=body)

    # ---------------- Webhooks Ops (Owner or API key) ----------------
    def list_webhooks(self, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = self._with_jwt(jwt_token)
        return self._make_request("GET", f"/agents/{agent}/webhooks", headers=headers)

    def create_webhook(self, url: str, event_types: Optional[list] = None, secret: Optional[str] = None, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = self._with_jwt(jwt_token)
        body: Dict[str, Any] = {"url": url}
        if secret:
            body["secret"] = secret
        if event_types:
            body["event_types"] = event_types
        return self._make_request("POST", f"/agents/{agent}/webhooks", headers=headers, json=body)

    def rotate_webhook_secret(self, subscription_id: str, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = self._with_jwt(jwt_token)
        return self._make_request("POST", f"/agents/{agent}/webhooks/{subscription_id}/rotate", headers=headers)

    def pause_webhook(self, subscription_id: str, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = self._with_jwt(jwt_token)
        return self._make_request("POST", f"/agents/{agent}/webhooks/{subscription_id}/pause", headers=headers)

    def resume_webhook(self, subscription_id: str, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = self._with_jwt(jwt_token)
        return self._make_request("POST", f"/agents/{agent}/webhooks/{subscription_id}/resume", headers=headers)

    def send_test_webhook(self, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = self._with_jwt(jwt_token)
        return self._make_request("POST", f"/agents/{agent}/webhooks/test", headers=headers)

    def list_webhook_deliveries(self, status: Optional[str] = None, limit: int = 50, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = self._with_jwt(jwt_token)
        params: Dict[str, Any] = {"limit": limit}
        if status:
            params["status"] = status
        return self._make_request("GET", f"/agents/{agent}/webhooks/deliveries", headers=headers, params=params)

    def replay_webhook_delivery(self, delivery_id: str, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = self._with_jwt(jwt_token)
        return self._make_request("POST", f"/agents/{agent}/webhooks/deliveries/{delivery_id}/replay", headers=headers)

    # --------------------------------------------------------------------
    # Pollable inbox helpers
    # --------------------------------------------------------------------
    def list_agent_events(
        self,
        *,
        types: Optional[list] = None,
        after: Optional[str] = None,
        limit: int = 50,
    ) -> list:
        params: Dict[str, Any] = {"limit": limit}
        if types:
            params["types"] = ",".join(types)
        if after:
            params["after"] = after

        result = self._make_request("GET", f"/agents/{self.agent_id}/events", params=params)
        events: list = []
        for e in result:
            events.append(AgentEvent(
                event_id=e["event_id"],
                type=e["type"],
                payload=e.get("payload", {}),
                status=e["status"],
                attempt_count=e.get("attempt_count", 0),
                created_at=e["created_at"] if isinstance(e["created_at"], str) else str(e["created_at"]),
            ))
        return events

    def ack_agent_event(self, event_id: str) -> Dict[str, Any]:
        return self._make_request("POST", f"/agents/{self.agent_id}/events/{event_id}/ack")

    def create_a2a_payment(self, recipient_id: str, amount: int, 
                          currency: str = "usd", memo: Optional[str] = None) -> Dict[str, Any]:
        """Create a legacy agent-to-agent payment (deprecated - use pay_supplier instead)"""
        data = {
            "agent_id": self.agent_id,
            "recipient_id": recipient_id,
            "amount": amount,
            "currency": currency,
            "memo": memo
        }
        
        return self._make_request("POST", "/a2a/payment", json=data)
    
    def pay_supplier(self, supplier: str, amount: int, description: str, 
                    agent_owner_email: str, agent_id: str,
                    currency: str = "usd", txn_id: Optional[str] = None,
                    agent_description: Optional[str] = None,
                    company_name: Optional[str] = None) -> A2APaymentResponse:
        """
        Pay a supplier using the A2A protocol
        
        Args:
            supplier: Domain name of the supplier (e.g., "acme-corp.com")
            amount: Amount in cents
            description: Description of the payment
            agent_owner_email: REQUIRED - Email of agent owner (from SSO)
            agent_id: REQUIRED - Agent identifier (unique per owner)
            currency: Currency code (default: "usd")
            txn_id: Optional client-generated transaction ID
            agent_description: Optional agent description
            company_name: Optional company name
            
        Returns:
            A2APaymentResponse with transaction details
        """
        if txn_id is None:
            txn_id = f"agt_txn_{uuid.uuid4().hex[:12]}"
            
        data = {
            "msg": "PayRequest",
            "version": "1.0",
            "txn_id": txn_id,
            "supplier": supplier,
            "amount": amount,
            "currency": currency,
            "description": description,
            "api_key": self.api_key,
            "agent_owner_email": agent_owner_email,
            "agent_id": agent_id
        }
        
        # Add optional fields if provided
        if agent_description:
            data["agent_description"] = agent_description
        if company_name:
            data["company_name"] = company_name
        
        result = self._make_request("POST", "/a2a/pay", json=data)
        
        return A2APaymentResponse(
            txn_id=result["txn_id"],
            status=result["status"],
            msg=result.get("msg", "PayResponse"),
            provisional_key=result.get("provisional_key"),
            next_action_url=result.get("next_action_url"),
            error=result.get("error")
        )
    
    def check_a2a_status(self, txn_id: str) -> A2AStatusResponse:
        """
        Check the status of an A2A payment transaction
        
        Args:
            txn_id: Transaction ID to check
            
        Returns:
            A2AStatusResponse with current status and events
        """
        data = {
            "msg": "StatusQuery",
            "txn_id": txn_id
        }
        
        result = self._make_request("POST", "/a2a/status", json=data)
        
        return A2AStatusResponse(
            txn_id=result["txn_id"],
            status=result["status"],
            events=result.get("events", []),
            msg=result.get("msg", "StatusResponse")
        ) 

    def create_ap2_intent_mandate(
        self,
        *,
        policy: Dict[str, Any],
        agent_id: Optional[str] = None,
        constraints: Optional[Dict[str, Any]] = None,
        expires_at: Optional[Union[str, datetime]] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> IntentMandate:
        """Create an AP2 intent mandate binding a policy to an agent."""

        payload: Dict[str, Any] = {
            "agent_id": agent_id or self.agent_id,
            "policy": policy,
        }
        if constraints:
            payload["constraints"] = constraints
        if metadata:
            payload["metadata"] = metadata
        if expires_at:
            payload["expires_at"] = (
                expires_at.isoformat()
                if isinstance(expires_at, datetime)
                else expires_at
            )

        result = self._make_request("POST", "/ap2/mandates/intent", json=payload)
        mandate = result.get("intent_mandate", {})
        return IntentMandate(
            id=mandate.get("id"),
            status=mandate.get("status", "unknown"),
            policy=mandate.get("policy", {}),
            subject=mandate.get("subject", {}),
            hash=mandate.get("hash"),
            metadata=mandate.get("metadata"),
        )

    def create_ap2_cart_mandate(
        self,
        *,
        intent_mandate_id: str,
        cart: Dict[str, Any],
        metadata: Optional[Dict[str, Any]] = None,
    ) -> CartMandate:
        """Create an AP2 cart mandate that links to an intent."""

        payload: Dict[str, Any] = {
            "intent_mandate_id": intent_mandate_id,
            "cart": cart,
        }
        if metadata:
            payload["metadata"] = metadata

        result = self._make_request("POST", "/ap2/mandates/cart", json=payload)
        cart_mandate = result.get("cart_mandate", {})
        return CartMandate(
            id=cart_mandate.get("id"),
            status=cart_mandate.get("status", "unknown"),
            cart=cart_mandate.get("cart", {}),
            links=cart_mandate.get("links", {}),
            hash=cart_mandate.get("hash"),
            metadata=cart_mandate.get("metadata"),
        )

    def get_ap2_signing_digest(self, *, intent_mandate_id: str, cart_mandate_id: str, destination_wallet: str) -> Dict[str, Any]:
        """Optional zero-trust helper: fetch canonical digest to sign for stablecoin payments."""
        body = {
            "intent_mandate_id": intent_mandate_id,
            "cart_mandate_id": cart_mandate_id,
            "destination_wallet": destination_wallet,
        }
        return self._make_request("POST", "/ap2/signing-digest", json=body)

    def ap2_pay(
        self,
        *,
        intent_mandate_id: str,
        cart_mandate_id: str,
        payment_method: Union[PaymentMethodPayload, Dict[str, Any]],
        idempotency_key: Optional[str] = None,
    ) -> Any:
        """Execute an AP2 payment for the given mandates."""

        if isinstance(payment_method, PaymentMethodPayload):
            method_payload = payment_method.to_dict()
        else:
            method_payload = payment_method

        headers: Dict[str, str] = {}
        if idempotency_key:
            headers["Idempotency-Key"] = idempotency_key

        result = self._make_request(
            "POST",
            "/ap2/pay",
            headers=headers or None,
            json={
                "intent_mandate_id": intent_mandate_id,
                "cart_mandate_id": cart_mandate_id,
                "payment_method": method_payload,
            },
        )
        if isinstance(result, dict) and result.get("approval_required"):
            return {
                "approval_required": True,
                "request_id": result.get("request_id"),
                "policy": result.get("policy"),
            }

        return AP2PaymentResult(
            status=result.get("status", "processing"),
            rail=result.get("rail", method_payload.get("rail", "unknown")),
            processor=result.get("processor", "unknown"),
            processor_ref=result.get("processor_ref"),
            receipt=result.get("receipt", {}),
            onchain_txid=result.get("onchain_txid"),
        )

    # ========================================================================
    # Gap 2: Service Catalog Methods
    # ========================================================================

    def register_service(self, service: ServiceRegistration) -> ServiceDetails:
        """
        Register a new service in the catalog.

        Args:
            service: ServiceRegistration with all service details

        Returns:
            ServiceDetails of the registered service
        """
        data = {
            "service_name": service.service_name,
            "description": service.description,
            "category": service.category,
            "price_model": service.price_model,
            "base_price_cents": service.base_price_cents,
            "unit": service.unit,
            "min_quantity": service.min_quantity
        }

        if service.max_quantity is not None:
            data["max_quantity"] = service.max_quantity
        if service.capabilities:
            data["capabilities"] = service.capabilities
        if service.metadata:
            data["metadata"] = service.metadata

        # Metered proxy configuration
        if service.api_base_url:
            data["api_base_url"] = service.api_base_url
        if service.api_key:
            data["api_key"] = service.api_key
        if service.api_auth_header:
            data["api_auth_header"] = service.api_auth_header

        result = self._make_request("POST", "/agents/services", json=data)

        return ServiceDetails(
            service_id=result["service_id"],
            agent_id=result["agent_id"],
            service_name=result["service_name"],
            description=result["description"],
            category=result["category"],
            price_model=result["price_model"],
            base_price_cents=result["base_price_cents"],
            unit=result["unit"],
            min_quantity=result["min_quantity"],
            max_quantity=result.get("max_quantity"),
            capabilities=result.get("capabilities"),
            metadata=result.get("metadata"),
            is_active=result.get("is_active", True),
            created_at=result["created_at"],
            updated_at=result["updated_at"],
            api_base_url=result.get("api_base_url"),
            api_key_masked=result.get("api_key_masked"),
            api_auth_header=result.get("api_auth_header")
        )

    def update_service(
        self,
        service_id: str,
        service_name: Optional[str] = None,
        description: Optional[str] = None,
        base_price_cents: Optional[int] = None,
        min_quantity: Optional[int] = None,
        max_quantity: Optional[int] = None,
        capabilities: Optional[Dict[str, Any]] = None,
        metadata: Optional[Dict[str, Any]] = None
    ) -> Dict[str, Any]:
        """
        Update an existing service (partial updates supported).

        Args:
            service_id: ID of service to update
            service_name: New service name (optional)
            description: New description (optional)
            base_price_cents: New price (optional)
            min_quantity: New min quantity (optional)
            max_quantity: New max quantity (optional)
            capabilities: New capabilities (optional)
            metadata: New metadata (optional)

        Returns:
            Update response with success status
        """
        data = {}

        if service_name is not None:
            data["service_name"] = service_name
        if description is not None:
            data["description"] = description
        if base_price_cents is not None:
            data["base_price_cents"] = base_price_cents
        if min_quantity is not None:
            data["min_quantity"] = min_quantity
        if max_quantity is not None:
            data["max_quantity"] = max_quantity
        if capabilities is not None:
            data["capabilities"] = capabilities
        if metadata is not None:
            data["metadata"] = metadata

        return self._make_request("PUT", f"/agents/services/{service_id}", json=data)

    def delete_service(self, service_id: str) -> Dict[str, Any]:
        """
        Deactivate a service (soft delete).

        Args:
            service_id: ID of service to deactivate

        Returns:
            Deletion response with success status
        """
        return self._make_request("DELETE", f"/agents/services/{service_id}")

    def get_service(self, service_id: str) -> ServiceDetails:
        """
        Get details of a specific service.

        Args:
            service_id: ID of service to retrieve

        Returns:
            ServiceDetails of the requested service
        """
        result = self._make_request("GET", f"/agents/services/{service_id}")

        return ServiceDetails(
            service_id=result["service_id"],
            agent_id=result["agent_id"],
            service_name=result["service_name"],
            description=result["description"],
            category=result["category"],
            price_model=result["price_model"],
            base_price_cents=result["base_price_cents"],
            unit=result["unit"],
            min_quantity=result["min_quantity"],
            max_quantity=result.get("max_quantity"),
            capabilities=result.get("capabilities"),
            metadata=result.get("metadata"),
            is_active=result.get("is_active", True),
            created_at=result["created_at"],
            updated_at=result["updated_at"],
            api_base_url=result.get("api_base_url"),
            api_key_masked=result.get("api_key_masked"),
            api_auth_header=result.get("api_auth_header")
        )

    def list_my_services(self, limit: int = 100, offset: int = 0) -> list:
        """
        List all services registered by this agent.

        Args:
            limit: Maximum number of results (default: 100)
            offset: Pagination offset (default: 0)

        Returns:
            List of ServiceDetails
        """
        params = {"limit": limit, "offset": offset}
        result = self._make_request("GET", "/agents/services", params=params)

        return [
            ServiceDetails(
                service_id=s["service_id"],
                agent_id=s["agent_id"],
                service_name=s["service_name"],
                description=s["description"],
                category=s["category"],
                price_model=s["price_model"],
                base_price_cents=s["base_price_cents"],
                unit=s["unit"],
                min_quantity=s["min_quantity"],
                max_quantity=s.get("max_quantity"),
                capabilities=s.get("capabilities"),
                metadata=s.get("metadata"),
                is_active=s.get("is_active", True),
                created_at=s["created_at"],
                updated_at=s["updated_at"]
            )
            for s in result.get("services", [])
        ]

    def search_services(
        self,
        query: Optional[str] = None,
        category: Optional[str] = None,
        min_price: Optional[int] = None,
        max_price: Optional[int] = None,
        limit: int = 50,
        offset: int = 0
    ) -> CatalogSearchResult:
        """
        Search the service catalog.

        Args:
            query: Full-text search query (optional)
            category: Filter by category (optional)
            min_price: Minimum price in cents (optional)
            max_price: Maximum price in cents (optional)
            limit: Maximum results (default: 50)
            offset: Pagination offset (default: 0)

        Returns:
            CatalogSearchResult with matching services
        """
        data = {
            "limit": limit,
            "offset": offset
        }

        if query:
            data["query"] = query
        if category:
            data["category"] = category
        if min_price is not None:
            data["min_price"] = min_price
        if max_price is not None:
            data["max_price"] = max_price

        result = self._make_request("POST", "/catalog/search", json=data)

        # Convert results to ServiceDetails
        services = []
        for s in result.get("results", []):
            services.append(ServiceDetails(
                service_id=s["service_id"],
                agent_id=s["agent_id"],
                service_name=s["service_name"],
                description=s["description"],
                category=s["category"],
                price_model=s["price_model"],
                base_price_cents=s["base_price_cents"],
                unit=s["unit"],
                min_quantity=s["min_quantity"],
                max_quantity=s.get("max_quantity"),
                capabilities=s.get("capabilities"),
                metadata=s.get("metadata"),
                is_active=s.get("is_active", True),
                created_at=s["created_at"],
                updated_at=s["updated_at"]
            ))

        return CatalogSearchResult(
            total=result["total"],
            results=services,
            query=result["query"],
            has_more=result["has_more"],
            next_offset=result.get("next_offset")
        )

    # ========================================================================
    # Gap 3: Usage Agreement / Escrow Methods
    # ========================================================================

    def create_usage_agreement(self, request: UsageAgreementRequest) -> UsageAgreement:
        """
        Create a bilateral escrow usage agreement (buyer action).

        Bilateral escrow is the only agreement model. The SDK always sends
        ``settlement_model: "bilateral_escrow"`` and ``payment_method.rail: "stablecoin"``.

        Args:
            request: UsageAgreementRequest with all agreement details including
                buyer_wallet_address and permit signatures.

        Returns:
            UsageAgreement with agreement details
        """
        # Validate required bilateral fields
        if not request.buyer_wallet_address:
            raise ValueError("buyer_wallet_address is required for bilateral escrow agreements")
        if not request.buyer_permit_signature:
            raise ValueError("buyer_permit_signature is required for bilateral escrow agreements")

        data = {
            "seller_agent_id": request.seller_agent_id,
            "quantity": request.quantity,
            "unit": request.unit,
            "price_per_unit_cents": request.price_per_unit_cents,
            # Bilateral escrow is the only agreement model
            "settlement_model": "bilateral_escrow",
            "payment_method": {"rail": "stablecoin"},
            "expires_in_seconds": int(request.expires_in_hours) * 3600,
            # Bilateral escrow fields
            "buyer_wallet_address": request.buyer_wallet_address,
            "buyer_permit_signature": request.buyer_permit_signature,
            "buyer_permit_deadline": request.buyer_permit_deadline,
        }

        if request.buyer_deposit_auth_signature:
            data["buyer_deposit_auth_signature"] = request.buyer_deposit_auth_signature
        if request.buyer_deposit_auth_deadline is not None:
            data["buyer_deposit_auth_deadline"] = request.buyer_deposit_auth_deadline

        # Optional infra fee permit
        if request.buyer_infra_fee_permit_signature:
            data["buyer_infra_fee_permit_signature"] = request.buyer_infra_fee_permit_signature
        if request.buyer_infra_fee_permit_deadline is not None:
            data["buyer_infra_fee_permit_deadline"] = request.buyer_infra_fee_permit_deadline

        if request.service_id:
            data["service_id"] = request.service_id
        if request.service_description:
            data["service_description"] = request.service_description

        # Handle cart items (passed directly, API stores in metadata.cart_items)
        if request.cart_items:
            data["cart_items"] = request.cart_items

        # Handle shipping address (passed directly, API stores in metadata.shipping)
        if request.shipping_address:
            data["shipping_address"] = {
                "recipient_name": request.shipping_address.recipient_name,
                "street1": request.shipping_address.street1,
                "street2": request.shipping_address.street2,
                "city": request.shipping_address.city,
                "state": request.shipping_address.state,
                "postal_code": request.shipping_address.postal_code,
                "country": request.shipping_address.country,
                "phone": request.shipping_address.phone,
            }

        # Pass through additional metadata
        if request.metadata:
            data["metadata"] = request.metadata
        if request.client_proposal_id:
            data["client_proposal_id"] = request.client_proposal_id

        idem_key = request.client_proposal_id or f"agree:{uuid.uuid4().hex}"
        headers = {"Idempotency-Key": idem_key}

        result = self._make_request("POST", "/usage-agreements", json=data, headers=headers)

        return self._parse_usage_agreement(result)

    def get_usage_agreement(self, agreement_id: str) -> UsageAgreement:
        """
        Get details of a usage agreement.

        Args:
            agreement_id: ID of agreement to retrieve

        Returns:
            UsageAgreement details
        """
        result = self._make_request("GET", f"/usage-agreements/{agreement_id}")
        return self._parse_usage_agreement(result)

    def list_usage_agreements(
        self,
        role: Optional[str] = None,
        status: Optional[str] = None,
        limit: int = 100
    ) -> list:
        """
        List usage agreements for this agent (as buyer or seller).

        Args:
            status: Filter by status (optional)
            limit: Maximum results (default: 100)

        Returns:
            List of UsageAgreement objects
        """
        params = {"limit": limit}
        if status:
            params["status"] = status
        if role:
            params["role"] = role

        path = f"/usage-agreements/agents/{self.agent_id}/agreements"
        result = self._make_request("GET", path, params=params)
        return [self._parse_usage_agreement(a) for a in result]

    def accept_usage_agreement(self, agreement_id: str) -> UsageAgreement:
        """
        Accept a usage agreement and fund escrow (seller action).

        This triggers:
        1. Agreement marked as accepted
        2. A2A payment from buyer to escrow
        3. Agreement marked as active/funded

        Args:
            agreement_id: ID of agreement to accept

        Returns:
            Updated UsageAgreement
        """
        headers = {"Idempotency-Key": f"accept:{agreement_id}"}
        self._make_request("PUT", f"/usage-agreements/{agreement_id}/accept", headers=headers)
        return self.get_usage_agreement(agreement_id)

    def reject_usage_agreement(self, agreement_id: str) -> Dict[str, Any]:
        """Reject a usage agreement (seller action)."""
        headers = {"Idempotency-Key": f"reject:{agreement_id}"}
        return self._make_request("PUT", f"/usage-agreements/{agreement_id}/reject", headers=headers)

    def record_usage(
        self,
        agreement_id: str,
        request: RecordUsageRequest
    ) -> UsageAgreement:
        """
        Record usage and optionally complete agreement (seller action).

        If completed=True, triggers:
        - Proportional payment release to seller
        - Automatic refund for unused portion

        Args:
            agreement_id: ID of agreement
            request: RecordUsageRequest with usage details

        Returns:
            Updated UsageAgreement
        """
        data = {
            "units_used": request.units_used,
            "completed": request.completed
        }

        if request.usage_proof:
            data["usage_proof"] = request.usage_proof
        # Only include payment_header on final settlement
        if request.completed and request.payment_header:
            data["payment_header"] = request.payment_header

        result = self._make_request(
            "POST",
            f"/usage-agreements/{agreement_id}/record-usage",
            json=data
        )

        return self._parse_usage_agreement(result)

    def cancel_usage_agreement(self, agreement_id: str) -> Dict[str, Any]:
        """
        Cancel a usage agreement before acceptance (buyer only).

        Only works if status is 'proposed'.

        Args:
            agreement_id: ID of agreement to cancel

        Returns:
            Cancellation response
        """
        return self._make_request("POST", f"/usage-agreements/{agreement_id}/cancel")

    def dispute_usage_agreement(
        self,
        agreement_id: str,
        reason: str,
        evidence: Optional[Dict[str, Any]] = None
    ) -> UsageAgreement:
        """
        Dispute a usage agreement (buyer action).

        Holds escrow until admin resolves.

        Args:
            agreement_id: ID of agreement to dispute
            reason: Dispute reason (min 10 characters)
            evidence: Optional evidence dict

        Returns:
            Updated UsageAgreement
        """
        data = {"reason": reason}
        if evidence:
            data["evidence"] = evidence

        result = self._make_request(
            "POST",
            f"/usage-agreements/{agreement_id}/dispute",
            json=data
        )

        return self._parse_usage_agreement(result)

    def reject_usage_agreement_with_suggestions(
        self,
        agreement_id: str,
        *,
        reason: Optional[str] = None,
        suggested_cart_items: Optional[list] = None,
        suggested_total_cents: Optional[int] = None,
    ) -> Dict[str, Any]:
        """Reject an agreement with optional suggestions (seller action)."""
        headers = {"Idempotency-Key": f"reject:{agreement_id}"}
        body: Dict[str, Any] = {}
        if reason:
            body["reason"] = reason
        if suggested_cart_items:
            body["suggested_cart_items"] = suggested_cart_items
        if suggested_total_cents is not None:
            body["suggested_total_cents"] = suggested_total_cents
        return self._make_request("PUT", f"/usage-agreements/{agreement_id}/reject", headers=headers, json=body)

    # ========================================================================
    # Delivery Tracking Methods
    # ========================================================================

    def update_delivery(
        self,
        agreement_id: str,
        request: UpdateDeliveryRequest,
    ) -> DeliveryResponse:
        """
        Update delivery information for an agreement (seller only).

        Enforces strict status transitions:
        - pending -> shipped
        - shipped -> in_transit, exception
        - in_transit -> out_for_delivery, delivered, exception
        - out_for_delivery -> delivered, exception
        - exception -> in_transit, out_for_delivery, delivered
        - delivered -> (terminal state)

        Auto-generates tracking URLs for known carriers (UPS, FedEx, USPS, DHL).

        Args:
            agreement_id: The agreement ID
            request: UpdateDeliveryRequest with delivery details

        Returns:
            DeliveryResponse with updated delivery info
        """
        data: Dict[str, Any] = {"status": request.status}

        if request.carrier:
            data["carrier"] = request.carrier
        if request.tracking_number:
            data["tracking_number"] = request.tracking_number
        if request.tracking_url:
            data["tracking_url"] = request.tracking_url
        if request.estimated_delivery:
            data["estimated_delivery"] = request.estimated_delivery
        if request.notes:
            data["notes"] = request.notes

        result = self._make_request(
            "PATCH",
            f"/usage-agreements/{agreement_id}/delivery",
            json=data,
        )

        return DeliveryResponse(
            agreement_id=result["agreement_id"],
            status=result["status"],
            delivery=result["delivery"],
        )

    def get_delivery_info(self, agreement: UsageAgreement) -> Optional[DeliveryInfo]:
        """
        Extract delivery info from agreement metadata.

        Args:
            agreement: The usage agreement

        Returns:
            DeliveryInfo or None if not set
        """
        if not agreement.metadata or "delivery" not in agreement.metadata:
            return None

        d = agreement.metadata["delivery"]
        history = None
        if d.get("history"):
            history = [
                DeliveryStatusChange(status=h["status"], timestamp=h["timestamp"])
                for h in d["history"]
            ]

        return DeliveryInfo(
            status=d.get("status", "pending"),
            carrier=d.get("carrier"),
            tracking_number=d.get("tracking_number"),
            tracking_url=d.get("tracking_url"),
            shipped_at=d.get("shipped_at"),
            estimated_delivery=d.get("estimated_delivery"),
            delivered_at=d.get("delivered_at"),
            notes=d.get("notes"),
            history=history,
        )

    def get_shipping_address(self, agreement: UsageAgreement) -> Optional[ShippingAddress]:
        """
        Extract shipping address from agreement metadata.

        Args:
            agreement: The usage agreement

        Returns:
            ShippingAddress or None if not set
        """
        if not agreement.metadata or "shipping" not in agreement.metadata:
            return None

        s = agreement.metadata["shipping"]
        return ShippingAddress(
            recipient_name=s["recipient_name"],
            street1=s["street1"],
            street2=s.get("street2"),
            city=s["city"],
            state=s["state"],
            postal_code=s["postal_code"],
            country=s["country"],
            phone=s.get("phone"),
        )

    @staticmethod
    def requires_shipping(cart_items: List[Dict[str, Any]]) -> bool:
        """
        Check if any cart items require shipping.

        Use this to determine if you need to collect a shipping address from the buyer.

        Args:
            cart_items: List of cart item dictionaries

        Returns:
            True if any item has requires_shipping=True attribute
        """
        return any(
            item.get("attributes", {}).get("requires_shipping") is True
            for item in cart_items
        )

    # ---------------- Policies & Approvals (owner JWT) ----------------
    def get_agent_policies(self, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = {"Authorization": f"Bearer {jwt_token}"} if jwt_token else None
        return self._make_request("GET", f"/agents/{agent}/policies", headers=headers)

    def update_agent_policies(self, policy: Dict[str, Any], agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = {"Authorization": f"Bearer {jwt_token}"} if jwt_token else None
        return self._make_request("PUT", f"/agents/{agent}/policies", headers=headers, json=policy)

    def list_approvals(self, status: Optional[str] = None, limit: int = 50, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        params: Dict[str, Any] = {"limit": limit}
        if status:
            params["status"] = status
        headers = {"Authorization": f"Bearer {jwt_token}"} if jwt_token else None
        return self._make_request("GET", f"/agents/{agent}/approvals", headers=headers, params=params)

    def approve_approval(self, request_id: str, agent_id: Optional[str] = None, jwt_token: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = {"Authorization": f"Bearer {jwt_token}"} if jwt_token else None
        return self._make_request("POST", f"/agents/{agent}/approvals/{request_id}/approve", headers=headers)

    def reject_approval(self, request_id: str, agent_id: Optional[str] = None, jwt_token: Optional[str] = None, reason: Optional[str] = None) -> Dict[str, Any]:
        agent = agent_id or self.agent_id
        headers = {"Authorization": f"Bearer {jwt_token}"} if jwt_token else None
        path = f"/agents/{agent}/approvals/{request_id}/reject"
        if reason:
            return self._make_request("POST", f"{path}?reason={requests.utils.quote(reason)}", headers=headers)
        return self._make_request("POST", path, headers=headers)

    def _parse_usage_agreement(self, data: Dict[str, Any]) -> UsageAgreement:
        """Helper to parse API response into UsageAgreement object"""
        return UsageAgreement(
            agreement_id=data["agreement_id"],
            buyer_agent_id=data["buyer_agent_id"],
            seller_agent_id=data["seller_agent_id"],
            quantity=data["quantity"],
            unit=data["unit"],
            price_per_unit_cents=data["price_per_unit_cents"],
            total_cents=data["total_cents"],
            payment_method=data.get("payment_method"),
            status=data["status"],
            service_description=data.get("service_description"),
            expires_at=data["expires_at"],
            created_at=data.get("created_at"),
            units_used=data.get("units_used"),
            escrow_payment_id=data.get("escrow_payment_id"),
            released_cents=data.get("released_cents"),
            seller_receipt_id=data.get("seller_receipt_id"),
            buyer_receipt_id=data.get("buyer_receipt_id"),
            metadata=data.get("metadata"),
            # Bilateral escrow fields
            settlement_model=data.get("settlement_model"),
            buyer_wallet_address=data.get("buyer_wallet_address"),
            seller_wallet_address=data.get("seller_wallet_address"),
            escrow_status=data.get("escrow_status"),
            onchain_agreement_id=data.get("onchain_agreement_id"),
            infra_fee_status=data.get("infra_fee_status"),
            service_amount_atomic=data.get("service_amount_atomic"),
            infra_fee_amount_atomic=data.get("infra_fee_amount_atomic"),
            per_call_fee_amount_atomic=data.get("per_call_fee_amount_atomic"),
            pricing_version=data.get("pricing_version"),
            settlement_status=data.get("settlement_status"),
            challenge_window_expires_at=data.get("challenge_window_expires_at"),
            aggregate_earned_atomic=data.get("aggregate_earned_atomic"),
            aggregate_claimed_atomic=data.get("aggregate_claimed_atomic"),
            aggregate_withdrawn_atomic=data.get("aggregate_withdrawn_atomic"),
        )

    # ========================================================================
    # Bilateral Escrow Lifecycle Methods
    # ========================================================================

    def get_escrow_status(self, agreement_id: str) -> EscrowStatusResponse:
        """Get extended escrow status for a bilateral agreement.

        Returns settlement progress, claimable amounts, and challenge window timer.

        Args:
            agreement_id: ID of the usage agreement

        Returns:
            EscrowStatusResponse with settlement details
        """
        result = self._make_request("GET", f"/usage-agreements/{agreement_id}/escrow-status")
        return EscrowStatusResponse(
            agreement_id=result["agreement_id"],
            escrow_status=result["escrow_status"],
            settlement_model=result.get("settlement_model"),
            status=result.get("status"),
            settlement_status=result.get("settlement_status"),
            onchain_agreement_id=result.get("onchain_agreement_id"),
            infra_fee_status=result.get("infra_fee_status"),
            onchain=result.get("onchain"),
            service_amount_atomic=result.get("service_amount_atomic"),
            aggregate_earned_atomic=result.get("aggregate_earned_atomic"),
            aggregate_claimed_atomic=result.get("aggregate_claimed_atomic"),
            aggregate_withdrawn_atomic=result.get("aggregate_withdrawn_atomic"),
            seller_claimable_atomic=result.get("seller_claimable_atomic"),
            buyer_withdrawable_atomic=result.get("buyer_withdrawable_atomic"),
            challenge_window_expires_at=result.get("challenge_window_expires_at"),
            buyer_wallet_address=result.get("buyer_wallet_address"),
            seller_wallet_address=result.get("seller_wallet_address"),
            chain_id=result.get("chain_id"),
        )

    def get_deposit_params(self, agreement_id: str) -> DepositParamsResponse:
        """Get deposit parameters needed before signing permits.

        Call this before ``build_bilateral_agreement_signatures()`` to get
        the chain_id, contract addresses, actual on-chain agreement ID when
        available, and amounts needed for signing.

        Args:
            agreement_id: ID of the usage agreement

        Returns:
            DepositParamsResponse with chain config and amounts
        """
        result = self._make_request("GET", f"/usage-agreements/{agreement_id}/deposit-params")
        return DepositParamsResponse(
            agreement_id=result["agreement_id"],
            chain_id=result["chain_id"],
            usdc_address=result["usdc_address"],
            escrow_contract_address=result["escrow_contract_address"],
            service_amount_atomic=result["service_amount_atomic"],
            onchain_agreement_id=result.get("onchain_agreement_id"),
            infra_fee_amount_atomic=result.get("infra_fee_amount_atomic"),
            per_call_fee_amount_atomic=result.get("per_call_fee_amount_atomic"),
            gas_wallet_address=result.get("gas_wallet_address"),
            buyer_wallet_address=result.get("buyer_wallet_address"),
            pricing_version=result.get("pricing_version"),
        )

    def refresh_permit(
        self,
        agreement_id: str,
        *,
        buyer_permit_signature: Optional[str] = None,
        buyer_permit_deadline: Optional[int] = None,
        buyer_deposit_auth_signature: Optional[str] = None,
        buyer_deposit_auth_deadline: Optional[int] = None,
        buyer_infra_fee_permit_signature: Optional[str] = None,
        buyer_infra_fee_permit_deadline: Optional[int] = None,
    ) -> Dict[str, Any]:
        """Submit refreshed permit/deposit-auth signatures for a bilateral agreement.

        Args:
            agreement_id: ID of the usage agreement
            buyer_permit_signature: New EIP-2612 USDC permit signature (optional)
            buyer_permit_deadline: New permit deadline (unix timestamp, optional)
            buyer_deposit_auth_signature: New EIP-712 deposit auth signature (optional)
            buyer_deposit_auth_deadline: New deposit auth deadline (optional)
            buyer_infra_fee_permit_signature: New infra fee permit signature (optional)
            buyer_infra_fee_permit_deadline: New infra fee permit deadline (optional)

        Returns:
            Updated agreement status
        """
        data: Dict[str, Any] = {}
        if buyer_permit_signature:
            data["buyer_permit_signature"] = buyer_permit_signature
        if buyer_permit_deadline is not None:
            data["buyer_permit_deadline"] = buyer_permit_deadline
        if buyer_deposit_auth_signature:
            data["buyer_deposit_auth_signature"] = buyer_deposit_auth_signature
        if buyer_deposit_auth_deadline is not None:
            data["buyer_deposit_auth_deadline"] = buyer_deposit_auth_deadline
        if buyer_infra_fee_permit_signature:
            data["buyer_infra_fee_permit_signature"] = buyer_infra_fee_permit_signature
        if buyer_infra_fee_permit_deadline is not None:
            data["buyer_infra_fee_permit_deadline"] = buyer_infra_fee_permit_deadline
        if not data:
            raise ValueError("At least one signature update is required")
        return self._make_request("POST", f"/usage-agreements/{agreement_id}/refresh-permit", json=data)

    def _submit_sponsored_action(
        self,
        agreement_id: str,
        endpoint: str,
        req: SponsoredActionRequest,
    ) -> SponsoredActionResponse:
        result = self._make_request(
            "POST",
            f"/usage-agreements/{agreement_id}/sponsored-actions/{endpoint}",
            json={
                "signer_wallet_address": req.signer_wallet_address,
                "nonce": req.nonce,
                "deadline": req.deadline,
                "signature": req.signature,
            },
        )
        return SponsoredActionResponse(
            agreement_id=result["agreement_id"],
            onchain_agreement_id=result["onchain_agreement_id"],
            action=result["action"],
            relay_status=result["relay_status"],
            relay_audit_id=result.get("relay_audit_id"),
            signer_wallet_address=result["signer_wallet_address"],
            signer_role=result["signer_role"],
            nonce=result["nonce"],
            deadline=result["deadline"],
            tx_hash=result["tx_hash"],
            chain_id=result["chain_id"],
            settlement_status=result.get("settlement_status"),
            claimable_atomic=result.get("claimable_atomic"),
            sponsored=result.get("sponsored", True),
        )

    def submit_sponsored_initiate_close(
        self,
        agreement_id: str,
        req: SponsoredActionRequest,
    ) -> SponsoredActionResponse:
        """Relay a signed initiate-close intent through the API."""
        return self._submit_sponsored_action(agreement_id, "initiate-close", req)

    def submit_sponsored_seller_claim(
        self,
        agreement_id: str,
        req: SponsoredActionRequest,
    ) -> SponsoredActionResponse:
        """Relay a signed seller-claim intent through the API."""
        return self._submit_sponsored_action(agreement_id, "seller-claim", req)

    def submit_sponsored_buyer_withdraw(
        self,
        agreement_id: str,
        req: SponsoredActionRequest,
    ) -> SponsoredActionResponse:
        """Relay a signed buyer-withdraw intent through the API."""
        return self._submit_sponsored_action(agreement_id, "buyer-withdraw", req)

    def get_settlement_action_options(
        self,
        agreement_id: str,
        *,
        action: str,
        signer_wallet_address: Optional[str] = None,
    ) -> SettlementActionOptionsResponse:
        """Get direct-vs-sponsored execution options for a settlement action."""
        path = f"/usage-agreements/{agreement_id}/settlement-action-options?action={action}"
        if signer_wallet_address:
            path += f"&signer_wallet_address={signer_wallet_address}"
        result = self._make_request("GET", path)
        return SettlementActionOptionsResponse(
            agreement_id=result["agreement_id"],
            onchain_agreement_id=result["onchain_agreement_id"],
            action=result["action"],
            signer_wallet_address=result["signer_wallet_address"],
            signer_role=result["signer_role"],
            current_nonce=result["current_nonce"],
            claimable_atomic=result.get("claimable_atomic"),
            settlement_status=result.get("settlement_status"),
            chain_id=result["chain_id"],
            escrow_contract_address=result.get("escrow_contract_address"),
            gas_wallet_address=result.get("gas_wallet_address"),
            gas_wallet_eth_balance_wei=result.get("gas_wallet_eth_balance_wei", 0),
            signer_eth_balance_wei=result.get("signer_eth_balance_wei", 0),
            signer_has_gas=result.get("signer_has_gas", False),
            direct_available=result.get("direct_available", False),
            sponsored_available=result.get("sponsored_available", False),
            availability_reason=result.get("availability_reason", "action_not_available"),
            sponsored_block_reason=result.get("sponsored_block_reason"),
            recommended_mode=result.get("recommended_mode", "unavailable"),
            recommendation_reason=result.get("recommendation_reason", "action_not_available"),
            deadline_window_seconds=result.get("deadline_window_seconds", 0),
            suggested_deadline=result.get("suggested_deadline", 0),
            direct_function=result.get("direct_function", ""),
        )

    def get_claim_instructions(self, agreement_id: str) -> ClaimInstructionsResponse:
        """Get unsigned calldata for sellerClaim() or buyerWithdraw().

        After settlement finalization, the seller can claim earned funds and
        the buyer can withdraw remaining funds. This returns unsigned calldata
        that the agent signs and submits from their own wallet.

        Args:
            agreement_id: ID of the usage agreement

        Returns:
            ClaimInstructionsResponse with unsigned calldata
        """
        result = self._make_request("GET", f"/usage-agreements/{agreement_id}/claim-instructions")
        tx = None
        if result.get("transaction"):
            cd = result["transaction"]
            try:
                from .escrow_signing import verify_calldata_selector
            except ImportError:
                from escrow_signing import verify_calldata_selector

            fn_name = verify_calldata_selector(cd["data"])
            if fn_name not in ("sellerClaim(uint256)", "buyerWithdraw(uint256)"):
                raise ValueError(
                    f"Unexpected calldata function for claim instructions: {fn_name}"
                )
            tx = TransactionCalldata(
                to=cd["to"],
                data=cd["data"],
                value=cd.get("value", "0"),
            )
        return ClaimInstructionsResponse(
            agreement_id=result["agreement_id"],
            onchain_agreement_id=result["onchain_agreement_id"],
            function=result["function"],
            claimable_atomic=result["claimable_atomic"],
            transaction=tx,
            chain_id=result.get("chain_id"),
            closed=result.get("closed", False),
        )

    def get_initiate_close_calldata(self, agreement_id: str) -> InitiateCloseCalldataResponse:
        """Get unsigned calldata for self-service close initiation.

        Fallback for buyer/seller-initiated close when automatic settlement
        hasn't triggered. Starts the 24-hour challenge window.

        Args:
            agreement_id: ID of the usage agreement

        Returns:
            InitiateCloseCalldataResponse with unsigned calldata
        """
        result = self._make_request("GET", f"/usage-agreements/{agreement_id}/initiate-close-calldata")
        tx = None
        if result.get("transaction"):
            cd = result["transaction"]
            try:
                from .escrow_signing import verify_calldata_selector
            except ImportError:
                from escrow_signing import verify_calldata_selector

            fn_name = verify_calldata_selector(cd["data"])
            if fn_name != "initiateClose(uint256)":
                raise ValueError(
                    f"Unexpected calldata function for initiate-close: {fn_name}"
                )
            tx = TransactionCalldata(
                to=cd["to"],
                data=cd["data"],
                value=cd.get("value", "0"),
            )
        return InitiateCloseCalldataResponse(
            onchain_agreement_id=result["onchain_agreement_id"],
            function=result["function"],
            transaction=tx,
            chain_id=result.get("chain_id"),
        )

    def get_call_proofs(
        self,
        agreement_id: str,
        *,
        cursor: Optional[str] = None,
        limit: int = 50,
        direction: str = "desc",
    ) -> CallProofResponse:
        """Get call proofs for hash chain audit.

        Args:
            agreement_id: ID of the usage agreement
            cursor: Pagination cursor from previous response
            limit: Maximum items per page (default: 50)
            direction: Sort direction ("asc" or "desc", default: "desc")

        Returns:
            CallProofResponse with paginated call proofs
        """
        params: Dict[str, Any] = {"limit": limit, "direction": direction}
        if cursor:
            params["cursor"] = cursor
        result = self._make_request("GET", f"/usage-agreements/{agreement_id}/call-proofs", params=params)
        items = []
        for p in result.get("items", []):
            items.append(CallProofItem(
                proof_id=p["proof_id"],
                agreement_id=p["agreement_id"],
                call_index=p["call_index"],
                request_hash=p["request_hash"],
                response_hash=p["response_hash"],
                chain_hash=p["chain_hash"],
                signature=p["signature"],
                created_at=p["created_at"],
            ))
        return CallProofResponse(
            items=items,
            cursor=result.get("cursor"),
            has_more=result.get("has_more", False),
        )

    def get_cumulative_receipts(
        self,
        agreement_id: str,
        *,
        cursor: Optional[str] = None,
        limit: int = 50,
        direction: str = "desc",
    ) -> CumulativeReceiptResponse:
        """Get cumulative ECDSA receipts for on-chain verification audit.

        Args:
            agreement_id: ID of the usage agreement
            cursor: Pagination cursor from previous response
            limit: Maximum items per page (default: 50)
            direction: Sort direction ("asc" or "desc", default: "desc")

        Returns:
            CumulativeReceiptResponse with paginated receipts
        """
        params: Dict[str, Any] = {"limit": limit, "direction": direction}
        if cursor:
            params["cursor"] = cursor
        result = self._make_request("GET", f"/usage-agreements/{agreement_id}/cumulative-receipts", params=params)
        items = []
        for r in result.get("items", []):
            items.append(CumulativeReceiptItem(
                receipt_id=r["receipt_id"],
                agreement_id=r["agreement_id"],
                cumulative_units=r["cumulative_units"],
                price_per_unit_atomic=r["price_per_unit_atomic"],
                nonce=r["nonce"],
                signature=r["signature"],
                signer=r["signer"],
                proof_hash=r.get("proof_hash"),
                created_at=r.get("created_at"),
            ))
        return CumulativeReceiptResponse(
            items=items,
            cursor=result.get("cursor"),
            has_more=result.get("has_more", False),
        )

    # ========================================================================
    # Metered Proxy Client
    # ========================================================================

    def create_metered_client(self, agreement_id: str) -> 'MeteredClient':
        """
        Create a metered API client for automatic usage tracking.

        This client wraps buyer requests to seller APIs, automatically tracking
        usage and capturing payments through Paegents' metered proxy.

        Features:
        - Automatic usage tracking (tamper-proof)
        - Custom usage units support (via X-Usage-Units header)
        - Response caching for GET requests
        - Replay protection with nonces
        - All existing security features (auth, rate limiting, escrow caps)

        Args:
            agreement_id: The usage agreement ID to track usage against

        Returns:
            MeteredClient instance for making metered API calls

        Example:
            ```python
            # Create metered client
            client = sdk.create_metered_client("agr_123")

            # Make requests (automatically tracked)
            result = client.post("/generate", json={"prompt": "hello"})

            # Check usage
            status = client.get_usage_status()
            print(f"Used: {status['units_used']}/{status['units_total']}")
            ```
        """
        return MeteredClient(self, agreement_id)


@dataclass
class MeteredUsageStatus:
    """Usage status for a metered agreement"""
    agreement_id: str
    units_used: int
    units_total: int
    units_remaining: int
    status: str
    escrow_status: str
    unit: str
    price_per_unit_cents: int


class MeteredClient:
    """
    HTTP client for metered API usage with automatic tracking.

    Proxies requests through Paegents to automatically track usage
    and capture payments based on actual API consumption.
    """

    def __init__(self, sdk: AgentPaymentsSDK, agreement_id: str):
        """
        Initialize metered client.

        Args:
            sdk: Parent AgentPaymentsSDK instance
            agreement_id: Usage agreement ID
        """
        self.sdk = sdk
        self.agreement_id = agreement_id
        self.base_url = f"{sdk.api_url}/metered-proxy/{agreement_id}"

    def get(self, path: str, **kwargs) -> Any:
        """
        Make a GET request through the metered proxy.

        GET requests are cached for 5 seconds and only charge once.

        Args:
            path: Path on seller's API (e.g., "/search")
            **kwargs: Additional arguments passed to requests.request()

        Returns:
            Response data (JSON)

        Example:
            ```python
            results = client.get("/search", params={"query": "test", "limit": 10})
            ```
        """
        return self._request("GET", path, **kwargs)

    def post(self, path: str, json: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
        """
        Make a POST request through the metered proxy.

        Args:
            path: Path on seller's API (e.g., "/generate")
            json: JSON request body
            **kwargs: Additional arguments passed to requests.request()

        Returns:
            Response data (JSON)

        Example:
            ```python
            result = client.post("/generate", json={
                "prompt": "a cat wearing a hat",
                "max_tokens": 100
            })
            ```
        """
        if json is not None:
            kwargs["json"] = json
        return self._request("POST", path, **kwargs)

    def put(self, path: str, json: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
        """
        Make a PUT request through the metered proxy.

        Args:
            path: Path on seller's API
            json: JSON request body
            **kwargs: Additional arguments passed to requests.request()

        Returns:
            Response data (JSON)
        """
        if json is not None:
            kwargs["json"] = json
        return self._request("PUT", path, **kwargs)

    def delete(self, path: str, **kwargs) -> Any:
        """
        Make a DELETE request through the metered proxy.

        Args:
            path: Path on seller's API
            **kwargs: Additional arguments passed to requests.request()

        Returns:
            Response data (JSON)
        """
        return self._request("DELETE", path, **kwargs)

    def patch(self, path: str, json: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
        """
        Make a PATCH request through the metered proxy.

        Args:
            path: Path on seller's API
            json: JSON request body
            **kwargs: Additional arguments passed to requests.request()

        Returns:
            Response data (JSON)
        """
        if json is not None:
            kwargs["json"] = json
        return self._request("PATCH", path, **kwargs)

    def get_usage_status(self) -> MeteredUsageStatus:
        """
        Get current usage status for this agreement.

        Returns:
            MeteredUsageStatus with usage details

        Example:
            ```python
            status = client.get_usage_status()
            print(f"Used {status.units_used} of {status.units_total} {status.unit}s")
            print(f"Remaining: {status.units_remaining}")
            ```
        """
        url = f"{self.base_url}/status"
        nonce = self._generate_nonce()

        headers = {
            "Content-Type": "application/json",
            "X-Request-Nonce": nonce,
            "Authorization": f"Bearer {self.sdk.api_key}",
        }

        response = requests.get(url, headers=headers)

        if not response.ok:
            try:
                body = response.json()
                detail = body.get("detail") if isinstance(body, dict) else None
            except Exception:
                detail = None
            msg = detail or response.text
            raise ApiError(f"Failed to get usage status: {msg}", response.status_code)

        data = response.json()

        return MeteredUsageStatus(
            agreement_id=data["agreement_id"],
            units_used=data["units_used"],
            units_total=data["units_total"],
            units_remaining=data["units_remaining"],
            status=data["status"],
            escrow_status=data["escrow_status"],
            unit=data["unit"],
            price_per_unit_cents=data["price_per_unit_cents"]
        )

    def _request(self, method: str, path: str, **kwargs) -> Any:
        """
        Internal method to make proxied requests with automatic nonce generation.

        Args:
            method: HTTP method
            path: Path on seller's API
            **kwargs: Additional arguments passed to requests.request()

        Returns:
            Response data (JSON)
        """
        # Build URL
        clean_path = path.lstrip("/")
        url = f"{self.base_url}/{clean_path}"

        # Generate nonce for replay protection
        nonce = self._generate_nonce()

        # Prepare headers
        headers = kwargs.pop("headers", {})
        headers.update({
            "Content-Type": "application/json",
            "X-Request-Nonce": nonce,
            "Authorization": f"Bearer {self.sdk.api_key}",
        })

        # Make request
        response = requests.request(method, url, headers=headers, **kwargs)

        # Handle errors
        if not response.ok:
            try:
                body = response.json()
                detail = body.get("detail") if isinstance(body, dict) else None
            except Exception:
                body = None
                detail = None
            msg = detail or response.text
            raise ApiError(f"Metered proxy request failed: {msg}", response.status_code, body)

        # Return JSON response
        try:
            return response.json()
        except Exception:
            # Some endpoints may return non-JSON
            return response.text

    def _generate_nonce(self) -> str:
        """Generate a unique nonce for replay protection"""
        return str(uuid.uuid4())


# ========================================================================
# Settlement Polling Utility (standalone function)
# ========================================================================

def poll_settlement_status(
    sdk: AgentPaymentsSDK,
    agreement_id: str,
    target_status: str = "settled",
    interval: int = 60,
    timeout: int = 90000,
) -> EscrowStatusResponse:
    """Poll escrow status until it reaches the target state.

    Standalone utility function (not on the SDK class) for polling
    settlement progress. Useful for agents waiting for finalization.

    Args:
        sdk: AgentPaymentsSDK instance
        agreement_id: ID of the usage agreement
        target_status: Target settlement_status to wait for (default: "settled")
        interval: Seconds between polls (default: 60)
        timeout: Maximum wait time in seconds (default: 90000 = 25 hours)

    Returns:
        EscrowStatusResponse when target status is reached

    Raises:
        TimeoutError: If target status not reached within timeout
        ApiError: If API call fails

    Example:
        >>> from paegents import poll_settlement_status
        >>> status = poll_settlement_status(sdk, "agr_123", target_status="finalized")
        >>> print(f"Settlement finalized! Claimable: {status.claimable_seller}")
    """
    import time as _time

    start = _time.time()
    while True:
        status = sdk.get_escrow_status(agreement_id)
        if status.settlement_status == target_status:
            return status

        elapsed = _time.time() - start
        if elapsed >= timeout:
            raise TimeoutError(
                f"Settlement did not reach '{target_status}' within {timeout}s. "
                f"Current status: {status.settlement_status}"
            )

        _time.sleep(interval)


# ========================================================================
# Wallet-Based Credential Recovery (standalone function)
# ========================================================================

@dataclass
class RecoveredCredentials:
    agent_id: str
    api_key: str
    api_key_id: str
    key_prefix: str
    wallet_address: str
    keys_revoked: int
    message: str


def recover_credentials(
    api_url: str,
    private_key: str,
) -> RecoveredCredentials:
    """Recover agent credentials using only a wallet private key.

    When an agent loses its API key and agent_id, it can recover both
    by proving ownership of its wallet via an EIP-191 signature.

    This is a standalone function (not on the SDK class) because the
    agent has no credentials to construct an SDK instance.

    Args:
        api_url: Paegents API base URL (e.g. "https://api.paegents.com")
        private_key: Hex-encoded Ethereum private key (with or without 0x prefix)

    Returns:
        RecoveredCredentials with new api_key, agent_id, and metadata

    Raises:
        ApiError: If recovery fails (unverified agent, invalid sig, etc.)

    Example:
        >>> from paegents import recover_credentials
        >>> creds = recover_credentials(
        ...     "https://api.paegents.com",
        ...     "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
        ... )
        >>> print(f"Recovered! agent_id={creds.agent_id}")
        >>> sdk = AgentPaymentsSDK(api_url, creds.agent_id, creds.api_key)
    """
    from eth_account import Account as _Account
    from eth_account.messages import encode_defunct as _encode_defunct

    api_url = api_url.rstrip("/")

    # Derive wallet address from private key
    acct = _Account.from_key(private_key)
    wallet_address = acct.address.lower()

    # Step 1: Get recovery challenge
    resp = requests.get(
        f"{api_url}/wallet/recovery-challenge",
        params={"wallet_address": wallet_address},
    )
    if not resp.ok:
        body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
        raise ApiError(
            f"Recovery challenge failed: {body.get('detail', resp.text)}",
            resp.status_code,
            body,
        )

    challenge = resp.json()
    message = challenge["message"]
    nonce = challenge["nonce"]
    issued_at = challenge["issued_at"]

    # Step 2: Sign the challenge
    signable = _encode_defunct(text=message)
    signed = acct.sign_message(signable)
    signature = signed.signature.hex()
    if not signature.startswith("0x"):
        signature = f"0x{signature}"

    # Step 3: Submit recovery request
    resp = requests.post(
        f"{api_url}/wallet/recover",
        json={
            "wallet_address": wallet_address,
            "nonce": nonce,
            "issued_at": issued_at,
            "signature": signature,
        },
    )
    if not resp.ok:
        body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
        raise ApiError(
            f"Credential recovery failed: {body.get('detail', resp.text)}",
            resp.status_code,
            body,
        )

    result = resp.json()
    return RecoveredCredentials(
        agent_id=result["agent_id"],
        api_key=result["api_key"],
        api_key_id=result["api_key_id"],
        key_prefix=result["key_prefix"],
        wallet_address=result["wallet_address"],
        keys_revoked=result["keys_revoked"],
        message=result["message"],
    )
