"""Integration tests against a running dev API server.

Run with:
    pytest tests/test_integration.py -v

Requires the platform-api running at the configured BASE_URL.
Tests dynamically discover exchanges, result IDs, and version IDs
from the API — no hardcoded data required.
"""

from __future__ import annotations

import pytest
import pytest_asyncio

from rulebook import (
    AsyncRulebook,
    Rulebook,
    APIConnectionError,
    APITimeoutError,
    AuthenticationError,
    NotFoundError,
    PermissionDeniedError,
    UnprocessableEntityError,
)
from rulebook._response import APIResponse
from rulebook.types import (
    Exchange,
    ExchangeDetail,
    DateRange,
    FeeScheduleResult,
    FeeScheduleResultFilters,
    PaginatedResponse,
)

BASE_URL = "https://dev-be-stock-rule-book.codeaza.org/api/v1"
API_KEY = "080785a6785e4b0887a79a6c4f64377a"
NONEXISTENT_UUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
FORBIDDEN_EXCHANGE = "nasdaqphlx"


@pytest.fixture(scope="session")
def client() -> Rulebook:
    c = Rulebook(api_key=API_KEY, base_url=BASE_URL, max_retries=0, timeout=60.0)
    yield c
    c.close()


@pytest.fixture
def bad_client() -> Rulebook:
    c = Rulebook(api_key="bad-key", base_url=BASE_URL, max_retries=0, timeout=60.0)
    yield c
    c.close()


@pytest_asyncio.fixture
async def async_client() -> AsyncRulebook:
    c = AsyncRulebook(api_key=API_KEY, base_url=BASE_URL, max_retries=0, timeout=60.0)
    yield c
    await c.close()


@pytest.fixture(scope="session")
def exchange_name(client) -> str:
    """Discover the first available exchange name."""
    exchanges = client.exchanges.list()
    assert len(exchanges) >= 1, "No exchanges available for this API key"
    return exchanges[0].name


@pytest.fixture(scope="session")
def valid_result(client) -> FeeScheduleResult:
    """Discover a valid fee schedule result from the API."""
    page = client.fee_schedule_results.list(page_size=1, latest_only=True)
    assert len(page.data) >= 1, "No fee schedule results available for this API key"
    return page.data[0]


@pytest.fixture(scope="session")
def valid_result_id(valid_result) -> str:
    return valid_result.record_id


@pytest.fixture(scope="session")
def valid_version_id(valid_result) -> str:
    assert valid_result.version_id is not None, "First result has no version_id"
    return valid_result.version_id


# ===================================================================
# Exchanges — list
# ===================================================================


class TestExchangesListIntegration:

    def test_list_returns_exchanges(self, client, exchange_name):
        result = client.exchanges.list()

        assert isinstance(result, list)
        assert len(result) >= 1

        names = [ex.name for ex in result]
        assert exchange_name in names

        ex = next(e for e in result if e.name == exchange_name)
        assert isinstance(ex, Exchange)
        assert ex.display_name
        assert len(ex.fee_types) >= 1
        assert ex.record_count > 0

    def test_list_unauthenticated(self, bad_client):
        with pytest.raises(AuthenticationError) as exc_info:
            bad_client.exchanges.list()

        assert exc_info.value.status_code == 401


# ===================================================================
# Exchanges — retrieve
# ===================================================================


class TestExchangesRetrieveIntegration:

    def test_retrieve_success(self, client, exchange_name):
        detail = client.exchanges.retrieve(exchange_name)

        assert isinstance(detail, ExchangeDetail)
        assert detail.name == exchange_name
        assert detail.display_name
        assert isinstance(detail.date_range, DateRange)
        assert detail.date_range.earliest
        assert detail.date_range.latest
        assert len(detail.fee_types) >= 1
        assert len(detail.fee_categories) > 0
        assert len(detail.actions) > 0
        assert len(detail.participants) > 0
        assert len(detail.symbol_classifications) > 0
        assert len(detail.symbol_types) > 0
        assert detail.record_count > 0

    def test_retrieve_not_found(self, client):
        with pytest.raises(NotFoundError) as exc_info:
            client.exchanges.retrieve("nonexistent_exchange")

        assert exc_info.value.status_code == 404

    def test_retrieve_unauthenticated(self, bad_client, exchange_name):
        with pytest.raises(AuthenticationError):
            bad_client.exchanges.retrieve(exchange_name)

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


# ===================================================================
# Exchanges — with_raw_response
# ===================================================================


class TestExchangesRawResponseIntegration:

    def test_raw_response_list(self, client):
        raw = client.exchanges.with_raw_response.list()

        assert isinstance(raw, APIResponse)
        assert raw.status_code == 200
        assert "application/json" in raw.headers["content-type"]

        parsed = raw.parse()
        assert isinstance(parsed, list)
        assert len(parsed) >= 1
        assert isinstance(parsed[0], Exchange)

    def test_raw_response_retrieve(self, client, exchange_name):
        raw = client.exchanges.with_raw_response.retrieve(exchange_name)

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

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


# ===================================================================
# Exchanges — async
# ===================================================================


class TestAsyncExchangesIntegration:

    @pytest.mark.asyncio
    async def test_async_list(self, async_client):
        result = await async_client.exchanges.list()

        assert isinstance(result, list)
        assert len(result) >= 1
        assert isinstance(result[0], Exchange)

    @pytest.mark.asyncio
    async def test_async_retrieve(self, async_client, exchange_name):
        detail = await async_client.exchanges.retrieve(exchange_name)

        assert isinstance(detail, ExchangeDetail)
        assert detail.name == exchange_name

    @pytest.mark.asyncio
    async def test_async_retrieve_not_found(self, async_client):
        with pytest.raises(NotFoundError):
            await async_client.exchanges.retrieve("nonexistent_exchange")


# ===================================================================
# Client — per-request overrides
# ===================================================================


class TestPerRequestOverridesIntegration:

    def test_extra_headers(self, client):
        result = client.exchanges.list(extra_headers={"X-Custom-Test": "integration"})
        assert isinstance(result, list)

    def test_timeout_override(self, client):
        result = client.exchanges.list(timeout=120.0)
        assert isinstance(result, list)

    def test_with_options(self, client):
        client2 = client.with_options(timeout=120.0)
        result = client2.exchanges.list()
        assert isinstance(result, list)
        client2.close()

    def test_context_manager(self):
        with Rulebook(api_key=API_KEY, base_url=BASE_URL, timeout=60.0) as c:
            result = c.exchanges.list()
            assert isinstance(result, list)


# ===================================================================
# Fee schedule results — list
# ===================================================================


class TestFeeScheduleResultsListIntegration:

    def test_list_returns_paginated_results(self, client):
        result = client.fee_schedule_results.list(page_size=5)

        assert isinstance(result, PaginatedResponse)
        assert result.total_records > 0
        assert result.page_size == 5
        assert result.total_pages >= 1
        assert result.current_page == 0
        assert len(result.data) <= 5

        item = result.data[0]
        assert isinstance(item, FeeScheduleResult)
        assert item.record_id
        assert item.exchange_name
        assert item.fee_type
        assert item.fee_category

    def test_list_with_exchange_filter(self, client, exchange_name):
        result = client.fee_schedule_results.list(
            exchange_name=[exchange_name],
            page_size=5,
        )

        assert isinstance(result, PaginatedResponse)
        for item in result.data:
            assert item.exchange_name == exchange_name

    def test_list_empty_page(self, client):
        result = client.fee_schedule_results.list(page_number=99999, page_size=100)

        assert isinstance(result, PaginatedResponse)
        assert result.data == []

    def test_list_latest_only(self, client):
        result = client.fee_schedule_results.list(latest_only=True, page_size=5)

        assert isinstance(result, PaginatedResponse)
        assert result.total_records >= 0

    def test_list_with_include_source(self, client):
        result = client.fee_schedule_results.list(
            include_source=True,
            latest_only=True,
            page_size=10,
        )

        assert isinstance(result, PaginatedResponse)
        # At least some results across all exchanges should have source info
        has_source = any(item.source_document is not None for item in result.data)
        assert has_source

    def test_list_unauthenticated(self, bad_client):
        with pytest.raises(AuthenticationError) as exc_info:
            bad_client.fee_schedule_results.list()

        assert exc_info.value.status_code == 401


# ===================================================================
# Fee schedule results — retrieve
# ===================================================================


class TestFeeScheduleResultsRetrieveIntegration:

    def test_retrieve_success(self, client, valid_result_id):
        result = client.fee_schedule_results.retrieve(valid_result_id)

        assert isinstance(result, FeeScheduleResult)
        assert result.record_id == valid_result_id
        assert result.exchange_name
        assert result.fee_type
        assert result.fee_category

    def test_retrieve_with_source(self, client, valid_result_id):
        result = client.fee_schedule_results.retrieve(
            valid_result_id,
            include_source=True,
        )

        assert isinstance(result, FeeScheduleResult)
        assert result.record_id == valid_result_id
        assert result.source_document is not None
        assert result.source_link is not None
        assert result.page_number is not None

    def test_retrieve_with_relationships(self, client, valid_result_id):
        result = client.fee_schedule_results.retrieve(
            valid_result_id,
            include_footnotes=True,
            include_definitions=True,
        )

        assert isinstance(result, FeeScheduleResult)
        assert result.relationships is not None
        # relationships object should exist even if lists are empty
        assert isinstance(result.relationships.footnotes, list)
        assert isinstance(result.relationships.definitions, list)

    def test_retrieve_not_found(self, client):
        with pytest.raises(NotFoundError) as exc_info:
            client.fee_schedule_results.retrieve(NONEXISTENT_UUID)

        assert exc_info.value.status_code == 404

    def test_retrieve_unauthenticated(self, bad_client, valid_result_id):
        with pytest.raises(AuthenticationError):
            bad_client.fee_schedule_results.retrieve(valid_result_id)

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


# ===================================================================
# Fee schedule results — get_filters
# ===================================================================


class TestFeeScheduleResultsGetFiltersIntegration:

    def test_get_filters_success(self, client):
        filters = client.fee_schedule_results.get_filters()

        assert isinstance(filters, FeeScheduleResultFilters)
        assert len(filters.exchange_names) >= 1
        assert len(filters.fee_types) >= 1
        assert len(filters.fee_categories) >= 1
        assert len(filters.fee_actions) >= 1
        assert len(filters.fee_participants) >= 1

    def test_get_filters_with_exchange_name(self, client, exchange_name):
        filters = client.fee_schedule_results.get_filters(
            exchange_name=[exchange_name]
        )

        assert isinstance(filters, FeeScheduleResultFilters)
        assert exchange_name in filters.exchange_names
        assert len(filters.fee_types) >= 1

    def test_get_filters_unauthenticated(self, bad_client):
        with pytest.raises(AuthenticationError):
            bad_client.fee_schedule_results.get_filters()


# ===================================================================
# Fee schedule results — get_results_by_version
# ===================================================================


class TestFeeScheduleResultsByVersionIntegration:

    def test_get_results_by_version_success(self, client, valid_version_id):
        result = client.fee_schedule_results.get_results_by_version(
            valid_version_id, page_size=5
        )

        assert isinstance(result, PaginatedResponse)
        assert result.total_records > 0
        assert len(result.data) <= 5
        for item in result.data:
            assert isinstance(item, FeeScheduleResult)
            assert item.version_id == valid_version_id

    def test_get_results_by_version_with_source(self, client, valid_version_id):
        result = client.fee_schedule_results.get_results_by_version(
            valid_version_id,
            include_source=True,
            page_size=5,
        )

        assert isinstance(result, PaginatedResponse)
        has_source = any(item.source_document is not None for item in result.data)
        assert has_source

    def test_get_results_by_version_not_found(self, client):
        with pytest.raises(NotFoundError):
            client.fee_schedule_results.get_results_by_version(NONEXISTENT_UUID)

    def test_get_results_by_version_empty_id_raises_locally(self, client):
        with pytest.raises(ValueError, match="non-empty"):
            client.fee_schedule_results.get_results_by_version("")


# ===================================================================
# Fee schedule results — with_raw_response
# ===================================================================


class TestFeeScheduleResultsRawResponseIntegration:

    def test_raw_response_list(self, client):
        raw = client.fee_schedule_results.with_raw_response.list(page_size=5)

        assert isinstance(raw, APIResponse)
        assert raw.status_code == 200
        assert "application/json" in raw.headers["content-type"]

        parsed = raw.parse()
        assert isinstance(parsed, PaginatedResponse)
        assert len(parsed.data) >= 1
        assert isinstance(parsed.data[0], FeeScheduleResult)

    def test_raw_response_retrieve(self, client, valid_result_id):
        raw = client.fee_schedule_results.with_raw_response.retrieve(valid_result_id)

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

        result = raw.parse()
        assert isinstance(result, FeeScheduleResult)
        assert result.record_id == valid_result_id

    def test_raw_response_get_filters(self, client):
        raw = client.fee_schedule_results.with_raw_response.get_filters()

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

        filters = raw.parse()
        assert isinstance(filters, FeeScheduleResultFilters)
        assert len(filters.exchange_names) >= 1


# ===================================================================
# Fee schedule results — async
# ===================================================================


class TestAsyncFeeScheduleResultsIntegration:

    @pytest.mark.asyncio
    async def test_async_list(self, async_client):
        result = await async_client.fee_schedule_results.list(page_size=5)

        assert isinstance(result, PaginatedResponse)
        assert result.total_records > 0
        assert isinstance(result.data[0], FeeScheduleResult)

    @pytest.mark.asyncio
    async def test_async_retrieve(self, async_client, valid_result_id):
        result = await async_client.fee_schedule_results.retrieve(valid_result_id)

        assert isinstance(result, FeeScheduleResult)
        assert result.record_id == valid_result_id

    @pytest.mark.asyncio
    async def test_async_retrieve_not_found(self, async_client):
        with pytest.raises(NotFoundError):
            await async_client.fee_schedule_results.retrieve(NONEXISTENT_UUID)

    @pytest.mark.asyncio
    async def test_async_get_filters(self, async_client):
        filters = await async_client.fee_schedule_results.get_filters()

        assert isinstance(filters, FeeScheduleResultFilters)
        assert len(filters.exchange_names) >= 1

    @pytest.mark.asyncio
    async def test_async_get_results_by_version(self, async_client, valid_version_id):
        result = await async_client.fee_schedule_results.get_results_by_version(
            valid_version_id, page_size=5
        )

        assert isinstance(result, PaginatedResponse)
        assert result.total_records > 0


# ===================================================================
# PermissionDenied — forbidden exchange (403)
# ===================================================================


class TestPermissionDeniedIntegration:

    def test_retrieve_forbidden_exchange(self, client):
        with pytest.raises(PermissionDeniedError) as exc_info:
            client.exchanges.retrieve(FORBIDDEN_EXCHANGE)

        assert exc_info.value.status_code == 403

    def test_list_filtered_by_forbidden_exchange(self, client):
        with pytest.raises(PermissionDeniedError) as exc_info:
            client.fee_schedule_results.list(exchange_name=[FORBIDDEN_EXCHANGE])

        assert exc_info.value.status_code == 403


# ===================================================================
# error_code and details fields on API errors
# ===================================================================


class TestErrorFieldsIntegration:

    def test_error_code_on_401_unauthenticated(self, bad_client):
        with pytest.raises(AuthenticationError) as exc_info:
            bad_client.exchanges.list()

        err = exc_info.value
        assert err.error_code == "UNAUTHORIZED"
        assert err.body is not None
        assert err.body["error"]["code"] == "UNAUTHORIZED"

    def test_error_code_on_404_not_found(self, client):
        with pytest.raises(NotFoundError) as exc_info:
            client.exchanges.retrieve("nonexistent_exchange")

        err = exc_info.value
        assert err.error_code == "NOT_FOUND"
        assert err.body is not None
        assert err.body["error"]["code"] == "NOT_FOUND"

    def test_error_code_on_403_forbidden(self, client):
        with pytest.raises(PermissionDeniedError) as exc_info:
            client.exchanges.retrieve(FORBIDDEN_EXCHANGE)

        err = exc_info.value
        assert err.error_code == "FORBIDDEN"
        assert err.body is not None
        assert err.body["error"]["code"] == "FORBIDDEN"

    def test_error_code_on_422_validation(self, client):
        with pytest.raises(UnprocessableEntityError) as exc_info:
            client.fee_schedule_results.list(order_by="invalid_field")

        err = exc_info.value
        assert err.error_code == "VALIDATION_ERROR"
        assert err.body is not None
        assert err.body["error"]["code"] == "VALIDATION_ERROR"

    def test_details_on_422_validation(self, client):
        with pytest.raises(UnprocessableEntityError) as exc_info:
            client.fee_schedule_results.list(order_by="invalid_field")

        err = exc_info.value
        # details may or may not be populated depending on API response
        # but error_code should always be present
        assert err.error_code == "VALIDATION_ERROR"

    def test_error_message_on_401(self, bad_client):
        with pytest.raises(AuthenticationError) as exc_info:
            bad_client.exchanges.list()

        err = exc_info.value
        assert err.message  # non-empty message from API

    def test_error_message_on_404(self, client):
        with pytest.raises(NotFoundError) as exc_info:
            client.fee_schedule_results.retrieve(NONEXISTENT_UUID)

        err = exc_info.value
        assert err.message  # non-empty message from API

    @pytest.mark.asyncio
    async def test_async_error_code_on_401(self, async_client):
        bad_async = AsyncRulebook(
            api_key="bad-key", base_url=BASE_URL, max_retries=0, timeout=60.0
        )
        try:
            with pytest.raises(AuthenticationError) as exc_info:
                await bad_async.exchanges.list()

            err = exc_info.value
            assert err.error_code == "UNAUTHORIZED"
        finally:
            await bad_async.close()

    @pytest.mark.asyncio
    async def test_async_error_code_on_404(self, async_client):
        with pytest.raises(NotFoundError) as exc_info:
            await async_client.exchanges.retrieve("nonexistent_exchange")

        err = exc_info.value
        assert err.error_code == "NOT_FOUND"


# ===================================================================
# record_count on ExchangeDetail from meta
# ===================================================================


class TestExchangeDetailRecordCountIntegration:

    def test_record_count_present_on_exchange_detail(self, client, exchange_name):
        detail = client.exchanges.retrieve(exchange_name)

        assert detail.record_count is not None
        assert isinstance(detail.record_count, int)
        assert detail.record_count > 0

    def test_record_count_via_raw_response(self, client, exchange_name):
        raw = client.exchanges.with_raw_response.retrieve(exchange_name)
        detail = raw.parse()

        assert detail.record_count is not None
        assert detail.record_count > 0

    @pytest.mark.asyncio
    async def test_async_record_count_on_exchange_detail(self, async_client, exchange_name):
        detail = await async_client.exchanges.retrieve(exchange_name)

        assert detail.record_count is not None
        assert detail.record_count > 0


# ===================================================================
# Raw response on error paths
# ===================================================================


class TestRawResponseErrorPathsIntegration:

    def test_raw_response_throws_on_401(self, bad_client):
        with pytest.raises(AuthenticationError) as exc_info:
            bad_client.exchanges.with_raw_response.list()

        err = exc_info.value
        assert err.status_code == 401
        assert err.error_code == "UNAUTHORIZED"

    def test_raw_response_throws_on_404(self, client):
        with pytest.raises(NotFoundError) as exc_info:
            client.exchanges.with_raw_response.retrieve("nonexistent_exchange")

        err = exc_info.value
        assert err.status_code == 404
        assert err.error_code == "NOT_FOUND"

    def test_raw_response_throws_on_403(self, client):
        with pytest.raises(PermissionDeniedError) as exc_info:
            client.exchanges.with_raw_response.retrieve(FORBIDDEN_EXCHANGE)

        err = exc_info.value
        assert err.status_code == 403
        assert err.error_code == "FORBIDDEN"


# ===================================================================
# Validation errors (422)
# ===================================================================


class TestUnprocessableEntityIntegration:

    def test_invalid_order_by_raises_422(self, client):
        with pytest.raises(UnprocessableEntityError) as exc_info:
            client.fee_schedule_results.list(order_by="invalid_field")

        assert exc_info.value.status_code == 422

    def test_invalid_order_dir_raises_422(self, client):
        with pytest.raises(UnprocessableEntityError) as exc_info:
            client.fee_schedule_results.list(order_dir="sideways")

        assert exc_info.value.status_code == 422

    def test_error_body_has_message(self, client):
        with pytest.raises(UnprocessableEntityError) as exc_info:
            client.fee_schedule_results.list(order_by="nonexistent")

        assert exc_info.value.body is not None
        assert "error" in exc_info.value.body
        assert exc_info.value.body["error"]["code"] == "VALIDATION_ERROR"

    @pytest.mark.asyncio
    async def test_async_invalid_order_by_raises_422(self, async_client):
        with pytest.raises(UnprocessableEntityError) as exc_info:
            await async_client.fee_schedule_results.list(order_by="bad_field")

        assert exc_info.value.status_code == 422


# ===================================================================
# Connection errors
# ===================================================================


class TestConnectionErrorIntegration:

    def test_unreachable_host_raises_connection_error(self):
        client = Rulebook(
            api_key=API_KEY,
            base_url="https://localhost:1",
            max_retries=0,
            timeout=5.0,
        )
        with pytest.raises(APIConnectionError):
            client.exchanges.list()
        client.close()

    @pytest.mark.asyncio
    async def test_async_unreachable_host_raises_connection_error(self):
        client = AsyncRulebook(
            api_key=API_KEY,
            base_url="https://localhost:1",
            max_retries=0,
            timeout=5.0,
        )
        with pytest.raises(APIConnectionError):
            await client.exchanges.list()
        await client.close()


# ===================================================================
# Timeout errors
# ===================================================================


class TestTimeoutErrorIntegration:

    def test_tiny_timeout_raises_timeout_error(self):
        client = Rulebook(
            api_key=API_KEY,
            base_url=BASE_URL,
            max_retries=0,
            timeout=0.001,
        )
        with pytest.raises(APITimeoutError):
            client.exchanges.list()
        client.close()

    @pytest.mark.asyncio
    async def test_async_tiny_timeout_raises_timeout_error(self):
        client = AsyncRulebook(
            api_key=API_KEY,
            base_url=BASE_URL,
            max_retries=0,
            timeout=0.001,
        )
        with pytest.raises(APITimeoutError):
            await client.exchanges.list()
        await client.close()
