"""Unit tests for the exchanges resource — sync and async, success and error paths."""

from __future__ import annotations

import json

import httpx
import pytest

from rulebook import (
    AsyncRulebook,
    Rulebook,
    AuthenticationError,
    NotFoundError,
    PermissionDeniedError,
    InternalServerError,
    UnprocessableEntityError,
)
from rulebook._response import APIResponse
from rulebook.types import Exchange, ExchangeDetail, DateRange

BASE_URL = "https://api.rulebookcompany.com/api/v1"

# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------

EXCHANGE_LIST_DATA = [
    {
        "name": "CBOE",
        "display_name": "CBOE US Options",
        "fee_types": ["Option"],
        "record_count": 150,
    },
    {
        "name": "NYSE",
        "display_name": "New York Stock Exchange",
        "fee_types": ["Equity", "Option"],
        "record_count": 320,
    },
]

EXCHANGE_DETAIL_DATA = {
    "name": "NYSE",
    "display_name": "New York Stock Exchange",
    "date_range": {"earliest": "2024-01-15", "latest": "2025-06-01"},
    "fee_types": ["Equity", "Option"],
    "fee_categories": ["Fee And Rebates", "Legal Regulatory Fees"],
    "actions": ["Make", "Take", "Open"],
    "participants": ["Market Maker", "Customer"],
    "symbol_classifications": ["ETF", "Equity"],
    "symbol_types": ["Penny", "Non Penny"],
    "trade_types": ["Simple Order", "Complex Order"],
    "record_count": 320,
}


def _envelope(data):
    """Wrap data in the standard API response envelope."""
    return json.dumps({"data": data, "meta": {"record_count": 1, "query_time_ms": 5}})


def _error_body(code: str, message: str):
    """Build an error response body matching the API error envelope."""
    return json.dumps({"error": {"code": code, "message": message}, "meta": {}})


# ===================================================================
# Sync — exchanges.list()
# ===================================================================


class TestExchangesList:
    """Tests for client.exchanges.list()."""

    def test_list_success(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": EXCHANGE_LIST_DATA, "meta": {"record_count": 2, "query_time_ms": 5}},
        )

        result = client.exchanges.list()

        assert isinstance(result, list)
        assert len(result) == 2
        assert isinstance(result[0], Exchange)
        assert result[0].name == "CBOE"
        assert result[0].display_name == "CBOE US Options"
        assert result[0].fee_types == ["Option"]
        assert result[0].record_count == 150
        assert result[1].name == "NYSE"

    def test_list_empty(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": [], "meta": {"record_count": 0, "query_time_ms": 1}},
        )

        result = client.exchanges.list()
        assert result == []

    def test_list_authentication_error(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            status_code=401,
            json={"error": {"code": "UNAUTHORIZED", "message": "Invalid API key"}, "meta": {}},
        )

        with pytest.raises(AuthenticationError) as exc_info:
            client.exchanges.list()

        assert exc_info.value.status_code == 401
        assert "Invalid API key" in str(exc_info.value)

    def test_list_sends_auth_header(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": [], "meta": {"record_count": 0, "query_time_ms": 1}},
        )

        client.exchanges.list()

        request = httpx_mock.get_request()
        assert request.headers["x-rulebook-api-access-key"] == "test-key"

    def test_list_sends_user_agent(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": [], "meta": {"record_count": 0, "query_time_ms": 1}},
        )

        client.exchanges.list()

        request = httpx_mock.get_request()
        assert "rulebook-python" in request.headers["user-agent"]

    def test_list_sends_user_agent_version(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": [], "meta": {"record_count": 0, "query_time_ms": 1}},
        )

        client.exchanges.list()

        request = httpx_mock.get_request()
        assert "rulebook-python/0.2.0" in request.headers["user-agent"]

    def test_list_extra_headers(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": [], "meta": {"record_count": 0, "query_time_ms": 1}},
        )

        client.exchanges.list(extra_headers={"X-Custom": "value"})

        request = httpx_mock.get_request()
        assert request.headers["x-custom"] == "value"

    def test_list_server_error(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            status_code=500,
            json={"error": {"code": "INTERNAL_SERVER_ERROR", "message": "Internal server error"}, "meta": {}},
        )

        with pytest.raises(InternalServerError) as exc_info:
            client.exchanges.list()

        assert exc_info.value.status_code == 500


# ===================================================================
# Sync — exchanges.retrieve()
# ===================================================================


class TestExchangesRetrieve:
    """Tests for client.exchanges.retrieve(exchange_name)."""

    def test_retrieve_success(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/NYSE",
            json={"data": EXCHANGE_DETAIL_DATA, "meta": {"record_count": 1, "query_time_ms": 5}},
        )

        detail = client.exchanges.retrieve("NYSE")

        assert isinstance(detail, ExchangeDetail)
        assert detail.name == "NYSE"
        assert detail.display_name == "New York Stock Exchange"
        assert isinstance(detail.date_range, DateRange)
        assert detail.date_range.earliest == "2024-01-15"
        assert detail.date_range.latest == "2025-06-01"
        assert detail.fee_types == ["Equity", "Option"]
        assert detail.fee_categories == ["Fee And Rebates", "Legal Regulatory Fees"]
        assert detail.actions == ["Make", "Take", "Open"]
        assert detail.participants == ["Market Maker", "Customer"]
        assert detail.symbol_classifications == ["ETF", "Equity"]
        assert detail.symbol_types == ["Penny", "Non Penny"]
        assert detail.trade_types == ["Simple Order", "Complex Order"]
        assert detail.record_count == 320

    def test_retrieve_not_found(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/NONEXISTENT",
            status_code=404,
            json={"error": {"code": "NOT_FOUND", "message": "Exchange not found"}, "meta": {}},
        )

        with pytest.raises(NotFoundError) as exc_info:
            client.exchanges.retrieve("NONEXISTENT")

        assert exc_info.value.status_code == 404
        assert "Exchange not found" in str(exc_info.value)

    def test_retrieve_forbidden(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/RESTRICTED",
            status_code=403,
            json={"error": {"code": "FORBIDDEN", "message": "Access denied"}, "meta": {}},
        )

        with pytest.raises(PermissionDeniedError) as exc_info:
            client.exchanges.retrieve("RESTRICTED")

        assert exc_info.value.status_code == 403

    def test_retrieve_authentication_error(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/NYSE",
            status_code=401,
            json={"error": {"code": "UNAUTHORIZED", "message": "Invalid API key"}, "meta": {}},
        )

        with pytest.raises(AuthenticationError) as exc_info:
            client.exchanges.retrieve("NYSE")

        assert exc_info.value.status_code == 401

    def test_retrieve_sends_correct_path(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/CBOE",
            json={"data": EXCHANGE_DETAIL_DATA, "meta": {"record_count": 1, "query_time_ms": 5}},
        )

        client.exchanges.retrieve("CBOE")

        request = httpx_mock.get_request()
        assert "/exchanges/CBOE" in str(request.url)

    def test_retrieve_extra_query(self, httpx_mock, client):
        httpx_mock.add_response(
            json={"data": EXCHANGE_DETAIL_DATA, "meta": {"record_count": 1, "query_time_ms": 5}},
        )

        client.exchanges.retrieve("NYSE", extra_query={"foo": "bar"})

        request = httpx_mock.get_request()
        assert "foo=bar" in str(request.url)

    def test_retrieve_url_encodes_exchange_name_with_space(self, httpx_mock, client):
        httpx_mock.add_response(
            json={"data": EXCHANGE_DETAIL_DATA, "meta": {"record_count": 1, "query_time_ms": 5}},
        )

        client.exchanges.retrieve("NYSE ARCA")

        request = httpx_mock.get_request()
        assert "/exchanges/NYSE%20ARCA" in str(request.url)

    def test_retrieve_url_encodes_exchange_name_with_slash(self, httpx_mock, client):
        httpx_mock.add_response(
            json={"data": EXCHANGE_DETAIL_DATA, "meta": {"record_count": 1, "query_time_ms": 5}},
        )

        client.exchanges.retrieve("NYSE/ARCA")

        request = httpx_mock.get_request()
        assert "/exchanges/NYSE%2FARCA" in str(request.url)


# ===================================================================
# Sync — error_code and details on APIStatusError
# ===================================================================


class TestExchangesErrorFields:
    """Tests that error_code and details are surfaced on APIStatusError."""

    def test_error_code_surfaced_on_401(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            status_code=401,
            json={"error": {"code": "UNAUTHORIZED", "message": "Invalid API key"}, "meta": {}},
        )

        with pytest.raises(AuthenticationError) as exc_info:
            client.exchanges.list()

        assert exc_info.value.error_code == "UNAUTHORIZED"

    def test_error_code_surfaced_on_404(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/NONEXISTENT",
            status_code=404,
            json={"error": {"code": "NOT_FOUND", "message": "Exchange not found"}, "meta": {}},
        )

        with pytest.raises(NotFoundError) as exc_info:
            client.exchanges.retrieve("NONEXISTENT")

        assert exc_info.value.error_code == "NOT_FOUND"

    def test_error_code_surfaced_on_403(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/RESTRICTED",
            status_code=403,
            json={
                "error": {
                    "code": "FORBIDDEN",
                    "message": "Access denied",
                    "details": [{"field": "exchange_name", "issue": "Not authorized"}],
                },
                "meta": {},
            },
        )

        with pytest.raises(PermissionDeniedError) as exc_info:
            client.exchanges.retrieve("RESTRICTED")

        assert exc_info.value.error_code == "FORBIDDEN"
        assert exc_info.value.details is not None
        assert len(exc_info.value.details) == 1
        assert exc_info.value.details[0]["field"] == "exchange_name"

    def test_error_code_none_for_legacy_format(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            status_code=500,
            json={"message": "Something went wrong"},
        )

        with pytest.raises(InternalServerError) as exc_info:
            client.exchanges.list()

        assert exc_info.value.error_code is None
        assert exc_info.value.details is None

    def test_details_none_when_not_present(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            status_code=401,
            json={"error": {"code": "UNAUTHORIZED", "message": "Invalid API key"}, "meta": {}},
        )

        with pytest.raises(AuthenticationError) as exc_info:
            client.exchanges.list()

        assert exc_info.value.details is None


# ===================================================================
# Sync — input validation
# ===================================================================


class TestExchangesValidation:
    """Tests for local input validation before HTTP calls."""

    def test_retrieve_empty_string_raises(self, client):
        with pytest.raises(ValueError, match="non-empty"):
            client.exchanges.retrieve("")

    def test_retrieve_none_raises(self, client):
        with pytest.raises((ValueError, TypeError)):
            client.exchanges.retrieve(None)  # type: ignore[arg-type]


# ===================================================================
# Sync — with_raw_response
# ===================================================================


class TestExchangesWithRawResponse:
    """Tests for client.exchanges.with_raw_response wrappers."""

    def test_raw_response_list(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": EXCHANGE_LIST_DATA, "meta": {"record_count": 2, "query_time_ms": 5}},
        )

        raw = client.exchanges.with_raw_response.list()

        assert isinstance(raw, APIResponse)
        assert raw.status_code == 200
        assert raw.headers is not None

        parsed = raw.parse()
        assert isinstance(parsed, list)
        assert len(parsed) == 2
        assert isinstance(parsed[0], Exchange)
        assert parsed[0].name == "CBOE"

    def test_raw_response_retrieve(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/NYSE",
            json={"data": EXCHANGE_DETAIL_DATA, "meta": {"record_count": 1, "query_time_ms": 5}},
        )

        raw = client.exchanges.with_raw_response.retrieve("NYSE")

        assert isinstance(raw, APIResponse)
        assert raw.status_code == 200

        detail = raw.parse()
        assert isinstance(detail, ExchangeDetail)
        assert detail.name == "NYSE"
        assert detail.date_range.earliest == "2024-01-15"

    def test_raw_response_error_propagates(self, httpx_mock, client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/BAD",
            status_code=404,
            json={"error": {"code": "NOT_FOUND", "message": "Not found"}, "meta": {}},
        )

        with pytest.raises(NotFoundError):
            client.exchanges.with_raw_response.retrieve("BAD")

    def test_raw_response_empty_name_raises(self, client):
        with pytest.raises(ValueError, match="non-empty"):
            client.exchanges.with_raw_response.retrieve("")

    def test_cached_property(self, client):
        """with_raw_response is lazily cached — same instance on repeated access."""
        raw1 = client.exchanges.with_raw_response
        raw2 = client.exchanges.with_raw_response
        assert raw1 is raw2


# ===================================================================
# Async — exchanges.list()
# ===================================================================


class TestAsyncExchangesList:
    """Tests for async client.exchanges.list()."""

    @pytest.mark.asyncio
    async def test_list_success(self, httpx_mock, async_client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": EXCHANGE_LIST_DATA, "meta": {"record_count": 2, "query_time_ms": 5}},
        )

        result = await async_client.exchanges.list()

        assert isinstance(result, list)
        assert len(result) == 2
        assert isinstance(result[0], Exchange)
        assert result[0].name == "CBOE"

    @pytest.mark.asyncio
    async def test_list_empty(self, httpx_mock, async_client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": [], "meta": {"record_count": 0, "query_time_ms": 1}},
        )

        result = await async_client.exchanges.list()
        assert result == []

    @pytest.mark.asyncio
    async def test_list_authentication_error(self, httpx_mock, async_client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            status_code=401,
            json={"error": {"code": "UNAUTHORIZED", "message": "Invalid API key"}, "meta": {}},
        )

        with pytest.raises(AuthenticationError) as exc_info:
            await async_client.exchanges.list()

        assert exc_info.value.status_code == 401


# ===================================================================
# Async — exchanges.retrieve()
# ===================================================================


class TestAsyncExchangesRetrieve:
    """Tests for async client.exchanges.retrieve(exchange_name)."""

    @pytest.mark.asyncio
    async def test_retrieve_success(self, httpx_mock, async_client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/NYSE",
            json={"data": EXCHANGE_DETAIL_DATA, "meta": {"record_count": 1, "query_time_ms": 5}},
        )

        detail = await async_client.exchanges.retrieve("NYSE")

        assert isinstance(detail, ExchangeDetail)
        assert detail.name == "NYSE"
        assert isinstance(detail.date_range, DateRange)
        assert detail.date_range.earliest == "2024-01-15"

    @pytest.mark.asyncio
    async def test_retrieve_not_found(self, httpx_mock, async_client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/NONEXISTENT",
            status_code=404,
            json={"error": {"code": "NOT_FOUND", "message": "Exchange not found"}, "meta": {}},
        )

        with pytest.raises(NotFoundError) as exc_info:
            await async_client.exchanges.retrieve("NONEXISTENT")

        assert exc_info.value.status_code == 404

    @pytest.mark.asyncio
    async def test_retrieve_forbidden(self, httpx_mock, async_client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/RESTRICTED",
            status_code=403,
            json={"error": {"code": "FORBIDDEN", "message": "Access denied"}, "meta": {}},
        )

        with pytest.raises(PermissionDeniedError):
            await async_client.exchanges.retrieve("RESTRICTED")

    @pytest.mark.asyncio
    async def test_retrieve_authentication_error(self, httpx_mock, async_client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/NYSE",
            status_code=401,
            json={"error": {"code": "UNAUTHORIZED", "message": "Invalid API key"}, "meta": {}},
        )

        with pytest.raises(AuthenticationError):
            await async_client.exchanges.retrieve("NYSE")


# ===================================================================
# Async — input validation
# ===================================================================


class TestAsyncExchangesValidation:
    """Async input validation tests."""

    @pytest.mark.asyncio
    async def test_retrieve_empty_string_raises(self, async_client):
        with pytest.raises(ValueError, match="non-empty"):
            await async_client.exchanges.retrieve("")


# ===================================================================
# Async — with_raw_response
# ===================================================================


class TestAsyncExchangesWithRawResponse:
    """Tests for async with_raw_response wrappers."""

    @pytest.mark.asyncio
    async def test_raw_response_list(self, httpx_mock, async_client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/",
            json={"data": EXCHANGE_LIST_DATA, "meta": {"record_count": 2, "query_time_ms": 5}},
        )

        raw = await async_client.exchanges.with_raw_response.list()

        assert isinstance(raw, APIResponse)
        assert raw.status_code == 200

        parsed = raw.parse()
        assert isinstance(parsed, list)
        assert len(parsed) == 2

    @pytest.mark.asyncio
    async def test_raw_response_retrieve(self, httpx_mock, async_client):
        httpx_mock.add_response(
            url=f"{BASE_URL}/exchanges/NYSE",
            json={"data": EXCHANGE_DETAIL_DATA, "meta": {"record_count": 1, "query_time_ms": 5}},
        )

        raw = await async_client.exchanges.with_raw_response.retrieve("NYSE")

        assert isinstance(raw, APIResponse)
        assert raw.status_code == 200

        detail = raw.parse()
        assert isinstance(detail, ExchangeDetail)
        assert detail.name == "NYSE"


# ===================================================================
# Model tests
# ===================================================================


class TestExchangeModel:
    """Tests for the Exchange Pydantic model."""

    def test_from_dict(self):
        data = {
            "name": "CBOE",
            "display_name": "CBOE US Options",
            "fee_types": ["Option"],
            "record_count": 150,
        }
        exchange = Exchange.model_validate(data)
        assert exchange.name == "CBOE"
        assert exchange.display_name == "CBOE US Options"
        assert exchange.fee_types == ["Option"]
        assert exchange.record_count == 150

    def test_frozen(self):
        exchange = Exchange.model_validate(EXCHANGE_LIST_DATA[0])
        with pytest.raises(Exception):
            exchange.name = "MODIFIED"  # type: ignore[misc]


class TestExchangeDetailModel:
    """Tests for the ExchangeDetail Pydantic model."""

    def test_from_dict(self):
        detail = ExchangeDetail.model_validate(EXCHANGE_DETAIL_DATA)
        assert detail.name == "NYSE"
        assert isinstance(detail.date_range, DateRange)
        assert detail.date_range.earliest == "2024-01-15"
        assert detail.date_range.latest == "2025-06-01"
        assert detail.fee_categories == ["Fee And Rebates", "Legal Regulatory Fees"]
        assert detail.record_count == 320

    def test_frozen(self):
        detail = ExchangeDetail.model_validate(EXCHANGE_DETAIL_DATA)
        with pytest.raises(Exception):
            detail.name = "MODIFIED"  # type: ignore[misc]


class TestDateRangeModel:
    """Tests for the DateRange Pydantic model."""

    def test_from_dict(self):
        dr = DateRange.model_validate({"earliest": "2024-01-01", "latest": "2025-12-31"})
        assert dr.earliest == "2024-01-01"
        assert dr.latest == "2025-12-31"
