"""
SynAuth client — Python SDK for AI agents to request human authorization.

SynAuth is the unified authorization layer. Any agent action — sending emails,
making purchases, booking meetings, signing contracts — goes through here.
Human approves via Face ID on iPhone.

Usage:
    from synauth import SynAuthClient, compute_content_hash

    client = SynAuthClient(api_key="aa_...")

    # WYSIWYS: user sees exactly what they approve (recommended)
    result = client.wysiwys_email(
        to="john@company.com",
        subject="Quarterly report",
        body="Please find the Q4 results attached.",
    )
    status = client.wait_for_result(result["id"])
    if status["status"] == "approved":
        send_email(...)

    # Verify content hash client-side
    expected = compute_content_hash({"to": "john@company.com", "subject": "Quarterly report"})
    assert result["content_hash"] == expected

    # WYSIWYS payment
    result = client.wysiwys_payment(
        recipient="AWS", amount=150.00, currency="USD", memo="Monthly hosting"
    )

    # WYSIWYS HTTP request (for vault-like operations)
    result = client.wysiwys_http(
        method="POST",
        url="https://api.openai.com/v1/chat/completions",
        body={"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]},
    )

    # Service-specific WYSIWYS (auto-constructs correct HTTP params)
    result = client.wysiwys_stripe_payment(
        amount=4999, currency="usd", customer="cus_Nk8J",
        description="Invoice #1042",
    )
    result = client.wysiwys_openai_chat(
        model="gpt-4o", messages=[{"role": "user", "content": "Summarize Q4"}],
    )
    result = client.wysiwys_github_issue(
        repo="acme/webapp", title="Bug: login 500 on Safari",
    )
    result = client.wysiwys_slack_message(
        channel="#deployments", text="v1.2.3 deployed to production",
    )
    result = client.wysiwys_anthropic_message(
        model="claude-sonnet-4-20250514",
        messages=[{"role": "user", "content": "Analyze this contract"}],
    )

    # Generic WYSIWYS for any action type
    result = client.wysiwys_action(
        action_type="deployment",
        params={"environment": "production", "version": "1.2.3"},
    )

    # List vault services (structural enforcement — agent never sees credentials)
    services = client.list_vault_services()

    # Execute an API call through the vault (biometric approval + credential injection)
    result = client.execute_api_call(
        service_name="openai",
        method="POST",
        url="https://api.openai.com/v1/chat/completions",
        body='{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}',
    )
"""

import hashlib
import json
import time
import requests


# --- WYSIWYS utilities ---


def compute_content_hash(execution_params: dict) -> str:
    """Compute the WYSIWYS content hash for execution parameters.

    Uses the same canonical form as the SynAuth backend: sorted keys,
    compact JSON separators, SHA-256. Use this to verify that a response's
    content_hash matches what you submitted.

    Args:
        execution_params: Dict of execution parameters.

    Returns:
        SHA-256 hex digest string (64 characters).

    Example:
        >>> from synauth import compute_content_hash
        >>> compute_content_hash({"to": "alice@example.com", "subject": "Hello"})
        'a1b2c3...'  # deterministic hash
    """
    canonical = json.dumps(execution_params, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode()).hexdigest()


# --- Error classes ---


class SynAuthError(Exception):
    """Base exception for all SynAuth SDK errors."""
    pass


class SynAuthAPIError(SynAuthError):
    """HTTP error from the SynAuth backend."""

    def __init__(self, status_code: int, detail: str, response: requests.Response = None):
        self.status_code = status_code
        self.detail = detail
        self.response = response
        super().__init__(f"SynAuth API error {status_code}: {detail}")


class RateLimitError(SynAuthAPIError):
    """Rate limit exceeded (HTTP 429)."""

    def __init__(self, detail: str = "Rate limit exceeded", response: requests.Response = None):
        super().__init__(429, detail, response)


class ActionExpiredError(SynAuthError):
    """Action request expired before approval."""

    def __init__(self, request_id: str):
        self.request_id = request_id
        super().__init__(f"Action {request_id} expired")


class ActionDeniedError(SynAuthError):
    """Action request was denied."""

    def __init__(self, request_id: str, reason: str = None):
        self.request_id = request_id
        self.reason = reason
        msg = f"Action {request_id} denied"
        if reason:
            msg += f": {reason}"
        super().__init__(msg)


class ApprovalTimeoutError(SynAuthError):
    """Client-side polling timeout — the action is still pending on the server."""

    def __init__(self, request_id: str, timeout: int):
        self.request_id = request_id
        self.timeout = timeout
        super().__init__(
            f"Approval timed out after {timeout}s — action {request_id} is still pending"
        )


class VaultExecutionError(SynAuthError):
    """Vault credential execution failed."""

    def __init__(self, detail: str):
        self.detail = detail
        super().__init__(f"Vault execution failed: {detail}")


class SynAuthClient:
    """Client for agents to request Face ID-authorized actions."""

    API_VERSION = "v1"

    def __init__(self, api_key: str, base_url: str = "https://synauth.fly.dev"):
        self.api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.session = requests.Session()
        self.session.headers["X-API-Key"] = api_key

    def _request(self, method: str, path: str, **kwargs) -> dict:
        """Make an authenticated request to the SynAuth backend.

        Centralizes error handling — converts HTTP errors to typed exceptions.
        """
        url = f"{self.base_url}/api/{self.API_VERSION}{path}"
        resp = self.session.request(method, url, timeout=30, **kwargs)

        if resp.status_code == 429:
            raise RateLimitError(response=resp)

        if not resp.ok:
            try:
                detail = resp.json().get("detail", resp.text)
            except (ValueError, KeyError):
                detail = resp.text
            raise SynAuthAPIError(resp.status_code, detail, resp)

        return resp.json()

    # --- Core action methods ---

    def request_action(
        self,
        action_type: str,
        title: str,
        description: str = None,
        risk_level: str = "medium",
        reversible: bool = True,
        amount: float = None,
        currency: str = "USD",
        recipient: str = None,
        metadata: dict = None,
        expires_in_seconds: int = 300,
        callback_url: str = None,
        execution_params: dict = None,
    ) -> dict:
        """Submit an action for human authorization. Returns immediately.

        If execution_params is provided, the backend computes a WYSIWYS content
        hash and rendered display. The user sees exactly what they approve —
        no separate title/description that could diverge from the parameters.
        The response includes content_hash and rendered_display fields.

        If callback_url is set, the backend will POST the action status to that
        URL when the human approves or denies. The agent can still poll as a
        fallback.
        """
        payload = {
            "action_type": action_type,
            "title": title,
            "risk_level": risk_level,
            "reversible": reversible,
            "expires_in_seconds": expires_in_seconds,
        }
        if description:
            payload["description"] = description
        if amount is not None:
            payload["amount"] = amount
            payload["currency"] = currency
        if recipient:
            payload["recipient"] = recipient
        if metadata:
            payload["metadata"] = metadata
        if callback_url:
            payload["callback_url"] = callback_url
        if execution_params is not None:
            payload["execution_params"] = execution_params

        return self._request("POST", "/actions", json=payload)

    def get_status(self, request_id: str) -> dict:
        """Check the current status of an action request."""
        return self._request("GET", f"/actions/{request_id}")

    def wait_for_result(
        self,
        request_id: str,
        timeout: int = 300,
        poll_interval: float = 2.0,
    ) -> dict:
        """Block until the action is approved, denied, or expired."""
        start = time.time()
        while time.time() - start < timeout:
            status = self.get_status(request_id)
            if status["status"] != "pending":
                return status
            time.sleep(poll_interval)
        return self.get_status(request_id)

    # --- History ---

    def get_history(
        self,
        limit: int = 50,
        status: str = None,
        action_type: str = None,
    ) -> dict:
        """Get this agent's action request history.

        Returns past action requests (approved, denied, expired, pending).
        Useful for reviewing what actions have been taken and their outcomes.

        Args:
            limit: Max number of results (default 50).
            status: Filter by status ('approved', 'denied', 'expired', 'pending').
            action_type: Filter by action type ('communication', 'purchase', etc.).

        Returns:
            Dict with 'actions' key containing list of action records.
        """
        params = {"limit": limit}
        if status:
            params["status"] = status
        if action_type:
            params["action_type"] = action_type
        return self._request("GET", "/actions", params=params)

    # --- Spending summary ---

    def get_spending_summary(self) -> dict:
        """Get this agent's current spending vs. limits.

        Returns spending summaries for all limits that apply to this agent —
        both agent-specific limits and global limits. Spend amounts are scoped
        to this agent only.

        Use this before making a purchase or other monetary action to check
        whether you have budget remaining. Each summary includes the limit,
        amount spent, amount remaining, and utilization percentage.

        Returns:
            Dict with 'agent_id' and 'summaries' keys. Each summary contains:
            limit_id, agent_id, action_type, period, limit, spent, remaining,
            utilization_pct.
        """
        return self._request("GET", "/agent/spending-summary")

    # --- Vault (structural enforcement) ---

    def list_vault_services(self) -> dict:
        """List available vault services (stored API credentials).

        Shows which services have credentials stored in SynAuth's vault.
        Each service has allowed hosts that restrict where credentials can be sent.
        The agent never sees the actual credential values — only service names
        and metadata.

        Use this to discover what API services are available before calling
        execute_api_call().

        Returns:
            Dict with 'services' key containing list of service records,
            each with: service_name, auth_type, allowed_hosts, description.
        """
        return self._request("GET", "/vault/services")

    def execute_api_call(
        self,
        service_name: str,
        method: str,
        url: str,
        headers: dict = None,
        body: str = None,
        description: str = None,
        timeout: int = 120,
        poll_interval: float = 3.0,
    ) -> dict:
        """Make an API call using a credential stored in SynAuth's vault.

        This is the core structural enforcement method: the agent provides the
        request details, SynAuth requests biometric approval, then executes the
        call with the stored credential. The agent never sees the raw API key.

        Flow:
        1. Creates an action request with vault execution metadata.
        2. Waits for biometric approval via Face ID.
        3. Executes the HTTP request with the stored credential injected.
        4. Returns the API response.

        The URL must match one of the service's allowed hosts (security:
        prevents credential exfiltration). Each approval is single-use.

        Args:
            service_name: Name of the vault service (see list_vault_services()).
            method: HTTP method (GET, POST, PUT, PATCH, DELETE).
            url: Full URL to call (host must be in service's allowed_hosts).
            headers: Additional headers (auth header is injected automatically).
            body: Request body (typically JSON string for POST/PUT/PATCH).
            description: Human-readable description shown in the approval prompt.
            timeout: Max seconds to wait for approval (default 120).
            poll_interval: Seconds between status checks (default 3.0).

        Returns:
            Dict with vault execution result including the API response.

        Raises:
            ActionDeniedError: If the user denied the request.
            ActionExpiredError: If the request expired before approval.
            ApprovalTimeoutError: If client-side polling timed out (action still pending).
            VaultExecutionError: If the credential execution failed.
            SynAuthAPIError: For other API errors.
        """
        # Step 1: Create approval request with vault metadata
        payload = {
            "action_type": "data_access",
            "title": description or f"API call: {method} {url}",
            "description": f"Service: {service_name} | {method} {url}",
            "risk_level": "medium",
            "metadata": {
                "vault_execute": True,
                "service_name": service_name,
                "method": method,
                "url": url,
                "headers": headers or {},
                "body": body,
            },
        }
        result = self._request("POST", "/actions", json=payload)

        # May be auto-denied by rules
        if result.get("status") == "denied":
            raise ActionDeniedError(result["id"], result.get("deny_reason"))

        request_id = result["id"]

        # Step 2: Wait for approval if pending
        if result.get("status") == "pending":
            start = time.time()
            while time.time() - start < timeout:
                result = self.get_status(request_id)
                if result["status"] != "pending":
                    break
                time.sleep(poll_interval)
            else:
                # Polling timeout expired — action is still pending on the server
                raise ApprovalTimeoutError(request_id, timeout)

        if result.get("status") == "expired":
            raise ActionExpiredError(request_id)
        if result.get("status") == "denied":
            raise ActionDeniedError(request_id, result.get("deny_reason"))
        if result.get("status") != "approved":
            raise VaultExecutionError(
                f"Unexpected status '{result.get('status')}' for request {request_id}"
            )

        # Step 3: Execute with stored credential
        return self._request("POST", f"/vault/execute/{request_id}")

    # --- Convenience methods for common action types ---

    def request_email(self, recipient: str, subject: str, preview: str = None, **kwargs) -> dict:
        return self.request_action(
            action_type="communication",
            title=f"Send email: {subject}",
            description=preview,
            recipient=recipient,
            risk_level=kwargs.pop("risk_level", "low"),
            **kwargs,
        )

    def request_purchase(self, amount: float, merchant: str, description: str = None, **kwargs) -> dict:
        return self.request_action(
            action_type="purchase",
            title=f"Purchase from {merchant}",
            description=description,
            amount=amount,
            recipient=merchant,
            risk_level=kwargs.pop("risk_level", "medium"),
            **kwargs,
        )

    def request_booking(self, title: str, description: str = None, amount: float = None, **kwargs) -> dict:
        return self.request_action(
            action_type="scheduling",
            title=title,
            description=description,
            amount=amount,
            risk_level=kwargs.pop("risk_level", "low"),
            **kwargs,
        )

    def request_post(self, platform: str, content_preview: str, **kwargs) -> dict:
        return self.request_action(
            action_type="social",
            title=f"Post to {platform}",
            description=content_preview,
            risk_level=kwargs.pop("risk_level", "medium"),
            **kwargs,
        )

    def request_data_access(self, resource: str, reason: str, **kwargs) -> dict:
        return self.request_action(
            action_type="data_access",
            title=f"Access: {resource}",
            description=reason,
            risk_level=kwargs.pop("risk_level", "high"),
            **kwargs,
        )

    def request_contract(self, title: str, description: str, **kwargs) -> dict:
        return self.request_action(
            action_type="legal",
            title=title,
            description=description,
            reversible=False,
            risk_level=kwargs.pop("risk_level", "critical"),
            **kwargs,
        )

    # --- WYSIWYS convenience methods ---
    #
    # These use execution_params so the user sees exactly what they approve.
    # The backend computes a content hash over the params; the device verifies
    # the hash before completing approval. This prevents display-execution
    # mismatch — the agent cannot show one thing and do another.

    def wysiwys_action(
        self,
        action_type: str,
        params: dict,
        **kwargs,
    ) -> dict:
        """Create a WYSIWYS-verified action with arbitrary parameters.

        The user sees the exact execution parameters and approves a content
        hash — ensuring what's displayed is what's executed.

        Args:
            action_type: Type of action (e.g., "communication", "purchase").
            params: Execution parameters the user will see and approve.
            **kwargs: Additional arguments passed to request_action
                (risk_level, callback_url, expires_in_seconds, etc.).

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        title = kwargs.pop("title", f"{action_type} action")
        risk_level = kwargs.pop("risk_level", "medium")
        return self.request_action(
            action_type=action_type,
            title=title,
            execution_params=params,
            risk_level=risk_level,
            **kwargs,
        )

    def wysiwys_email(
        self,
        to: str,
        subject: str,
        body: str = None,
        **kwargs,
    ) -> dict:
        """Request approval for sending an email with WYSIWYS verification.

        The user sees the exact recipient, subject, and body — not an
        agent-provided title that could differ from the actual email.

        Args:
            to: Email recipient address.
            subject: Email subject line.
            body: Email body text (optional).
            **kwargs: Additional arguments (risk_level, callback_url, etc.).

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        params = {"to": to, "subject": subject}
        if body is not None:
            params["body"] = body
        return self.wysiwys_action(
            action_type="communication",
            params=params,
            title=kwargs.pop("title", f"Send email: {subject}"),
            risk_level=kwargs.pop("risk_level", "low"),
            **kwargs,
        )

    def wysiwys_payment(
        self,
        recipient: str,
        amount: float,
        currency: str = "USD",
        memo: str = None,
        **kwargs,
    ) -> dict:
        """Request approval for a payment with WYSIWYS verification.

        The user sees the exact recipient, amount, and currency — preventing
        amount or recipient substitution between display and execution.

        Args:
            recipient: Payment recipient.
            amount: Payment amount.
            currency: Currency code (default "USD").
            memo: Optional payment memo/reference.
            **kwargs: Additional arguments (risk_level, callback_url, etc.).

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        params = {"recipient": recipient, "amount": amount, "currency": currency}
        if memo is not None:
            params["memo"] = memo
        return self.wysiwys_action(
            action_type="purchase",
            params=params,
            title=kwargs.pop("title", f"Pay {recipient} {amount} {currency}"),
            risk_level=kwargs.pop("risk_level", "medium"),
            **kwargs,
        )

    def wysiwys_http(
        self,
        method: str,
        url: str,
        headers: dict = None,
        body=None,
        **kwargs,
    ) -> dict:
        """Request approval for an HTTP API call with WYSIWYS verification.

        The user sees the exact method, URL, headers, and body. Authorization
        headers are automatically redacted in the display but included in the
        content hash (so tampering is still detected).

        Args:
            method: HTTP method (GET, POST, PUT, DELETE, etc.).
            url: Full URL to call.
            headers: Optional request headers.
            body: Optional request body (dict or string).
            **kwargs: Additional arguments (risk_level, callback_url, etc.).

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        params = {"method": method, "url": url}
        if headers is not None:
            params["headers"] = headers
        if body is not None:
            params["body"] = body
        return self.wysiwys_action(
            action_type="data_access",
            params=params,
            title=kwargs.pop("title", f"API call: {method} {url}"),
            risk_level=kwargs.pop("risk_level", "high"),
            **kwargs,
        )

    # --- Service-specific WYSIWYS methods ---
    # Each constructs the HTTP-shaped execution_params that the backend's
    # service-specific renderers already know how to display beautifully.

    def wysiwys_stripe_payment(
        self,
        amount: int,
        currency: str = "usd",
        customer: str = None,
        description: str = None,
        payment_method: str = None,
        **kwargs,
    ) -> dict:
        """Request approval for a Stripe payment with WYSIWYS verification.

        The user sees: "Stripe: Create Payment", amount (converted from cents),
        customer, description — rendered by the backend's Stripe renderer.

        Args:
            amount: Amount in cents (e.g., 4999 = $49.99).
            currency: Three-letter currency code (default: "usd").
            customer: Optional Stripe customer ID.
            description: Optional payment description.
            payment_method: Optional Stripe payment method ID.
            **kwargs: Additional arguments (risk_level, callback_url, etc.).

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        body = {"amount": amount, "currency": currency}
        if customer is not None:
            body["customer"] = customer
        if description is not None:
            body["description"] = description
        if payment_method is not None:
            body["payment_method"] = payment_method
        return self.wysiwys_http(
            method="POST",
            url="https://api.stripe.com/v1/payment_intents",
            body=body,
            title=f"Stripe payment: ${amount / 100:.2f} {currency.upper()}",
            risk_level=kwargs.pop("risk_level", "high"),
            **kwargs,
        )

    def wysiwys_stripe_refund(
        self,
        charge: str,
        amount: int = None,
        reason: str = None,
        **kwargs,
    ) -> dict:
        """Request approval for a Stripe refund with WYSIWYS verification.

        Args:
            charge: Stripe charge ID to refund.
            amount: Optional amount in cents (full refund if omitted).
            reason: Optional reason (duplicate, fraudulent, requested_by_customer).
            **kwargs: Additional arguments.

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        body = {"charge": charge}
        if amount is not None:
            body["amount"] = amount
        if reason is not None:
            body["reason"] = reason
        return self.wysiwys_http(
            method="POST",
            url="https://api.stripe.com/v1/refunds",
            body=body,
            title=f"Stripe refund: {charge}",
            risk_level=kwargs.pop("risk_level", "high"),
            **kwargs,
        )

    def wysiwys_openai_chat(
        self,
        model: str,
        messages: list,
        max_tokens: int = None,
        temperature: float = None,
        **kwargs,
    ) -> dict:
        """Request approval for an OpenAI chat completion with WYSIWYS verification.

        The user sees: "OpenAI: Chat Completion", model, message count,
        last user message preview — rendered by the backend's OpenAI renderer.

        Args:
            model: Model name (e.g., "gpt-4o", "gpt-4o-mini").
            messages: List of message dicts with "role" and "content".
            max_tokens: Optional max tokens for the response.
            temperature: Optional sampling temperature.
            **kwargs: Additional arguments.

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        body = {"model": model, "messages": messages}
        if max_tokens is not None:
            body["max_tokens"] = max_tokens
        if temperature is not None:
            body["temperature"] = temperature
        return self.wysiwys_http(
            method="POST",
            url="https://api.openai.com/v1/chat/completions",
            body=body,
            title=f"OpenAI: {model} chat",
            risk_level=kwargs.pop("risk_level", "medium"),
            **kwargs,
        )

    def wysiwys_openai_image(
        self,
        prompt: str,
        model: str = "dall-e-3",
        size: str = "1024x1024",
        n: int = 1,
        **kwargs,
    ) -> dict:
        """Request approval for OpenAI image generation with WYSIWYS verification.

        Args:
            prompt: Image generation prompt.
            model: Model name (default: "dall-e-3").
            size: Image size (default: "1024x1024").
            n: Number of images (default: 1).
            **kwargs: Additional arguments.

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        body = {"prompt": prompt, "model": model, "size": size, "n": n}
        return self.wysiwys_http(
            method="POST",
            url="https://api.openai.com/v1/images/generations",
            body=body,
            title=f"OpenAI: Generate image",
            risk_level=kwargs.pop("risk_level", "medium"),
            **kwargs,
        )

    def wysiwys_anthropic_message(
        self,
        model: str,
        messages: list,
        max_tokens: int = 1024,
        system: str = None,
        **kwargs,
    ) -> dict:
        """Request approval for an Anthropic Claude message with WYSIWYS verification.

        The user sees: "Anthropic: Create Message", model, message count,
        last user message preview — rendered by the backend's Anthropic renderer.

        Args:
            model: Model name (e.g., "claude-sonnet-4-20250514").
            messages: List of message dicts with "role" and "content".
            max_tokens: Max tokens for the response (default: 1024).
            system: Optional system prompt.
            **kwargs: Additional arguments.

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        body = {"model": model, "messages": messages, "max_tokens": max_tokens}
        if system is not None:
            body["system"] = system
        return self.wysiwys_http(
            method="POST",
            url="https://api.anthropic.com/v1/messages",
            body=body,
            title=f"Anthropic: {model} message",
            risk_level=kwargs.pop("risk_level", "medium"),
            **kwargs,
        )

    def wysiwys_github_issue(
        self,
        repo: str,
        title: str,
        body: str = None,
        labels: list = None,
        **kwargs,
    ) -> dict:
        """Request approval to create a GitHub issue with WYSIWYS verification.

        The user sees: "GitHub: Create Issue", repository, title, labels —
        rendered by the backend's GitHub renderer.

        Args:
            repo: Repository in "owner/repo" format.
            title: Issue title.
            body: Optional issue body.
            labels: Optional list of label names.
            **kwargs: Additional arguments.

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        issue_body = {"title": title}
        if body is not None:
            issue_body["body"] = body
        if labels is not None:
            issue_body["labels"] = labels
        return self.wysiwys_http(
            method="POST",
            url=f"https://api.github.com/repos/{repo}/issues",
            body=issue_body,
            title=f"GitHub: Create issue in {repo}",
            risk_level=kwargs.pop("risk_level", "medium"),
            **kwargs,
        )

    def wysiwys_github_pr(
        self,
        repo: str,
        title: str,
        head: str,
        base: str = "main",
        body: str = None,
        **kwargs,
    ) -> dict:
        """Request approval to create a GitHub pull request with WYSIWYS verification.

        Args:
            repo: Repository in "owner/repo" format.
            title: PR title.
            head: Head branch name.
            base: Base branch name (default: "main").
            body: Optional PR body.
            **kwargs: Additional arguments.

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        pr_body = {"title": title, "head": head, "base": base}
        if body is not None:
            pr_body["body"] = body
        return self.wysiwys_http(
            method="POST",
            url=f"https://api.github.com/repos/{repo}/pulls",
            body=pr_body,
            title=f"GitHub: Create PR in {repo}",
            risk_level=kwargs.pop("risk_level", "medium"),
            **kwargs,
        )

    def wysiwys_slack_message(
        self,
        channel: str,
        text: str,
        **kwargs,
    ) -> dict:
        """Request approval to send a Slack message with WYSIWYS verification.

        The user sees: "Slack: Post Message", channel, message text —
        rendered by the backend's Slack renderer.

        Args:
            channel: Slack channel (e.g., "#deployments" or channel ID).
            text: Message text.
            **kwargs: Additional arguments.

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        return self.wysiwys_http(
            method="POST",
            url="https://slack.com/api/chat.postMessage",
            body={"channel": channel, "text": text},
            title=f"Slack: Post to {channel}",
            risk_level=kwargs.pop("risk_level", "low"),
            **kwargs,
        )

    def wysiwys_sendgrid_email(
        self,
        to: str,
        subject: str,
        content: str = None,
        from_email: str = None,
        **kwargs,
    ) -> dict:
        """Request approval to send an email via SendGrid with WYSIWYS verification.

        The user sees: "SendGrid: Send Email", recipients, subject, from —
        rendered by the backend's SendGrid renderer.

        Args:
            to: Recipient email address.
            subject: Email subject.
            content: Optional email body text.
            from_email: Optional sender email address.
            **kwargs: Additional arguments.

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        body = {
            "personalizations": [{"to": [{"email": to}], "subject": subject}],
        }
        if from_email is not None:
            body["from"] = {"email": from_email}
        if content is not None:
            body["content"] = [{"type": "text/plain", "value": content}]
        return self.wysiwys_http(
            method="POST",
            url="https://api.sendgrid.com/v3/mail/send",
            body=body,
            title=f"SendGrid: Email to {to}",
            risk_level=kwargs.pop("risk_level", "low"),
            **kwargs,
        )

    def wysiwys_twilio_sms(
        self,
        to: str,
        from_number: str,
        body: str,
        **kwargs,
    ) -> dict:
        """Request approval to send an SMS via Twilio with WYSIWYS verification.

        The user sees: "Twilio: Send SMS", to, from, message —
        rendered by the backend's Twilio renderer.

        Args:
            to: Recipient phone number.
            from_number: Sender phone number (Twilio number).
            body: SMS message text.
            **kwargs: Additional arguments.

        Returns:
            Dict with id, status, content_hash, rendered_display, wysiwys=True.
        """
        return self.wysiwys_http(
            method="POST",
            url="https://api.twilio.com/2010-04-01/Accounts/ACXXXX/Messages",
            body={"To": to, "From": from_number, "Body": body},
            title=f"Twilio: SMS to {to}",
            risk_level=kwargs.pop("risk_level", "medium"),
            **kwargs,
        )


class SynAuthAdmin:
    """Admin client for account setup, TOTP management, and API key operations.

    Uses device authentication (X-Device-Id header) — for developers setting up
    SynAuth, not for agents making action requests. Agents use SynAuthClient.

    The typical flow:
        1. SynAuthAdmin.request_magic_link("you@example.com")
        2. SynAuthAdmin.verify_magic_link(token, "My Laptop")  → device_id
        3. admin = SynAuthAdmin(device_id="dev_...")
        4. admin.totp_setup()  → provisioning_uri (scan with authenticator)
        5. admin.totp_verify("123456")
        6. admin.create_key("my-agent", "My Agent")  → api_key
        7. client = SynAuthClient(api_key="aa_...")  → agent is ready

    Usage:
        from synauth import SynAuthAdmin

        # One-time setup: get your device_id via magic link
        result = SynAuthAdmin.request_magic_link("you@example.com")
        device = SynAuthAdmin.verify_magic_link(result["token"], "My Laptop")

        admin = SynAuthAdmin(device_id=device["device_id"])

        # Set up TOTP
        setup = admin.totp_setup()
        print(f"Scan this URI: {setup['provisioning_uri']}")
        admin.totp_verify("123456")  # code from authenticator

        # Create API key for your agent
        key = admin.create_key("my-agent", "My Agent")
        print(f"Agent API key: {key['key']}")

        # Approve pending actions
        pending = admin.get_pending()
        admin.approve(pending["actions"][0]["id"], totp_code="654321")
    """

    API_VERSION = "v1"

    def __init__(self, device_id: str, base_url: str = "https://synauth.fly.dev"):
        self.device_id = device_id
        self.base_url = base_url.rstrip("/")
        self.session = requests.Session()
        self.session.headers["X-Device-Id"] = device_id

    def _request(self, method: str, path: str, **kwargs) -> dict:
        """Make a device-authenticated request to the SynAuth backend."""
        url = f"{self.base_url}/api/{self.API_VERSION}{path}"
        resp = self.session.request(method, url, timeout=30, **kwargs)

        if resp.status_code == 429:
            raise RateLimitError(response=resp)

        if not resp.ok:
            try:
                detail = resp.json().get("detail", resp.text)
            except (ValueError, KeyError):
                detail = resp.text
            raise SynAuthAPIError(resp.status_code, detail, resp)

        return resp.json()

    # --- Static auth methods (no device_id needed) ---

    @staticmethod
    def request_magic_link(
        email: str,
        base_url: str = "https://synauth.fly.dev",
    ) -> dict:
        """Request a magic link to create or access an account.

        No authentication required. Creates the account if it doesn't exist.
        In dev mode (no SMTP configured), the token is returned directly.
        In production, a magic link email is sent.

        Args:
            email: Your email address.
            base_url: SynAuth backend URL (default: production).

        Returns:
            Dict with 'sent', 'email', and optionally 'token' (dev mode).
        """
        url = f"{base_url.rstrip('/')}/api/v1/auth/magic-link"
        resp = requests.post(url, json={"email": email}, timeout=30)

        if not resp.ok:
            try:
                detail = resp.json().get("detail", resp.text)
            except (ValueError, KeyError):
                detail = resp.text
            raise SynAuthAPIError(resp.status_code, detail, resp)

        return resp.json()

    @staticmethod
    def verify_magic_link(
        token: str,
        device_name: str = "Python SDK",
        base_url: str = "https://synauth.fly.dev",
    ) -> dict:
        """Verify a magic link token and register a new device.

        Args:
            token: The token from the magic link (email or dev mode response).
            device_name: Human-readable name for this device.
            base_url: SynAuth backend URL.

        Returns:
            Dict with 'device_id', 'account_id', 'email'.
        """
        url = f"{base_url.rstrip('/')}/api/v1/auth/verify"
        resp = requests.post(
            url,
            json={"token": token, "device_name": device_name},
            timeout=30,
        )

        if not resp.ok:
            try:
                detail = resp.json().get("detail", resp.text)
            except (ValueError, KeyError):
                detail = resp.text
            raise SynAuthAPIError(resp.status_code, detail, resp)

        return resp.json()

    # --- TOTP management ---

    def totp_setup(self) -> dict:
        """Generate a TOTP secret for this device.

        Returns a provisioning URI that can be scanned as a QR code by any
        TOTP authenticator app (Google Authenticator, Authy, Apple Passwords,
        1Password). Also returns the raw secret for manual entry.

        After setup, call totp_verify() with a code from the authenticator
        to confirm the setup is working.

        Returns:
            Dict with 'provisioning_uri', 'secret', 'issuer', 'account'.

        Raises:
            SynAuthAPIError: 409 if TOTP is already configured and verified.
        """
        return self._request("POST", "/totp/setup")

    def totp_verify(self, code: str) -> dict:
        """Verify TOTP setup with a code from the authenticator app.

        Must be called after totp_setup() to confirm the authenticator is
        correctly configured. Until verified, TOTP cannot be used for approvals.

        Args:
            code: 6-digit TOTP code from authenticator app.

        Returns:
            Dict with 'verified': True.

        Raises:
            SynAuthAPIError: 403 if code is invalid, 404 if TOTP not set up.
        """
        return self._request("POST", "/totp/verify-setup", json={"code": code})

    def totp_status(self) -> dict:
        """Check TOTP setup status for this device.

        Returns:
            Dict with 'enabled' (bool), 'pending_verification' (bool),
            'created_at' (str). If TOTP not set up: {'enabled': False}.
        """
        return self._request("GET", "/totp/status")

    def totp_delete(self) -> dict:
        """Remove TOTP configuration for this device.

        After deletion, totp_setup() must be called again to re-enable TOTP.

        Returns:
            Dict with 'deleted': True.

        Raises:
            SynAuthAPIError: 404 if TOTP not configured.
        """
        return self._request("DELETE", "/totp")

    # --- API key management ---

    def create_key(self, agent_id: str, name: str) -> dict:
        """Create an API key for the account.

        The key is returned once and cannot be retrieved again. Save it
        immediately. Use it to create a SynAuthClient for your agent.

        Args:
            agent_id: Identifier for the agent (e.g., "my-agent").
            name: Human-readable name (e.g., "Research Agent").

        Returns:
            Dict with 'key' (full key, shown once), 'key_prefix',
            'agent_id', 'name', 'account_id'.

        Raises:
            SynAuthAPIError: 403 if device not linked to account,
                429 if max keys (10) reached.
        """
        return self._request(
            "POST", "/keys",
            json={"agent_id": agent_id, "name": name},
        )

    def list_keys(self) -> dict:
        """List active API keys for the account.

        Key values are never returned — only prefixes, agent IDs, and names.

        Returns:
            Dict with 'keys' list, each containing 'key_prefix',
            'agent_id', 'name', 'created_at'.
        """
        return self._request("GET", "/keys")

    def revoke_key(self, key_prefix: str) -> dict:
        """Revoke an API key by its prefix.

        The key is deactivated (not deleted). Any agent using this key
        will immediately lose access.

        Args:
            key_prefix: The key prefix (e.g., "aa_abc12").

        Returns:
            Dict with 'revoked': True, 'key_prefix'.

        Raises:
            SynAuthAPIError: 404 if key not found or already revoked.
        """
        return self._request("DELETE", f"/keys/{key_prefix}")

    # --- Pending actions & approval ---

    def get_pending(self) -> dict:
        """List pending action requests awaiting approval.

        Returns all pending actions for this device's account. Use this
        to see what your agents are waiting for.

        Returns:
            Dict with 'actions' list of pending action requests.
        """
        return self._request("GET", "/pending")

    def approve(
        self,
        request_id: str,
        totp_code: str = None,
        content_hash: str = None,
    ) -> dict:
        """Approve a pending action request.

        For TOTP-enabled devices, provide the current TOTP code.
        For WYSIWYS actions (with content_hash), provide the matching hash.

        Args:
            request_id: The action request ID to approve.
            totp_code: 6-digit TOTP code (required if TOTP is enabled).
            content_hash: WYSIWYS content hash (required for WYSIWYS actions).

        Returns:
            Dict with 'status': 'approved', 'verified_by'.

        Raises:
            SynAuthAPIError: 403 if TOTP code invalid or content hash mismatch,
                409 if action already resolved.
        """
        body = {}
        if totp_code is not None:
            body["totp_code"] = totp_code
        if content_hash is not None:
            body["content_hash"] = content_hash
        return self._request(
            "POST", f"/actions/{request_id}/approve",
            json=body if body else None,
        )

    def deny(self, request_id: str, reason: str = None) -> dict:
        """Deny a pending action request.

        Args:
            request_id: The action request ID to deny.
            reason: Optional reason for denial.

        Returns:
            Dict with 'status': 'denied'.

        Raises:
            SynAuthAPIError: 409 if action already resolved.
        """
        body = {}
        if reason is not None:
            body["reason"] = reason
        return self._request(
            "POST", f"/actions/{request_id}/deny",
            json=body if body else None,
        )

    def get_account(self) -> dict:
        """Get account info for the current device.

        Returns:
            Dict with account details including 'linked', 'email',
            'account_id', 'device_count', 'key_count'.
        """
        return self._request("GET", "/auth/account")
