"""Tests for the SynAuth SDK client.

Mocks all HTTP calls — no real backend needed.
"""

import hashlib
import json
import time
from unittest.mock import patch, MagicMock

import pytest
import requests

import sys
import os

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))

from synauth.client import (
    SynAuthClient,
    SynAuthAdmin,
    SynAuthError,
    SynAuthAPIError,
    RateLimitError,
    ActionExpiredError,
    ActionDeniedError,
    ApprovalTimeoutError,
    VaultExecutionError,
    compute_content_hash,
)
from synauth.pay import SynPayClient


# --- Fixtures ---


@pytest.fixture
def client():
    """SynAuthClient pointing at a fake backend."""
    return SynAuthClient(api_key="aa_test_key", base_url="https://test.synauth.dev")


@pytest.fixture
def pay_client():
    """SynPayClient pointing at a fake backend."""
    return SynPayClient(api_key="aa_test_key", base_url="https://test.synauth.dev")


def _mock_response(status_code=200, json_data=None, text=""):
    """Create a mock requests.Response."""
    resp = MagicMock(spec=requests.Response)
    resp.status_code = status_code
    resp.ok = 200 <= status_code < 400
    resp.text = text or (str(json_data) if json_data else "")
    resp.json.return_value = json_data or {}
    return resp


# --- Exception hierarchy ---


class TestExceptions:
    def test_exception_hierarchy(self):
        """All exceptions inherit from SynAuthError."""
        assert issubclass(SynAuthAPIError, SynAuthError)
        assert issubclass(RateLimitError, SynAuthAPIError)
        assert issubclass(ActionExpiredError, SynAuthError)
        assert issubclass(ActionDeniedError, SynAuthError)
        assert issubclass(ApprovalTimeoutError, SynAuthError)
        assert issubclass(VaultExecutionError, SynAuthError)

    def test_rate_limit_error_details(self):
        err = RateLimitError()
        assert err.status_code == 429
        assert "Rate limit" in str(err)

    def test_action_expired_error(self):
        err = ActionExpiredError("req-123")
        assert err.request_id == "req-123"
        assert "req-123" in str(err)
        assert "expired" in str(err)

    def test_action_denied_error_with_reason(self):
        err = ActionDeniedError("req-123", reason="spending limit exceeded")
        assert err.request_id == "req-123"
        assert err.reason == "spending limit exceeded"
        assert "spending limit exceeded" in str(err)

    def test_action_denied_error_no_reason(self):
        err = ActionDeniedError("req-123")
        assert err.reason is None
        assert "req-123" in str(err)

    def test_approval_timeout_error(self):
        err = ApprovalTimeoutError("req-456", 120)
        assert err.request_id == "req-456"
        assert err.timeout == 120
        assert "120s" in str(err)
        assert "req-456" in str(err)
        assert "still pending" in str(err)

    def test_vault_execution_error(self):
        err = VaultExecutionError("host not allowed")
        assert err.detail == "host not allowed"
        assert "host not allowed" in str(err)

    def test_api_error_with_response(self):
        resp = _mock_response(500, text="Internal Server Error")
        err = SynAuthAPIError(500, "Internal Server Error", resp)
        assert err.status_code == 500
        assert err.response is resp


# --- Client init ---


class TestClientInit:
    def test_sets_api_key_header(self):
        c = SynAuthClient(api_key="aa_xyz")
        assert c.session.headers["X-API-Key"] == "aa_xyz"

    def test_strips_trailing_slash(self):
        c = SynAuthClient(api_key="aa_xyz", base_url="https://example.com/")
        assert c.base_url == "https://example.com"

    def test_default_base_url(self):
        c = SynAuthClient(api_key="aa_xyz")
        assert c.base_url == "https://synauth.fly.dev"

    def test_api_version(self):
        assert SynAuthClient.API_VERSION == "v1"


# --- _request error handling ---


class TestRequestErrorHandling:
    def test_rate_limit_raises_rate_limit_error(self, client):
        with patch.object(client.session, "request", return_value=_mock_response(429)):
            with pytest.raises(RateLimitError):
                client._request("GET", "/test")

    def test_server_error_raises_api_error(self, client):
        resp = _mock_response(500, json_data={"detail": "Internal Server Error"})
        with patch.object(client.session, "request", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                client._request("GET", "/test")
            assert exc_info.value.status_code == 500
            assert exc_info.value.detail == "Internal Server Error"

    def test_error_with_non_json_body(self, client):
        resp = _mock_response(502, text="Bad Gateway")
        resp.json.side_effect = ValueError("No JSON")
        with patch.object(client.session, "request", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                client._request("GET", "/test")
            assert exc_info.value.detail == "Bad Gateway"

    def test_success_returns_json(self, client):
        resp = _mock_response(200, json_data={"status": "ok"})
        with patch.object(client.session, "request", return_value=resp):
            result = client._request("GET", "/test")
        assert result == {"status": "ok"}

    def test_url_construction(self, client):
        resp = _mock_response(200, json_data={})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client._request("POST", "/actions")
        mock_req.assert_called_once()
        call_args = mock_req.call_args
        assert call_args[0] == ("POST", "https://test.synauth.dev/api/v1/actions")
        assert call_args[1]["timeout"] == 30


# --- request_action ---


class TestRequestAction:
    def test_minimal_payload(self, client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            result = client.request_action(action_type="communication", title="Test")
        assert result["id"] == "req-1"
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "communication"
        assert payload["title"] == "Test"
        assert payload["risk_level"] == "medium"
        assert payload["reversible"] is True
        assert payload["expires_in_seconds"] == 300

    def test_full_payload(self, client):
        resp = _mock_response(200, json_data={"id": "req-2", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_action(
                action_type="purchase",
                title="Buy stuff",
                description="Cloud credits",
                risk_level="high",
                reversible=False,
                amount=99.99,
                currency="EUR",
                recipient="vendor@example.com",
                metadata={"invoice": "INV-001"},
                expires_in_seconds=60,
                callback_url="https://example.com/webhook",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["amount"] == 99.99
        assert payload["currency"] == "EUR"
        assert payload["recipient"] == "vendor@example.com"
        assert payload["metadata"] == {"invoice": "INV-001"}
        assert payload["callback_url"] == "https://example.com/webhook"
        assert payload["description"] == "Cloud credits"

    def test_amount_zero_included(self, client):
        """amount=0 should still be included in payload (not falsy-skipped)."""
        resp = _mock_response(200, json_data={"id": "req-3", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_action(action_type="purchase", title="Free trial", amount=0)
        payload = mock_req.call_args[1]["json"]
        assert "amount" in payload
        assert payload["amount"] == 0


# --- get_status ---


class TestGetStatus:
    def test_returns_status(self, client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "approved"})
        with patch.object(client.session, "request", return_value=resp):
            result = client.get_status("req-1")
        assert result["status"] == "approved"


# --- wait_for_result ---


class TestWaitForResult:
    def test_returns_immediately_if_resolved(self, client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "approved"})
        with patch.object(client.session, "request", return_value=resp):
            with patch("synauth.client.time.sleep") as mock_sleep:
                result = client.wait_for_result("req-1")
        assert result["status"] == "approved"
        mock_sleep.assert_not_called()

    def test_polls_until_resolved(self, client):
        pending = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        approved = _mock_response(200, json_data={"id": "req-1", "status": "approved"})
        with patch.object(client.session, "request", side_effect=[pending, approved]):
            with patch("synauth.client.time.sleep"):
                result = client.wait_for_result("req-1", timeout=10)
        assert result["status"] == "approved"

    def test_returns_final_status_on_timeout(self, client):
        """After timeout, makes one final status check and returns it."""
        pending = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        expired = _mock_response(200, json_data={"id": "req-1", "status": "expired"})

        # Simulate time passing beyond timeout
        call_count = 0

        def mock_time():
            nonlocal call_count
            call_count += 1
            # First call (start): 0, second call (loop check): beyond timeout
            if call_count <= 1:
                return 0.0
            return 999.0

        with patch.object(client.session, "request", side_effect=[expired]):
            with patch("synauth.client.time.time", side_effect=mock_time):
                with patch("synauth.client.time.sleep"):
                    result = client.wait_for_result("req-1", timeout=5)
        assert result["status"] == "expired"


# --- get_history ---


class TestGetHistory:
    def test_default_params(self, client):
        resp = _mock_response(200, json_data={"actions": []})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.get_history()
        call_kwargs = mock_req.call_args[1]
        assert call_kwargs["params"]["limit"] == 50

    def test_filters(self, client):
        resp = _mock_response(200, json_data={"actions": []})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.get_history(limit=10, status="approved", action_type="purchase")
        params = mock_req.call_args[1]["params"]
        assert params["limit"] == 10
        assert params["status"] == "approved"
        assert params["action_type"] == "purchase"


# --- get_spending_summary ---


class TestGetSpendingSummary:
    def test_calls_correct_endpoint(self, client):
        resp = _mock_response(200, json_data={"agent_id": "agent-1", "summaries": []})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            result = client.get_spending_summary()
        assert result["agent_id"] == "agent-1"
        url = mock_req.call_args[0][1]
        assert "/agent/spending-summary" in url


# --- list_vault_services ---


class TestListVaultServices:
    def test_returns_services(self, client):
        resp = _mock_response(200, json_data={"services": [{"service_name": "openai"}]})
        with patch.object(client.session, "request", return_value=resp):
            result = client.list_vault_services()
        assert len(result["services"]) == 1


# --- execute_api_call ---


class TestExecuteApiCall:
    def test_happy_path(self, client):
        """Approved immediately → vault execute → returns result."""
        create_resp = _mock_response(200, json_data={"id": "req-1", "status": "approved"})
        exec_resp = _mock_response(200, json_data={"status_code": 200, "body": '{"ok":true}'})
        with patch.object(client.session, "request", side_effect=[create_resp, exec_resp]):
            result = client.execute_api_call(
                service_name="openai", method="POST", url="https://api.openai.com/v1/chat"
            )
        assert result["status_code"] == 200

    def test_auto_denied_raises(self, client):
        """Auto-denied by rules → ActionDeniedError immediately."""
        resp = _mock_response(200, json_data={
            "id": "req-1", "status": "denied", "deny_reason": "blocked by rule"
        })
        with patch.object(client.session, "request", return_value=resp):
            with pytest.raises(ActionDeniedError) as exc_info:
                client.execute_api_call(
                    service_name="openai", method="GET", url="https://api.openai.com/"
                )
            assert exc_info.value.reason == "blocked by rule"

    def test_pending_then_approved(self, client):
        """Pending → poll → approved → vault execute."""
        create_resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        poll_pending = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        poll_approved = _mock_response(200, json_data={"id": "req-1", "status": "approved"})
        exec_resp = _mock_response(200, json_data={"status_code": 200, "body": "ok"})

        with patch.object(client.session, "request",
                          side_effect=[create_resp, poll_pending, poll_approved, exec_resp]):
            with patch("synauth.client.time.sleep"):
                result = client.execute_api_call(
                    service_name="openai", method="POST",
                    url="https://api.openai.com/v1/chat", timeout=60,
                )
        assert result["status_code"] == 200

    def test_pending_then_denied(self, client):
        """Pending → poll → denied → ActionDeniedError."""
        create_resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        poll_denied = _mock_response(200, json_data={
            "id": "req-1", "status": "denied", "deny_reason": "user denied"
        })
        with patch.object(client.session, "request",
                          side_effect=[create_resp, poll_denied]):
            with patch("synauth.client.time.sleep"):
                with pytest.raises(ActionDeniedError):
                    client.execute_api_call(
                        service_name="openai", method="GET", url="https://api.openai.com/"
                    )

    def test_pending_then_expired(self, client):
        """Pending → poll → expired → ActionExpiredError."""
        create_resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        poll_expired = _mock_response(200, json_data={"id": "req-1", "status": "expired"})
        with patch.object(client.session, "request",
                          side_effect=[create_resp, poll_expired]):
            with patch("synauth.client.time.sleep"):
                with pytest.raises(ActionExpiredError):
                    client.execute_api_call(
                        service_name="openai", method="GET", url="https://api.openai.com/"
                    )

    def test_timeout_raises_approval_timeout_error(self, client):
        """Polling times out → ApprovalTimeoutError (NOT VaultExecutionError)."""
        create_resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        poll_pending = _mock_response(200, json_data={"id": "req-1", "status": "pending"})

        # Simulate: first time.time() = 0 (start), then jumps past timeout
        time_values = [0.0, 0.0, 999.0]
        time_idx = [0]

        def mock_time():
            val = time_values[min(time_idx[0], len(time_values) - 1)]
            time_idx[0] += 1
            return val

        with patch.object(client.session, "request",
                          side_effect=[create_resp, poll_pending]):
            with patch("synauth.client.time.time", side_effect=mock_time):
                with patch("synauth.client.time.sleep"):
                    with pytest.raises(ApprovalTimeoutError) as exc_info:
                        client.execute_api_call(
                            service_name="openai", method="GET",
                            url="https://api.openai.com/", timeout=5,
                        )
            assert exc_info.value.request_id == "req-1"
            assert exc_info.value.timeout == 5

    def test_payload_construction(self, client):
        """Verify the action request payload includes vault metadata."""
        create_resp = _mock_response(200, json_data={"id": "req-1", "status": "approved"})
        exec_resp = _mock_response(200, json_data={"status_code": 200})
        with patch.object(client.session, "request",
                          side_effect=[create_resp, exec_resp]) as mock_req:
            client.execute_api_call(
                service_name="github",
                method="POST",
                url="https://api.github.com/repos",
                headers={"Accept": "application/json"},
                body='{"name":"test"}',
                description="Create repo",
            )
        # First call is the action creation
        payload = mock_req.call_args_list[0][1]["json"]
        assert payload["action_type"] == "data_access"
        assert payload["title"] == "Create repo"
        assert payload["metadata"]["vault_execute"] is True
        assert payload["metadata"]["service_name"] == "github"
        assert payload["metadata"]["method"] == "POST"
        assert payload["metadata"]["url"] == "https://api.github.com/repos"
        assert payload["metadata"]["headers"] == {"Accept": "application/json"}
        assert payload["metadata"]["body"] == '{"name":"test"}'

    def test_default_description_fallback(self, client):
        """When no description given, title falls back to method + url."""
        create_resp = _mock_response(200, json_data={"id": "req-1", "status": "approved"})
        exec_resp = _mock_response(200, json_data={"status_code": 200})
        with patch.object(client.session, "request",
                          side_effect=[create_resp, exec_resp]) as mock_req:
            client.execute_api_call(
                service_name="openai", method="GET", url="https://api.openai.com/v1/models",
            )
        payload = mock_req.call_args_list[0][1]["json"]
        assert payload["title"] == "API call: GET https://api.openai.com/v1/models"


# --- Convenience methods ---


class TestConvenienceMethods:
    def test_request_email(self, client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_email(
                recipient="alice@example.com",
                subject="Hello",
                preview="Preview text",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "communication"
        assert payload["risk_level"] == "low"
        assert "Hello" in payload["title"]
        assert payload["recipient"] == "alice@example.com"

    def test_request_purchase(self, client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_purchase(amount=50.0, merchant="AWS")
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "purchase"
        assert payload["amount"] == 50.0
        assert payload["risk_level"] == "medium"

    def test_request_booking(self, client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_booking(title="Book meeting room")
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "scheduling"
        assert payload["risk_level"] == "low"

    def test_request_post(self, client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_post(platform="Twitter", content_preview="Hello world")
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "social"
        assert "Twitter" in payload["title"]

    def test_request_data_access(self, client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_data_access(resource="Customer DB", reason="Quarterly report")
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "data_access"
        assert payload["risk_level"] == "high"

    def test_request_contract(self, client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_contract(title="Sign NDA", description="Standard NDA v2")
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "legal"
        assert payload["risk_level"] == "critical"
        assert payload["reversible"] is False

    def test_risk_level_override(self, client):
        """Convenience methods allow risk_level override via kwargs."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_email(
                recipient="bob@example.com",
                subject="Urgent",
                risk_level="critical",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["risk_level"] == "critical"


# --- SynPayClient ---


class TestSynPayClient:
    def test_request_payment(self, pay_client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(pay_client._auth.session, "request", return_value=resp) as mock_req:
            result = pay_client.request_payment(
                amount=29.99, merchant="OpenAI", description="API credits"
            )
        assert result["id"] == "req-1"
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "purchase"
        assert payload["amount"] == 29.99

    def test_get_status(self, pay_client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "approved"})
        with patch.object(pay_client._auth.session, "request", return_value=resp):
            result = pay_client.get_status("req-1")
        assert result["status"] == "approved"

    def test_wait_for_result(self, pay_client):
        resp = _mock_response(200, json_data={"id": "req-1", "status": "denied"})
        with patch.object(pay_client._auth.session, "request", return_value=resp):
            with patch("synauth.client.time.sleep"):
                result = pay_client.wait_for_result("req-1")
        assert result["status"] == "denied"


# --- Connection errors ---


class TestConnectionErrors:
    def test_connection_error_propagates(self, client):
        """Connection errors from requests propagate as-is (not swallowed)."""
        with patch.object(client.session, "request",
                          side_effect=requests.exceptions.ConnectionError("refused")):
            with pytest.raises(requests.exceptions.ConnectionError):
                client.request_action(action_type="communication", title="Test")

    def test_timeout_error_propagates(self, client):
        """HTTP timeout errors propagate."""
        with patch.object(client.session, "request",
                          side_effect=requests.exceptions.Timeout("timed out")):
            with pytest.raises(requests.exceptions.Timeout):
                client.get_status("req-1")


# --- compute_content_hash ---


class TestComputeContentHash:
    def test_deterministic(self):
        """Same params always produce the same hash."""
        params = {"to": "alice@example.com", "subject": "Hello"}
        assert compute_content_hash(params) == compute_content_hash(params)

    def test_different_params_different_hash(self):
        """Different params produce different hashes."""
        h1 = compute_content_hash({"to": "alice@example.com"})
        h2 = compute_content_hash({"to": "bob@example.com"})
        assert h1 != h2

    def test_key_order_irrelevant(self):
        """Key order doesn't affect hash (sorted keys)."""
        h1 = compute_content_hash({"b": 2, "a": 1})
        h2 = compute_content_hash({"a": 1, "b": 2})
        assert h1 == h2

    def test_matches_manual_computation(self):
        """Hash matches manual SHA-256 of canonical JSON."""
        params = {"method": "POST", "url": "https://api.example.com"}
        canonical = json.dumps(params, sort_keys=True, separators=(",", ":"))
        expected = hashlib.sha256(canonical.encode()).hexdigest()
        assert compute_content_hash(params) == expected

    def test_returns_hex_string(self):
        """Hash is a 64-character hex string."""
        result = compute_content_hash({"key": "value"})
        assert len(result) == 64
        assert all(c in "0123456789abcdef" for c in result)

    def test_nested_params(self):
        """Works with nested dicts and arrays."""
        params = {
            "headers": {"Content-Type": "application/json"},
            "tags": ["a", "b"],
        }
        result = compute_content_hash(params)
        assert len(result) == 64

    def test_importable_from_package(self):
        """compute_content_hash is importable from the synauth package."""
        from synauth import compute_content_hash as cch
        assert callable(cch)


# --- request_action with execution_params ---


class TestRequestActionWYSIWYS:
    def test_execution_params_in_payload(self, client):
        """execution_params are included in the request payload."""
        resp = _mock_response(200, json_data={
            "id": "req-1", "status": "pending",
            "content_hash": "abc123", "rendered_display": "To: alice",
            "wysiwys": True,
        })
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            result = client.request_action(
                action_type="communication",
                title="Send email",
                execution_params={"to": "alice@example.com", "subject": "Hello"},
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["execution_params"] == {"to": "alice@example.com", "subject": "Hello"}
        assert result["content_hash"] == "abc123"
        assert result["wysiwys"] is True

    def test_no_execution_params_omitted(self, client):
        """When execution_params is None, it's not in the payload."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_action(action_type="communication", title="Test")
        payload = mock_req.call_args[1]["json"]
        assert "execution_params" not in payload

    def test_backward_compatible(self, client):
        """Old-style calls without execution_params still work."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.request_action(
                action_type="purchase", title="Buy stuff",
                amount=99.99, recipient="vendor",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["amount"] == 99.99
        assert "execution_params" not in payload


# --- WYSIWYS convenience methods ---


class TestWYSIWYSAction:
    def test_basic_payload(self, client):
        """wysiwys_action sends execution_params with correct structure."""
        resp = _mock_response(200, json_data={
            "id": "req-1", "status": "pending",
            "content_hash": "abc", "wysiwys": True,
        })
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            result = client.wysiwys_action(
                action_type="deployment",
                params={"environment": "production", "version": "1.2.3"},
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "deployment"
        assert payload["execution_params"] == {"environment": "production", "version": "1.2.3"}
        assert payload["risk_level"] == "medium"
        assert payload["title"] == "deployment action"

    def test_custom_title(self, client):
        """Title can be overridden via kwargs."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_action(
                action_type="deploy",
                params={"env": "prod"},
                title="Deploy to production",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["title"] == "Deploy to production"

    def test_risk_level_override(self, client):
        """Risk level can be overridden."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_action(
                action_type="deploy", params={"env": "prod"},
                risk_level="critical",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["risk_level"] == "critical"

    def test_passthrough_kwargs(self, client):
        """Extra kwargs are passed through to request_action."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_action(
                action_type="deploy", params={"env": "prod"},
                callback_url="https://example.com/hook",
                expires_in_seconds=60,
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["callback_url"] == "https://example.com/hook"
        assert payload["expires_in_seconds"] == 60


class TestWYSIWYSEmail:
    def test_email_payload(self, client):
        """wysiwys_email structures params correctly."""
        resp = _mock_response(200, json_data={
            "id": "req-1", "status": "pending",
            "content_hash": "abc", "wysiwys": True,
        })
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_email(
                to="alice@example.com",
                subject="Meeting Tomorrow",
                body="Let's meet at 2pm.",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "communication"
        assert payload["execution_params"] == {
            "to": "alice@example.com",
            "subject": "Meeting Tomorrow",
            "body": "Let's meet at 2pm.",
        }
        assert payload["risk_level"] == "low"
        assert "Meeting Tomorrow" in payload["title"]

    def test_email_without_body(self, client):
        """Body is optional — omitted from params when None."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_email(to="bob@example.com", subject="Quick note")
        payload = mock_req.call_args[1]["json"]
        assert payload["execution_params"] == {
            "to": "bob@example.com",
            "subject": "Quick note",
        }
        assert "body" not in payload["execution_params"]

    def test_email_risk_override(self, client):
        """Risk level can be overridden for sensitive emails."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_email(
                to="ceo@company.com", subject="Board memo",
                risk_level="critical",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["risk_level"] == "critical"

    def test_email_content_hash_matches(self, client):
        """Content hash from wysiwys_email matches compute_content_hash."""
        params = {"to": "alice@example.com", "subject": "Test"}
        expected_hash = compute_content_hash(params)
        resp = _mock_response(200, json_data={
            "id": "req-1", "status": "pending",
            "content_hash": expected_hash, "wysiwys": True,
        })
        with patch.object(client.session, "request", return_value=resp):
            result = client.wysiwys_email(to="alice@example.com", subject="Test")
        assert result["content_hash"] == expected_hash


class TestWYSIWYSPayment:
    def test_payment_payload(self, client):
        """wysiwys_payment structures params correctly."""
        resp = _mock_response(200, json_data={
            "id": "req-1", "status": "pending",
            "content_hash": "abc", "wysiwys": True,
        })
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_payment(
                recipient="Acme Corp",
                amount=250.00,
                currency="EUR",
                memo="Invoice #1234",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "purchase"
        assert payload["execution_params"] == {
            "recipient": "Acme Corp",
            "amount": 250.00,
            "currency": "EUR",
            "memo": "Invoice #1234",
        }
        assert payload["risk_level"] == "medium"
        assert "Acme Corp" in payload["title"]
        assert "250.0" in payload["title"]

    def test_payment_defaults(self, client):
        """Currency defaults to USD, memo is optional."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_payment(recipient="AWS", amount=50.0)
        payload = mock_req.call_args[1]["json"]
        assert payload["execution_params"]["currency"] == "USD"
        assert "memo" not in payload["execution_params"]

    def test_payment_zero_amount(self, client):
        """Zero amount is valid (e.g., free trial activation)."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_payment(recipient="Service", amount=0)
        payload = mock_req.call_args[1]["json"]
        assert payload["execution_params"]["amount"] == 0


class TestWYSIWYSHttp:
    def test_http_payload(self, client):
        """wysiwys_http structures params correctly."""
        resp = _mock_response(200, json_data={
            "id": "req-1", "status": "pending",
            "content_hash": "abc", "wysiwys": True,
        })
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_http(
                method="POST",
                url="https://api.openai.com/v1/chat/completions",
                headers={"Content-Type": "application/json"},
                body={"model": "gpt-4", "messages": []},
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["action_type"] == "data_access"
        assert payload["execution_params"] == {
            "method": "POST",
            "url": "https://api.openai.com/v1/chat/completions",
            "headers": {"Content-Type": "application/json"},
            "body": {"model": "gpt-4", "messages": []},
        }
        assert payload["risk_level"] == "high"
        assert "POST" in payload["title"]

    def test_http_minimal(self, client):
        """Headers and body are optional."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_http(method="GET", url="https://api.example.com/status")
        payload = mock_req.call_args[1]["json"]
        assert payload["execution_params"] == {
            "method": "GET",
            "url": "https://api.example.com/status",
        }
        assert "headers" not in payload["execution_params"]
        assert "body" not in payload["execution_params"]

    def test_http_with_auth_header(self, client):
        """Auth headers are included in params (backend redacts in display)."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_http(
                method="GET",
                url="https://api.example.com/data",
                headers={"Authorization": "Bearer sk-secret"},
            )
        payload = mock_req.call_args[1]["json"]
        # Auth header is in execution_params (backend handles redaction)
        assert payload["execution_params"]["headers"]["Authorization"] == "Bearer sk-secret"

    def test_http_risk_override(self, client):
        """Risk level defaults to high but can be lowered."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_http(
                method="GET", url="https://example.com/public",
                risk_level="low",
            )
        payload = mock_req.call_args[1]["json"]
        assert payload["risk_level"] == "low"


# --- Service-specific WYSIWYS methods ---


class TestWYSIWYSStripe:
    def test_stripe_payment_payload(self, client):
        """Stripe payment constructs correct HTTP-shaped params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending", "wysiwys": True})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_stripe_payment(
                amount=4999, currency="usd", customer="cus_Nk8J",
                description="Invoice #1042", payment_method="pm_card_visa",
            )
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert params["method"] == "POST"
        assert params["url"] == "https://api.stripe.com/v1/payment_intents"
        assert params["body"]["amount"] == 4999
        assert params["body"]["currency"] == "usd"
        assert params["body"]["customer"] == "cus_Nk8J"
        assert params["body"]["description"] == "Invoice #1042"
        assert params["body"]["payment_method"] == "pm_card_visa"
        assert payload["risk_level"] == "high"

    def test_stripe_payment_minimal(self, client):
        """Amount and currency are required, rest is optional."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_stripe_payment(amount=500)
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert params["body"]["amount"] == 500
        assert params["body"]["currency"] == "usd"
        assert "customer" not in params["body"]
        assert "description" not in params["body"]

    def test_stripe_refund_payload(self, client):
        """Stripe refund constructs correct params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_stripe_refund(charge="ch_abc", amount=2500, reason="duplicate")
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert "refunds" in params["url"]
        assert params["body"]["charge"] == "ch_abc"
        assert params["body"]["amount"] == 2500
        assert params["body"]["reason"] == "duplicate"


class TestWYSIWYSOpenAI:
    def test_openai_chat_payload(self, client):
        """OpenAI chat constructs correct HTTP-shaped params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_openai_chat(
                model="gpt-4o",
                messages=[{"role": "user", "content": "Hello"}],
                max_tokens=2000,
                temperature=0.7,
            )
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert params["url"] == "https://api.openai.com/v1/chat/completions"
        assert params["body"]["model"] == "gpt-4o"
        assert params["body"]["messages"] == [{"role": "user", "content": "Hello"}]
        assert params["body"]["max_tokens"] == 2000
        assert params["body"]["temperature"] == 0.7
        assert payload["risk_level"] == "medium"

    def test_openai_image_payload(self, client):
        """OpenAI image generation constructs correct params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_openai_image(prompt="A sunset", n=2)
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert "images/generations" in params["url"]
        assert params["body"]["prompt"] == "A sunset"
        assert params["body"]["n"] == 2
        assert params["body"]["model"] == "dall-e-3"


class TestWYSIWYSAnthropic:
    def test_anthropic_message_payload(self, client):
        """Anthropic message constructs correct HTTP-shaped params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_anthropic_message(
                model="claude-sonnet-4-20250514",
                messages=[{"role": "user", "content": "Analyze this"}],
                max_tokens=4096,
                system="You are a legal analyst.",
            )
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert params["url"] == "https://api.anthropic.com/v1/messages"
        assert params["body"]["model"] == "claude-sonnet-4-20250514"
        assert params["body"]["messages"] == [{"role": "user", "content": "Analyze this"}]
        assert params["body"]["max_tokens"] == 4096
        assert params["body"]["system"] == "You are a legal analyst."
        assert payload["risk_level"] == "medium"

    def test_anthropic_minimal(self, client):
        """Model and messages required, system is optional."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_anthropic_message(
                model="claude-sonnet-4-20250514",
                messages=[{"role": "user", "content": "Hi"}],
            )
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert params["body"]["max_tokens"] == 1024  # default
        assert "system" not in params["body"]


class TestWYSIWYSGitHub:
    def test_github_issue_payload(self, client):
        """GitHub issue constructs correct HTTP-shaped params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_github_issue(
                repo="acme/webapp", title="Bug: login 500",
                body="Steps to reproduce...", labels=["bug", "p1"],
            )
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert params["url"] == "https://api.github.com/repos/acme/webapp/issues"
        assert params["body"]["title"] == "Bug: login 500"
        assert params["body"]["body"] == "Steps to reproduce..."
        assert params["body"]["labels"] == ["bug", "p1"]

    def test_github_pr_payload(self, client):
        """GitHub PR constructs correct HTTP-shaped params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_github_pr(
                repo="acme/webapp", title="Fix auth", head="fix-auth",
            )
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert "pulls" in params["url"]
        assert params["body"]["head"] == "fix-auth"
        assert params["body"]["base"] == "main"


class TestWYSIWYSSlack:
    def test_slack_message_payload(self, client):
        """Slack message constructs correct HTTP-shaped params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_slack_message(channel="#deployments", text="v1.2.3 deployed")
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert "chat.postMessage" in params["url"]
        assert params["body"]["channel"] == "#deployments"
        assert params["body"]["text"] == "v1.2.3 deployed"
        assert payload["risk_level"] == "low"


class TestWYSIWYSSendGrid:
    def test_sendgrid_email_payload(self, client):
        """SendGrid email constructs correct HTTP-shaped params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_sendgrid_email(
                to="alice@acme.com", subject="Weekly Report",
                content="Hi team...", from_email="reports@company.com",
            )
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert "mail/send" in params["url"]
        body = params["body"]
        assert body["personalizations"][0]["to"] == [{"email": "alice@acme.com"}]
        assert body["personalizations"][0]["subject"] == "Weekly Report"
        assert body["from"]["email"] == "reports@company.com"
        assert body["content"][0]["value"] == "Hi team..."

    def test_sendgrid_minimal(self, client):
        """To and subject required, rest optional."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_sendgrid_email(to="alice@acme.com", subject="Test")
        payload = mock_req.call_args[1]["json"]
        body = payload["execution_params"]["body"]
        assert "from" not in body
        assert "content" not in body


class TestWYSIWYSTwilio:
    def test_twilio_sms_payload(self, client):
        """Twilio SMS constructs correct HTTP-shaped params."""
        resp = _mock_response(200, json_data={"id": "req-1", "status": "pending"})
        with patch.object(client.session, "request", return_value=resp) as mock_req:
            client.wysiwys_twilio_sms(
                to="+15551234567", from_number="+15559876543",
                body="Your order shipped!",
            )
        payload = mock_req.call_args[1]["json"]
        params = payload["execution_params"]
        assert "Messages" in params["url"]
        assert params["body"]["To"] == "+15551234567"
        assert params["body"]["From"] == "+15559876543"
        assert params["body"]["Body"] == "Your order shipped!"
        assert payload["risk_level"] == "medium"


# =============================================================================
# SynAuthAdmin tests — device-authenticated operations
# =============================================================================


@pytest.fixture
def admin():
    """SynAuthAdmin pointing at a fake backend."""
    return SynAuthAdmin(device_id="dev_test123", base_url="https://test.synauth.dev")


# --- Admin init ---


class TestAdminInit:
    def test_sets_device_id_header(self):
        a = SynAuthAdmin(device_id="dev_xyz")
        assert a.session.headers["X-Device-Id"] == "dev_xyz"

    def test_strips_trailing_slash(self):
        a = SynAuthAdmin(device_id="dev_xyz", base_url="https://example.com/")
        assert a.base_url == "https://example.com"

    def test_default_base_url(self):
        a = SynAuthAdmin(device_id="dev_xyz")
        assert a.base_url == "https://synauth.fly.dev"

    def test_api_version(self):
        assert SynAuthAdmin.API_VERSION == "v1"


# --- Admin _request error handling ---


class TestAdminRequestErrors:
    def test_rate_limit_raises(self, admin):
        with patch.object(admin.session, "request", return_value=_mock_response(429)):
            with pytest.raises(RateLimitError):
                admin._request("GET", "/test")

    def test_server_error_raises(self, admin):
        resp = _mock_response(500, json_data={"detail": "Internal Server Error"})
        with patch.object(admin.session, "request", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                admin._request("GET", "/test")
            assert exc_info.value.status_code == 500

    def test_url_construction(self, admin):
        resp = _mock_response(200, json_data={})
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            admin._request("POST", "/totp/setup")
        call_args = mock_req.call_args
        assert call_args[0] == ("POST", "https://test.synauth.dev/api/v1/totp/setup")


# --- Static auth methods ---


class TestAdminMagicLink:
    def test_request_magic_link(self):
        resp = _mock_response(200, json_data={"sent": True, "email": "test@example.com", "token": "tok_abc"})
        with patch("synauth.client.requests.post", return_value=resp) as mock_post:
            result = SynAuthAdmin.request_magic_link(
                "test@example.com", base_url="https://test.synauth.dev"
            )
        assert result["sent"] is True
        assert result["token"] == "tok_abc"
        mock_post.assert_called_once()
        call_args = mock_post.call_args
        assert "auth/magic-link" in call_args[0][0]
        assert call_args[1]["json"]["email"] == "test@example.com"

    def test_request_magic_link_error(self):
        resp = _mock_response(400, json_data={"detail": "Invalid email"})
        with patch("synauth.client.requests.post", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                SynAuthAdmin.request_magic_link("bad")
            assert exc_info.value.status_code == 400

    def test_verify_magic_link(self):
        resp = _mock_response(200, json_data={
            "device_id": "dev_new123", "account_id": "acct_xyz", "email": "test@example.com"
        })
        with patch("synauth.client.requests.post", return_value=resp) as mock_post:
            result = SynAuthAdmin.verify_magic_link(
                "tok_abc", "My Laptop", base_url="https://test.synauth.dev"
            )
        assert result["device_id"] == "dev_new123"
        assert result["account_id"] == "acct_xyz"
        call_args = mock_post.call_args
        assert "auth/verify" in call_args[0][0]
        assert call_args[1]["json"]["token"] == "tok_abc"
        assert call_args[1]["json"]["device_name"] == "My Laptop"

    def test_verify_magic_link_expired(self):
        resp = _mock_response(410, json_data={"detail": "Link expired"})
        with patch("synauth.client.requests.post", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                SynAuthAdmin.verify_magic_link("old_token")
            assert exc_info.value.status_code == 410


# --- TOTP management ---


class TestAdminTOTP:
    def test_totp_setup(self, admin):
        resp = _mock_response(200, json_data={
            "provisioning_uri": "otpauth://totp/SynAuth:dev_test123?secret=ABC&issuer=SynAuth",
            "secret": "ABC",
            "issuer": "SynAuth",
            "account": "dev_test123",
        })
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.totp_setup()
        assert result["secret"] == "ABC"
        assert "provisioning_uri" in result
        call_args = mock_req.call_args
        assert call_args[0] == ("POST", "https://test.synauth.dev/api/v1/totp/setup")

    def test_totp_setup_already_configured(self, admin):
        resp = _mock_response(409, json_data={"detail": "TOTP already configured"})
        with patch.object(admin.session, "request", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                admin.totp_setup()
            assert exc_info.value.status_code == 409

    def test_totp_verify(self, admin):
        resp = _mock_response(200, json_data={"verified": True})
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.totp_verify("123456")
        assert result["verified"] is True
        call_args = mock_req.call_args
        assert call_args[0] == ("POST", "https://test.synauth.dev/api/v1/totp/verify-setup")
        assert call_args[1]["json"]["code"] == "123456"

    def test_totp_verify_invalid_code(self, admin):
        resp = _mock_response(403, json_data={"detail": "Invalid TOTP code"})
        with patch.object(admin.session, "request", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                admin.totp_verify("000000")
            assert exc_info.value.status_code == 403

    def test_totp_status_enabled(self, admin):
        resp = _mock_response(200, json_data={
            "enabled": True, "pending_verification": False, "created_at": "2026-02-25T00:00:00Z"
        })
        with patch.object(admin.session, "request", return_value=resp):
            result = admin.totp_status()
        assert result["enabled"] is True
        assert result["pending_verification"] is False

    def test_totp_status_not_configured(self, admin):
        resp = _mock_response(200, json_data={"enabled": False})
        with patch.object(admin.session, "request", return_value=resp):
            result = admin.totp_status()
        assert result["enabled"] is False

    def test_totp_delete(self, admin):
        resp = _mock_response(200, json_data={"deleted": True})
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.totp_delete()
        assert result["deleted"] is True
        call_args = mock_req.call_args
        assert call_args[0] == ("DELETE", "https://test.synauth.dev/api/v1/totp")

    def test_totp_delete_not_configured(self, admin):
        resp = _mock_response(404, json_data={"detail": "TOTP not configured"})
        with patch.object(admin.session, "request", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                admin.totp_delete()
            assert exc_info.value.status_code == 404


# --- API key management ---


class TestAdminKeys:
    def test_create_key(self, admin):
        resp = _mock_response(200, json_data={
            "key": "aa_abc123def456",
            "key_prefix": "aa_abc12",
            "agent_id": "my-agent",
            "name": "My Agent",
            "account_id": "acct_xyz",
        })
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.create_key("my-agent", "My Agent")
        assert result["key"] == "aa_abc123def456"
        assert result["agent_id"] == "my-agent"
        call_args = mock_req.call_args
        assert call_args[0] == ("POST", "https://test.synauth.dev/api/v1/keys")
        assert call_args[1]["json"]["agent_id"] == "my-agent"
        assert call_args[1]["json"]["name"] == "My Agent"

    def test_create_key_max_reached(self, admin):
        resp = _mock_response(429, json_data={"detail": "Maximum 10 active API keys"})
        with patch.object(admin.session, "request", return_value=resp):
            with pytest.raises(RateLimitError):
                admin.create_key("agent-11", "Eleventh Agent")

    def test_list_keys(self, admin):
        resp = _mock_response(200, json_data={
            "keys": [
                {"key_prefix": "aa_abc12", "agent_id": "my-agent", "name": "My Agent", "created_at": "2026-02-25T00:00:00Z"},
            ]
        })
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.list_keys()
        assert len(result["keys"]) == 1
        assert result["keys"][0]["agent_id"] == "my-agent"
        call_args = mock_req.call_args
        assert call_args[0] == ("GET", "https://test.synauth.dev/api/v1/keys")

    def test_revoke_key(self, admin):
        resp = _mock_response(200, json_data={"revoked": True, "key_prefix": "aa_abc12"})
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.revoke_key("aa_abc12")
        assert result["revoked"] is True
        call_args = mock_req.call_args
        assert call_args[0] == ("DELETE", "https://test.synauth.dev/api/v1/keys/aa_abc12")

    def test_revoke_key_not_found(self, admin):
        resp = _mock_response(404, json_data={"detail": "Key not found or already revoked"})
        with patch.object(admin.session, "request", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                admin.revoke_key("aa_bad00")
            assert exc_info.value.status_code == 404


# --- Pending actions & approval ---


class TestAdminApproval:
    def test_get_pending(self, admin):
        resp = _mock_response(200, json_data={
            "actions": [{"id": "req-1", "title": "Send email", "status": "pending"}]
        })
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.get_pending()
        assert len(result["actions"]) == 1
        call_args = mock_req.call_args
        assert call_args[0] == ("GET", "https://test.synauth.dev/api/v1/pending")

    def test_approve_with_totp(self, admin):
        resp = _mock_response(200, json_data={"status": "approved", "verified_by": "totp"})
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.approve("req-1", totp_code="123456")
        assert result["status"] == "approved"
        assert result["verified_by"] == "totp"
        call_args = mock_req.call_args
        assert call_args[0] == ("POST", "https://test.synauth.dev/api/v1/actions/req-1/approve")
        assert call_args[1]["json"]["totp_code"] == "123456"

    def test_approve_with_content_hash(self, admin):
        resp = _mock_response(200, json_data={"status": "approved", "verified_by": "totp"})
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            admin.approve("req-1", totp_code="123456", content_hash="abc123")
        call_args = mock_req.call_args
        body = call_args[1]["json"]
        assert body["totp_code"] == "123456"
        assert body["content_hash"] == "abc123"

    def test_approve_without_totp(self, admin):
        """Approve without TOTP (Face ID or no verification)."""
        resp = _mock_response(200, json_data={"status": "approved", "verified_by": "face_id"})
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.approve("req-1")
        assert result["verified_by"] == "face_id"
        # No body when no totp_code or content_hash
        call_args = mock_req.call_args
        assert call_args[1]["json"] is None

    def test_approve_invalid_totp(self, admin):
        resp = _mock_response(403, json_data={"detail": "Invalid TOTP code"})
        with patch.object(admin.session, "request", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                admin.approve("req-1", totp_code="000000")
            assert exc_info.value.status_code == 403

    def test_approve_already_resolved(self, admin):
        resp = _mock_response(409, json_data={"detail": "Already approved"})
        with patch.object(admin.session, "request", return_value=resp):
            with pytest.raises(SynAuthAPIError) as exc_info:
                admin.approve("req-1", totp_code="123456")
            assert exc_info.value.status_code == 409

    def test_deny(self, admin):
        resp = _mock_response(200, json_data={"status": "denied"})
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.deny("req-1", reason="Not authorized")
        assert result["status"] == "denied"
        call_args = mock_req.call_args
        assert call_args[0] == ("POST", "https://test.synauth.dev/api/v1/actions/req-1/deny")
        assert call_args[1]["json"]["reason"] == "Not authorized"

    def test_deny_without_reason(self, admin):
        resp = _mock_response(200, json_data={"status": "denied"})
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            admin.deny("req-1")
        call_args = mock_req.call_args
        assert call_args[1]["json"] is None


# --- Account info ---


class TestAdminAccount:
    def test_get_account(self, admin):
        resp = _mock_response(200, json_data={
            "linked": True, "email": "test@example.com",
            "account_id": "acct_xyz", "device_count": 1, "key_count": 2,
        })
        with patch.object(admin.session, "request", return_value=resp) as mock_req:
            result = admin.get_account()
        assert result["linked"] is True
        assert result["email"] == "test@example.com"
        call_args = mock_req.call_args
        assert call_args[0] == ("GET", "https://test.synauth.dev/api/v1/auth/account")
