"""Tests for the SynAuth SDK client.

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

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,
    SynAuthError,
    SynAuthAPIError,
    RateLimitError,
    ActionExpiredError,
    ActionDeniedError,
    ApprovalTimeoutError,
    VaultExecutionError,
)
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")
