"""
:mod:`tests.unit.api.test_u_api_endpoint_client` module.

Unit tests for :mod:`etlplus.api.endpoint_client`.

Notes
-----
- Preserves path prefixes in composed URLs.
- Mocks network calls via patched extract helpers.
- Includes optional Hypothesis-based property tests when available.
"""

from __future__ import annotations

import types
import urllib.parse as urlparse
from collections.abc import Callable
from collections.abc import Iterator
from collections.abc import Mapping
from collections.abc import Sequence
from functools import partial
from typing import Any
from typing import cast

import pytest
import requests  # type: ignore[import]

import etlplus.api.endpoint_client as ec_module
import etlplus.api.request_manager as rm_module
from etlplus.api import CursorPaginationConfigDict
from etlplus.api import EndpointClient
from etlplus.api import PagePaginationConfigDict
from etlplus.api import PaginationType
from etlplus.api import RequestOptions
from etlplus.api import RetryPolicyDict
from etlplus.api import errors as api_errors

from .test_u_api_mocks import MockSession

# SECTION: PRAGMAS ========================================================== #

# pylint: disable=import-outside-toplevel,protected-access,unused-argument

# SECTION: HELPERS ========================================================== #


EXAMPLE_BASE_URL = 'https://example.test'

type CursorConfigFactory = Callable[..., CursorPaginationConfigDict]
type StubRequestManager = Callable[
    [Sequence[dict[str, Any]]],
    list[dict[str, Any]],
]

# Optional Hypothesis import with safe stubs when missing.
try:  # pragma: no try
    from hypothesis import given  # type: ignore[import-not-found]
    from hypothesis import strategies as st  # type: ignore[import-not-found]

    _HYP_AVAILABLE = True
except ImportError:  # pragma: no cover
    _HYP_AVAILABLE = False

    def given(*_a, **_k):  # type: ignore[unused-ignore]
        """No-op decorator when Hypothesis is unavailable."""

        def _wrap(fn):
            return pytest.mark.skip(reason='needs hypothesis')(fn)

        return _wrap

    class _Strategy:  # minimal chainable strategy stub
        def filter(self, *_a, **_k):  # pragma: no cover
            """No-op filter when Hypothesis is unavailable."""
            return self

    class _DummyStrategies:
        def text(self, *_a, **_k):  # pragma: no cover
            """No-op text strategy when Hypothesis is unavailable."""
            return _Strategy()

        def characters(self, *_a, **_k):  # pragma: no cover
            """No-op characters strategy when Hypothesis is unavailable."""
            return _Strategy()

        def dictionaries(self, *_a, **_k):  # pragma: no cover
            """No-op dictionaries strategy when Hypothesis is unavailable."""
            return _Strategy()

    st = _DummyStrategies()  # type: ignore[assignment]


def _ascii_no_amp_eq() -> Any:
    """
    Returns a Hypothesis strategy for ASCII text excluding '&' and '='.

    Returns
    -------
    Any
        Hypothesis strategy for text.
    """
    alpha = st.characters(min_codepoint=32, max_codepoint=126).filter(
        lambda ch: ch not in ['&', '='],
    )
    return st.text(alphabet=alpha, min_size=0, max_size=12)


def _page_responder(
    pages: Sequence[Sequence[dict[str, Any]]],
) -> Callable[..., list[dict[str, Any]]]:
    """
    Build a deterministic RequestManager stub for page pagination tests.

    Parameters
    ----------
    pages : Sequence[Sequence[dict[str, Any]]]
        1-indexed payloads returned for page requests.

    Returns
    -------
    Callable[..., list[dict[str, Any]]]
        Handler compatible with ``patch_request_once``.
    """

    def _handler(
        self: EndpointClient,
        method: str,
        _url: str,
        *,
        session: Any,
        timeout: Any,
        **kwargs: dict[str, Any],
    ) -> list[dict[str, Any]]:
        assert method == 'GET'
        params = kwargs.get('params') or {}
        page = int(params.get('page', 1))
        if 1 <= page <= len(pages):
            return list(pages[page - 1])
        return []

    return _handler


def _stub_request_manager(
    monkeypatch: pytest.MonkeyPatch,
    responses: Sequence[dict[str, Any]],
) -> list[dict[str, Any]]:
    """
    Patch ``RequestManager.request_once`` with deterministic responses.

    Parameters
    ----------
    monkeypatch : pytest.MonkeyPatch
        Pytest fixture for attribute patching.
    responses : Sequence[dict[str, Any]]
        Ordered responses returned per invocation. The last payload is reused
        if the stub is called more times than there are entries.

    Returns
    -------
    list[dict[str, Any]]
        Mutable call log containing ``url`` and ``kwargs`` for each request.

    Raises
    ------
    ValueError
        If *responses* is empty.
    """

    if not responses:
        msg = 'responses must contain at least one payload'
        raise ValueError(msg)

    calls: list[dict[str, Any]] = []

    def _fake_request(
        self: rm_module.RequestManager,  # noqa: D401
        method: str,
        url: str,
        *,
        session: Any,
        timeout: Any,
        request_callable: Callable[..., Any] | None = None,
        **kwargs: dict[str, Any],
    ) -> dict[str, Any]:
        idx = min(len(calls), len(responses) - 1)
        calls.append({'method': method, 'url': url, 'kwargs': kwargs})
        return responses[idx]

    monkeypatch.setattr(
        rm_module.RequestManager,
        'request_once',
        _fake_request,
    )

    return calls


# SECTION: FIXTURES ========================================================= #


@pytest.fixture(name='stub_request_manager')
def stub_request_manager_fixture(
    monkeypatch: pytest.MonkeyPatch,
) -> Callable[[Sequence[dict[str, Any]]], list[dict[str, Any]]]:
    """
    Provide a callable that stubs ``RequestManager.request_once``.

    Parameters
    ----------
    monkeypatch : pytest.MonkeyPatch
        Fixture used to patch request helpers.

    Returns
    -------
    Callable[[Sequence[dict[str, Any]]], list[dict[str, Any]]]
        Factory that applies :func:`_stub_request_manager` with captured
        responses.
    """

    return partial(_stub_request_manager, monkeypatch)


@pytest.fixture(name='patch_request_once')
def patch_request_once_fixture(
    monkeypatch: pytest.MonkeyPatch,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    """Provide a helper to patch ``RequestManager.request_once``."""

    def _apply(handler: Callable[..., Any]) -> Callable[..., Any]:
        monkeypatch.setattr(
            rm_module.RequestManager,
            'request_once',
            handler,
        )
        return handler

    return _apply


# SECTION: TESTS ============================================================ #


class TestContextManager:
    """Unit tests for :class:`EndpointClient`."""

    def test_closes_factory_session(
        self,
        base_url: str,
        mock_session: MockSession,
        client_factory: Callable[..., EndpointClient],
        request_once_stub: dict[str, Any],
    ) -> None:
        """
        Test that :class:`EndpointClient` closes a session created by a
        factory.
        """
        sess = mock_session
        client = client_factory(
            endpoints={},
            session_factory=lambda: sess,
        )
        with client:
            out = client.paginate_url(f'{base_url}/items', None)
            assert out == {'ok': True}
        assert sess.closed is True
        assert request_once_stub['urls'] == [f'{base_url}/items']

    def test_creates_and_closes_default_session(
        self,
        base_url: str,
        monkeypatch: pytest.MonkeyPatch,
        client_factory: Callable[..., EndpointClient],
        request_once_stub: dict[str, Any],
    ) -> None:
        """
        Test that :class:`EndpointClient` creates and closes a default session.
        """

        # Substitute Session with MockSession to observe close()
        created: dict[str, MockSession] = {}

        def ctor() -> MockSession:
            s = MockSession()
            created['s'] = s
            return s

        # Patch extract to avoid network and capture params.
        monkeypatch.setattr(ec_module.requests, 'Session', ctor)

        client = client_factory(endpoints={})
        with client:
            out = client.paginate_url(f'{base_url}/items', None)
            assert out == {'ok': True}

        # After context exit, the created session should be closed.
        assert created['s'].closed is True
        assert request_once_stub['urls'] == [f'{base_url}/items']

    def test_does_not_close_external_session(
        self,
        base_url: str,
        mock_session: MockSession,
        client_factory: Callable[..., EndpointClient],
        request_once_stub: dict[str, Any],
    ) -> None:
        """
        Test that :class:`EndpointClient` does not close an externally provided
        session.
        """
        sess = mock_session
        client = client_factory(
            endpoints={},
            session=sess,
        )
        with client:
            out = client.paginate_url(f'{base_url}/items', None)
            assert out == {'ok': True}
        assert sess.closed is False
        assert request_once_stub['urls'] == [f'{base_url}/items']


class TestCursorPagination:
    """Unit tests for :class:`EndpointClient`."""

    @pytest.mark.parametrize(
        ('raw_page_size', 'expected_limit'),
        [(-1, 1), ('not-a-number', EndpointClient.DEFAULT_PAGE_SIZE)],
    )
    def test_page_size_normalizes(
        self,
        cursor_cfg: CursorConfigFactory,
        client_factory: Callable[..., EndpointClient],
        stub_request_manager: StubRequestManager,
        raw_page_size: Any,
        expected_limit: int,
    ) -> None:
        """
        Test that page_size is normalized to a valid integer.
        """
        calls = stub_request_manager(
            [{'items': [{'i': 1}], 'next': None}],
        )

        client = client_factory(base_url=EXAMPLE_BASE_URL, endpoints={})
        cfg = cursor_cfg(
            cursor_param='cursor',
            cursor_path='next',
            page_size=raw_page_size,
            records_path='items',
        )
        out = client.paginate_url(f'{EXAMPLE_BASE_URL}/x', cfg)
        assert isinstance(out, list)

        # mypy treats list element as Any due to external library response
        # type.
        items = [cast(dict, r)['i'] for r in out]  # type: ignore[index]
        assert items == [1]
        params = calls[0]['kwargs'].get('params', {})
        assert params.get('limit') == expected_limit


class TestRequestOptionIntegration:
    """Tests covering RequestOptions propagation across helpers."""

    def test_paginate_url_uses_request_snapshot(
        self,
        base_url: str,
        monkeypatch: pytest.MonkeyPatch,
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that non-paginated calls honor explicitly supplied
        :class:`RequestOptions`.
        """

        client = client_factory(endpoints={})

        captured: dict[str, Any] = {}

        def fake_get(
            _client: EndpointClient,
            url: str,
            **kwargs: Any,
        ) -> dict[str, bool]:
            captured['url'] = url
            captured['kwargs'] = kwargs
            return {'ok': True}

        monkeypatch.setattr(EndpointClient, 'get', fake_get)

        seed = RequestOptions(
            params={'seed': '1'},
            headers={'X-Seed': 'yes'},
            timeout=4.5,
        )
        out = client.paginate_url(
            f'{base_url}/items',
            None,
            request=seed,
        )

        assert out == {'ok': True}
        assert captured['url'] == f'{base_url}/items'
        assert captured['kwargs']['params'] == {'seed': '1'}
        assert captured['kwargs']['headers'] == {'X-Seed': 'yes'}
        assert captured['kwargs']['timeout'] == 4.5

    def test_paginate_url_iter_overrides_request_params(
        self,
        monkeypatch: pytest.MonkeyPatch,
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that paginated iterations override RequestOptions params per call.
        """

        client = client_factory(base_url=EXAMPLE_BASE_URL, endpoints={})
        observed: list[RequestOptions] = []

        def fake_fetch(
            self: EndpointClient,
            _url: str,
            request: RequestOptions,
            page: int | None,
        ) -> dict[str, Any]:
            observed.append(request)
            return {'items': []}

        monkeypatch.setattr(EndpointClient, '_fetch_page', fake_fetch)

        list(
            client.paginate_url_iter(
                f'{EXAMPLE_BASE_URL}/items',
                {
                    'type': PaginationType.PAGE,
                    'records_path': 'items',
                    'page_size': 1,
                },
                request=RequestOptions(
                    params={'seed': 1},
                    headers={'X-Seed': 'yes'},
                    timeout=2.0,
                ),
            ),
        )

        assert observed
        first = observed[0]
        assert first.params == {'seed': 1, 'page': 1, 'per_page': 1}
        assert first.headers == {'X-Seed': 'yes'}
        assert first.timeout == 2.0

    def test_adds_limit_and_advances_cursor(
        self,
        cursor_cfg: CursorConfigFactory,
        client_factory: Callable[..., EndpointClient],
        stub_request_manager: StubRequestManager,
    ) -> None:
        """
        Test that limit is added and cursor advances correctly.
        """
        calls = stub_request_manager(
            [
                {'items': [{'i': 1}], 'next': 'abc'},
                {'items': [{'i': 2}], 'next': None},
            ],
        )
        client = client_factory(base_url=EXAMPLE_BASE_URL, endpoints={})
        cfg = cursor_cfg(
            cursor_param='cursor',
            cursor_path='next',
            page_size=10,
            records_path='items',
        )
        data = client.paginate_url(f'{EXAMPLE_BASE_URL}/x', cfg)
        assert isinstance(data, list)
        assert len(calls) >= 2
        values = [cast(dict, r)['i'] for r in data]  # type: ignore[index]
        assert values == [1, 2]
        first = calls[0]['kwargs']
        second = calls[1]['kwargs']
        assert first.get('params', {}).get('limit') == 10
        assert 'cursor' not in (first.get('params') or {})
        assert second.get('params', {}).get('cursor') == 'abc'
        assert second.get('params', {}).get('limit') == 10

    def test_error_includes_page_number(
        self,
        base_url: str,
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        cursor_cfg: CursorConfigFactory,
        client_factory: Callable[..., EndpointClient],
        fake_http_error_factory: Callable[[int, str], requests.HTTPError],
    ) -> None:
        """
        Test that :class:`PaginationError` includes the page number on failure.

        When a cursor-paginated request fails, :class:`PaginationError`
        includes page.
        """
        client = client_factory(
            base_url=f'{base_url}/v1',
            endpoints={'list': '/items'},
        )
        # First page succeeds with next cursor; second raises 500.
        calls = {'n': 0}

        def extractor(
            self: EndpointClient,
            method: str,
            _url: str,
            *,
            session: Any,
            timeout: Any,
            **kwargs: dict[str, Any],
        ) -> dict[str, Any]:
            assert method == 'GET'
            calls['n'] += 1
            if calls['n'] == 1:
                return {
                    'items': [{'i': 1}],
                    'meta': {'next': 'xyz'},
                }
            raise fake_http_error_factory(500, 'boom')

        patch_request_once(extractor)

        cfg = cursor_cfg(
            cursor_param='cursor',
            cursor_path='meta.next',
            page_size=1,
            records_path='items',
        )

        with pytest.raises(api_errors.PaginationError) as ei:
            list(
                client.paginate_iter('list', pagination=cfg),
            )
        assert ei.value.page == 2
        assert ei.value.status == 500

    def test_rate_limit_overrides_adjust_sleep(
        self,
        base_url: str,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """
        Test that per-call overrides influence paginator pacing.

        When ``rate_limit_overrides`` are provided to ``paginate_url_iter``,
        the computed ``sleep_seconds`` reflects the overrides.
        """
        captured: dict[str, Any] = {}

        def fake_from_config(
            cls: type[EndpointClient],
            config: CursorPaginationConfigDict | PagePaginationConfigDict,
            *,
            fetch: Callable[..., Any],
            rate_limiter: Any,
        ) -> Any:
            captured['rate_limiter'] = rate_limiter
            captured['sleep_seconds'] = (
                getattr(rate_limiter, 'sleep_seconds', 0.0)
                if rate_limiter is not None
                else 0.0
            )

            class _StubPaginator:
                """Stub paginator yielding a single page."""

                def paginate_iter(self, *_args: Any, **_kwargs: Any):
                    """Yield a single stub record."""
                    yield {'id': 1}

            return _StubPaginator()

        monkeypatch.setattr(
            ec_module.Paginator,
            'from_config',
            classmethod(fake_from_config),
        )

        client = EndpointClient(
            base_url=base_url,
            endpoints={'items': '/items'},
            rate_limit={'max_per_sec': 2},
        )

        # pg: PagePaginationConfigDict = {'type': 'page'}
        pg: PagePaginationConfigDict = {'type': PaginationType.PAGE}

        out = list(
            client.paginate_url_iter(
                f'{base_url}/items',
                pagination=pg,
                rate_limit_overrides={'max_per_sec': 4},
            ),
        )

        assert out == [{'id': 1}]
        assert captured['sleep_seconds'] == pytest.approx(0.25)
        limiter = captured['rate_limiter']
        assert limiter is not None
        assert getattr(limiter, 'sleep_seconds', 0.0) == pytest.approx(0.25)

    def test_retry_backoff_sleeps(
        self,
        cursor_cfg: CursorConfigFactory,
        capture_sleeps: list[float],
        jitter: Callable[[list[float]], list[float]],
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that cursor pagination applies retry backoff sleep on failure.

        Cursor pagination applies retry backoff sleep on failure.
        """
        jitter([0.05])

        attempts = {'n': 0}

        def fake_request(
            self: EndpointClient,
            method: str,
            _url: str,
            *,
            session: Any,
            timeout: Any,
            **_k: Any,
        ) -> dict[str, Any]:
            assert method == 'GET'
            attempts['n'] += 1
            if attempts['n'] == 1:
                err = requests.HTTPError('boom')
                err.response = types.SimpleNamespace(status_code=503)
                raise err
            return {'items': [{'i': 1}], 'next': None}

        patch_request_once(fake_request)
        client = client_factory(
            base_url=EXAMPLE_BASE_URL,
            endpoints={},
            retry={'max_attempts': 2, 'backoff': 0.5, 'retry_on': [503]},
        )
        cfg = cursor_cfg(
            cursor_param='cursor',
            cursor_path='next',
            page_size=2,
            records_path='items',
        )

        out = client.paginate_url(f'{EXAMPLE_BASE_URL}/x', cfg)
        assert out == [{'i': 1}]

        # One sleep from the single retry attempt.
        assert len(capture_sleeps) == 1
        assert abs(capture_sleeps[0] - 0.05) < 1e-6
        assert attempts['n'] == 2


class TestErrors:
    """Unit tests for :class:`ApiAuthError`."""

    def test_auth_error_wrapping_on_single_attempt(
        self,
        base_url: str,
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
        fake_http_error_factory: Callable[[int, str], requests.HTTPError],
    ) -> None:
        """
        Test that :class:`ApiAuthError` is raised and wrapped on a single
        attempt.
        """
        client = client_factory(
            base_url=base_url,
            endpoints={'x': '/x'},
        )

        def boom(
            self: EndpointClient,
            method: str,
            url: str,
            *,
            session: Any,
            timeout: Any,
            **_kwargs: dict[str, Any],
        ) -> dict[str, Any]:
            assert method == 'GET'
            raise fake_http_error_factory(401, 'boom')

        patch_request_once(boom)
        with pytest.raises(api_errors.ApiAuthError) as ei:
            client.paginate_url(f'{base_url}/x', None)
        err = ei.value
        assert err.status == 401
        assert err.attempts == 1
        assert err.retried is False
        assert err.retry_policy is None


class TestOffsetPagination:
    """
    Unit tests for offset pagination in :class:`EndpointClient`.

    Tests offset-based pagination logic, including correct offset stepping,
    limit handling, and record truncation.
    """

    def test_offset_pagination_behaves_like_offset(
        self,
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that offset pagination behaves as expected.
        """
        calls: list[dict[str, Any]] = []

        def fake_request(
            self: EndpointClient,
            method: str,
            _url: str,
            *,
            session: Any,
            timeout: Any,
            **kwargs: dict[str, Any],
        ) -> list[dict[str, int]]:
            assert method == 'GET'
            calls.append(kwargs)
            params = kwargs.get('params') or {}
            off = int(params.get('offset', 0))
            limit = int(params.get('limit', 2))
            # return exactly `limit` items until offset reaches 4
            if off >= 4:
                return []
            return [{'i': i} for i in range(off, off + limit)]

        patch_request_once(fake_request)

        client = client_factory(
            base_url=EXAMPLE_BASE_URL,
            endpoints={},
        )
        cfg = cast(
            PagePaginationConfigDict,
            {
                'type': 'offset',
                'page_param': 'offset',
                'size_param': 'limit',
                'start_page': 0,
                'page_size': 2,
                'max_records': 3,
            },
        )

        data = client.paginate_url(f'{EXAMPLE_BASE_URL}/api', cfg)

        # Expected behavior: collects up to max_records using offset stepping.
        assert [r['i'] for r in cast(list[dict[str, int]], data)] == [0, 1, 2]


class TestPagePagination:
    """
    Unit tests for page-based pagination in :class:`EndpointClient`.

    Tests page-based pagination logic, including batch handling, page size
    normalization, error propagation, and query parameter merging.
    """

    def test_stops_on_short_final_batch(
        self,
        page_cfg: Callable[..., PagePaginationConfigDict],
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that pagination stops on a short final batch.
        """
        patch_request_once(
            _page_responder(
                (
                    ({'id': 1}, {'id': 2}),
                    ({'id': 3},),
                ),
            ),
        )
        client = client_factory(
            base_url=EXAMPLE_BASE_URL,
            endpoints={},
        )
        cfg = page_cfg(
            page_param='page',
            size_param='per_page',
            start_page=1,
            page_size=2,
        )
        data = client.paginate_url(f'{EXAMPLE_BASE_URL}/api', cfg)
        assert isinstance(data, list)
        ids = [cast(dict, r)['id'] for r in data]  # type: ignore[index]
        assert ids == [1, 2, 3]

    def test_max_records_cap(
        self,
        page_cfg: Callable[..., PagePaginationConfigDict],
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that max_records parameter truncates results as expected.
        """
        patch_request_once(
            _page_responder(
                tuple(
                    tuple({'p': page, 'i': i} for i in range(3))
                    for page in range(1, 5)
                ),
            ),
        )

        client = client_factory(
            base_url=EXAMPLE_BASE_URL,
            endpoints={},
        )
        cfg = page_cfg(
            page_param='page',
            size_param='per_page',
            start_page=1,
            page_size=3,
            max_records=5,  # Should truncate 2nd page (total would be 6).
        )
        data = client.paginate_url(f'{EXAMPLE_BASE_URL}/x', cfg)
        assert len(data) == 5
        assert all('p' in r for r in data)

    def test_page_size_normalization(
        self,
        page_cfg: Callable[..., PagePaginationConfigDict],
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that page_size is normalized to 1 if set to 0.
        """
        patch_request_once(
            _page_responder(
                (
                    ({'id': 1},),
                    ({'id': 2},),
                    ({'id': 3},),
                ),
            ),
        )

        client = client_factory(
            base_url=EXAMPLE_BASE_URL,
            endpoints={},
        )
        cfg = page_cfg(
            page_param='page',
            size_param='per_page',
            start_page=1,
            page_size=0,
            max_pages=3,
        )
        data = client.paginate_url(f'{EXAMPLE_BASE_URL}/x', cfg)
        assert isinstance(data, list)
        ids = [cast(dict, r)['id'] for r in data]  # type: ignore[index]
        assert ids == [1, 2, 3]

    def test_error_includes_page_number(
        self,
        base_url: str,
        page_cfg: Callable[..., PagePaginationConfigDict],
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
        fake_http_error_factory: Callable[[int, str], requests.HTTPError],
    ) -> None:
        """
        Test that :class:`PaginationError` includes the page number on failure.
        """
        client = client_factory(
            base_url=base_url,
            endpoints={'list': '/items'},
        )
        page_size = 2

        def extractor(
            self: EndpointClient,
            method: str,
            _url: str,
            *,
            session: Any,
            timeout: Any,
            **kwargs: dict[str, Any],
        ) -> dict[str, Any]:
            assert method == 'GET'
            params = kwargs.get('params') or {}
            page = int(params.get('page', 1))
            size = int(params.get('per_page', page_size))
            if page == 4:
                raise fake_http_error_factory(500, 'boom')
            return {'items': [{'i': i} for i in range(size)]}

        # Return exactly `size` records to force continue until failure.
        patch_request_once(extractor)
        cfg = page_cfg(
            page_param='page',
            size_param='per_page',
            start_page=3,
            page_size=page_size,
            records_path='items',
        )

        with pytest.raises(api_errors.PaginationError) as ei:
            client.paginate('list', pagination=cfg)
        # The paginator reports the iteration count (2) rather than literal
        # page 4.
        assert ei.value.page == 2
        assert ei.value.status == 500

    def test_unknown_type_returns_raw(
        self,
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that unknown pagination type returns raw output.
        """

        def _raw_response(
            self: EndpointClient,
            method: str,
            _u: str,
            *,
            session: Any,
            timeout: Any,
            **kwargs: Any,
        ) -> dict[str, str] | None:  # noqa: ARG005
            return {'foo': 'bar'} if method == 'GET' else None

        patch_request_once(_raw_response)
        client = client_factory(
            base_url=EXAMPLE_BASE_URL,
            endpoints={},
        )

        out = client.paginate_url(
            f'{EXAMPLE_BASE_URL}/x',
            cast(Any, {'type': 'weird'}),
        )
        assert out == {'foo': 'bar'}


class TestRateLimitPrecedence:
    """
    Unit tests for rate limit precedence in :class:`EndpointClient`.

    Tests explicit sleep_seconds override, rate_limit config precedence, and
    correct sleep duration application during pagination.
    """

    def test_overrides_sleep_seconds_wins(
        self,
        base_url: str,
        capture_sleeps: list[float],
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that explicit sleep_seconds overrides rate_limit config.
        """
        # :func:`capture_sleeps` fixture already records rate-limiter pacing.
        client = client_factory(
            base_url=base_url,
            endpoints={'list': '/items'},
            rate_limit={'max_per_sec': 2},  # would imply 0.5s if used
        )

        # Patch HTTP request helper to return enough pages for two sleeps.
        calls = {'n': 0}

        def fake_request(
            self: EndpointClient,
            method: str,
            url: str,
            *,
            session: Any,
            timeout: Any,
            **kw: Any,
        ) -> list[dict[str, int]]:  # noqa: D401
            assert method == 'GET'
            calls['n'] += 1
            # Return full page until third call which ends pagination.
            if calls['n'] < 3:
                return [{'i': calls['n']}, {'i': calls['n'] + 10}]
            return []

        patch_request_once(fake_request)

        list(
            client.paginate_iter(
                'list',
                pagination=cast(
                    PagePaginationConfigDict,
                    {
                        'type': 'page',
                        'page_size': 2,
                        'start_page': 1,
                    },
                ),
                sleep_seconds=0.05,  # explicit override should win
            ),
        )

        # We expect exactly two sleeps (between three page fetches) and both
        # should reflect explicit override (0.05) not derived 0.5.
        assert capture_sleeps == [pytest.approx(0.05), pytest.approx(0.05)]


class TestRetryLogic:
    """
    Unit tests for retry logic in :class:`EndpointClient`.

    This suite covers:
    - Retry behavior for request errors
    - Jitter backoff and sleep duration
    - Network error handling and retry policy application
    - Error propagation and attempt counting

    Notes
    -----
    - Uses monkeypatch to simulate failures and control retry attempts.
    - Uses pytest fixtures for retry configuration and sleep capture.
    """

    def test_request_error_after_retries_exhausted(
        self,
        base_url: str,
        retry_cfg: Callable[..., dict[str, Any]],
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
        fake_http_error_factory: Callable[[int, str], requests.HTTPError],
    ) -> None:
        """
        Test that :class:`ApiRequestError` is raised after retries are
        exhausted.
        """
        client = client_factory(
            base_url=base_url,
            endpoints={'x': '/x'},
            retry=cast(
                RetryPolicyDict,
                retry_cfg(max_attempts=2, backoff=0.0, retry_on=[503]),
            ),
        )
        attempts = {'n': 0}

        def boom(
            self: EndpointClient,
            method: str,
            _url: str,
            *,
            session: Any,
            timeout: Any,
            **_kwargs: dict[str, Any],
        ) -> dict[str, Any]:
            assert method == 'GET'
            attempts['n'] += 1
            raise fake_http_error_factory(503, 'boom')

        patch_request_once(boom)

        with pytest.raises(api_errors.ApiRequestError) as ei:
            client.paginate_url(f'{base_url}/x', None)
        err = ei.value
        assert isinstance(err, api_errors.ApiRequestError)
        assert err.status == 503
        assert err.attempts == 2  # Exhausted
        assert err.retried is True

    def test_full_jitter_backoff(
        self,
        base_url: str,
        capture_sleeps: list[float],
        retry_cfg: Callable[..., dict[str, Any]],
        jitter: Callable[[list[float]], list[float]],
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that full jitter backoff is applied on retries.
        """
        jitter([0.1, 0.2])

        # Patch HTTP helper to fail with 503 twice, then succeed.
        attempts = {'n': 0}

        def _fake_request(
            self: EndpointClient,
            method: str,
            _url: str,
            *,
            session: Any,
            timeout: Any,
            **_kwargs: dict[str, Any],
        ) -> dict[str, Any]:
            assert method == 'GET'
            attempts['n'] += 1
            if attempts['n'] < 3:
                err = requests.HTTPError('boom')
                err.response = types.SimpleNamespace(status_code=503)
                raise err
            return {'ok': True}

        patch_request_once(_fake_request)

        client = client_factory(
            base_url=base_url,
            endpoints={},
            retry=cast(
                RetryPolicyDict,
                retry_cfg(max_attempts=4, backoff=0.5, retry_on=[503]),
            ),
        )
        out = client.paginate_url(f'{base_url}/items', None)
        assert out == {'ok': True}

        # Should have slept twice (between the 3 attempts).
        assert len(capture_sleeps) == 2
        assert abs(capture_sleeps[0] - 0.1) < 1e-6
        assert abs(capture_sleeps[1] - 0.2) < 1e-6
        assert attempts['n'] == 3

    def test_retry_on_network_errors(
        self,
        base_url: str,
        capture_sleeps: list[float],
        retry_cfg: Callable[..., dict[str, Any]],
        jitter: Callable[[list[float]], list[float]],
        patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
        client_factory: Callable[..., EndpointClient],
    ) -> None:
        """
        Test that network errors are retried and sleep durations are captured.
        """
        jitter([0.12, 0.18])
        attempts = {'n': 0}

        def _fake_request(
            self: EndpointClient,
            method: str,
            _url: str,
            *,
            session: Any,
            timeout: Any,
            **_kwargs: dict[str, Any],
        ) -> dict[str, Any]:
            assert method == 'GET'
            attempts['n'] += 1
            if attempts['n'] == 1:
                raise requests.Timeout('slow')
            if attempts['n'] == 2:
                raise requests.ConnectionError('reset')
            return {'ok': True}

        patch_request_once(_fake_request)

        client = client_factory(
            base_url=base_url,
            endpoints={},
            retry=cast(
                RetryPolicyDict,
                retry_cfg(max_attempts=4, backoff=0.5),
            ),
            retry_network_errors=True,
        )
        out = client.paginate_url(f'{base_url}/items', None)
        assert out == {'ok': True}

        # Should have slept twice (after 2 failures).
        assert len(capture_sleeps) == 2
        assert abs(capture_sleeps[0] - 0.12) < 1e-6
        assert abs(capture_sleeps[1] - 0.18) < 1e-6
        assert attempts['n'] == 3


class TestUrlComposition:
    """
    Unit tests for URL composition in :class:`EndpointClient`.

    This suite covers:
    - Base path variants and endpoint joining
    - Query parameter merging and encoding
    - Path encoding and duplicate parameter handling
    - Query parameter ordering in composed URLs

    Notes
    -----
    - Uses pytest parameterization for variant coverage.
    - Ensures all composed URLs match expected output.
    """

    @pytest.mark.parametrize(
        ('base_url_suffix', 'base_path', 'endpoint', 'expected_suffix'),
        [
            ('', 'v2', 'items', '/v2/items'),
            ('', '/v2', '/items', '/v2/items'),
            ('/api', 'v1', '/items', '/api/v1/items'),
            # Note: trailing slashes on base_url/base_path not normalized by
            # client.
        ],
    )
    def test_base_path_variants(
        self,
        request_once_stub: dict[str, Any],
        base_url: str,
        base_url_suffix: str,
        base_path: str,
        endpoint: str,
        expected_suffix: str,
    ) -> None:
        """
        Test that base_path variants are composed correctly in URLs.
        """
        client = EndpointClient(
            base_url=f'{base_url}{base_url_suffix}',
            endpoints={'list': endpoint},
            base_path=base_path,
        )
        out = client.paginate('list', pagination=None)
        assert out == {'ok': True}
        assert request_once_stub['urls'] == [f'{base_url}{expected_suffix}']

    def test_query_merging_and_path_encoding(
        self,
        base_url: str,
        request_once_stub: dict[str, Any],
    ) -> None:
        """
        Test that query parameters are merged and path parameters are encoded.
        """
        client = EndpointClient(
            base_url=f'{base_url}?existing=a&dup=1',
            endpoints={'item': '/users/{id}'},
        )

        out = client.paginate(
            'item',
            path_parameters={'id': 'A/B C'},
            query_parameters={'q': 'x y', 'dup': '2'},
            pagination=None,
        )
        assert out == {'ok': True}
        assert request_once_stub['urls'][0] == (
            f'{base_url}/users/A%2FB%20C?existing=a&dup=1&q=x+y&dup=2'
        )

    def test_query_merging_duplicate_base_params(
        self,
        base_url: str,
        request_once_stub: dict[str, Any],
    ) -> None:
        """
        Test that duplicate base query parameters are merged correctly.
        """
        client = EndpointClient(
            base_url=f'{base_url}?dup=1&dup=2&z=9',
            endpoints={'e': '/ep'},
        )
        client.paginate(
            'e',
            query_parameters={'dup': '3', 'a': '1'},
            pagination=None,
        )
        assert request_once_stub['urls'][0] == (
            f'{base_url}/ep?dup=1&dup=2&z=9&dup=3&a=1'
        )

    def test_query_param_ordering(
        self,
        base_url: str,
        request_once_stub: dict[str, Any],
    ) -> None:
        """
        Test that query parameter ordering is preserved in composed URLs.
        """
        client = EndpointClient(
            base_url=f'{base_url}?z=9&dup=1',
            endpoints={'e': '/ep'},
        )
        client.paginate(
            'e',
            query_parameters={'a': '1', 'dup': '2'},
            pagination=None,
        )
        assert request_once_stub['urls'][0] == (
            f'{base_url}/ep?z=9&dup=1&a=1&dup=2'
        )


class TestEndpointClientInternalBranches:
    """Targeted branch coverage for constructor and helper edge paths."""

    def test_apply_sleep_uses_default_and_custom_sleepers(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """
        Test that :meth:`apply_sleep` calls either :func:`time.sleep` or
        provided sleeper.
        """
        recorded: list[float] = []
        monkeypatch.setattr(ec_module.time, 'sleep', recorded.append)

        EndpointClient.apply_sleep(0.05)
        assert recorded == [0.05]

        custom: list[float] = []
        EndpointClient.apply_sleep(0.1, sleeper=custom.append)
        assert custom == [0.1]

        EndpointClient.apply_sleep(0.0, sleeper=custom.append)
        assert custom == [0.1]

    def test_paginate_url_uses_object_type_for_non_mapping_pagination(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """
        Test that object pagination configs use their ``.type`` attribute.
        """
        client = EndpointClient(
            base_url=EXAMPLE_BASE_URL,
            endpoints={'items': '/items'},
        )
        seen: dict[str, Any] = {}

        def _paginate_url_iter(
            _self: EndpointClient,
            url: str,
            pagination: Any = None,
            *,
            request: RequestOptions | None = None,
            sleep_seconds: float = 0.0,
            rate_limit_overrides: Any = None,
        ) -> Iterator[dict[str, Any]]:
            seen['url'] = url
            seen['pagination'] = pagination
            seen['request'] = request
            seen['sleep_seconds'] = sleep_seconds
            seen['rate_limit_overrides'] = rate_limit_overrides
            yield {'id': 1}

        monkeypatch.setattr(
            EndpointClient,
            'paginate_url_iter',
            _paginate_url_iter,
        )

        pagination_obj = types.SimpleNamespace(type='page')
        out = client.paginate_url(
            f'{EXAMPLE_BASE_URL}/items',
            pagination=cast(Any, pagination_obj),
            sleep_seconds=0.2,
        )
        assert out == [{'id': 1}]
        assert seen['pagination'] is pagination_obj
        assert seen['url'] == f'{EXAMPLE_BASE_URL}/items'

    def test_post_and_request_delegate_to_request_manager(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """
        Test that thin wrappers delegate to the embedded request manager.
        """
        client = EndpointClient(base_url=EXAMPLE_BASE_URL, endpoints={})
        seen: dict[str, Any] = {}

        def _post(
            _self: rm_module.RequestManager,
            url: str,
            **kwargs: Any,
        ) -> dict[str, Any]:
            seen['post'] = (url, kwargs)
            return {'ok': 'post'}

        def _request(
            _self: rm_module.RequestManager,
            method: str,
            url: str,
            **kwargs: Any,
        ) -> dict[str, Any]:
            seen['request'] = (method, url, kwargs)
            return {'ok': 'request'}

        monkeypatch.setattr(rm_module.RequestManager, 'post', _post)
        monkeypatch.setattr(rm_module.RequestManager, 'request', _request)

        out_post = client.post('https://example.test/p', json={'x': 1})
        out_req = client.request('PATCH', 'https://example.test/r', timeout=3)

        assert out_post == {'ok': 'post'}
        assert out_req == {'ok': 'request'}
        assert seen['post'][0] == 'https://example.test/p'
        assert seen['request'][0] == 'PATCH'

    def test_post_init_prefers_explicit_session_and_normalizes_adapters(
        self,
        mock_session: MockSession,
    ) -> None:
        """
        Test that explicit session disables factory; adapters are tuple-
        normalized.
        """
        client = EndpointClient(
            base_url=EXAMPLE_BASE_URL,
            endpoints={'list': '/items'},
            session=cast(requests.Session, mock_session),
            session_factory=lambda: cast(requests.Session, MockSession()),
            session_adapters=[{'prefix': 'https://'}],
        )
        assert client.session is mock_session
        assert client.session_factory is None
        assert isinstance(client.session_adapters, tuple)

    def test_post_init_requires_absolute_base_url(self) -> None:
        """Test that relative base_url values be rejected."""
        with pytest.raises(ValueError, match='base_url must be absolute'):
            EndpointClient(base_url='/relative', endpoints={})

    @pytest.mark.parametrize(
        'endpoints',
        [
            {'ok': ''},
            {1: '/x'},  # type: ignore[dict-item]
        ],
    )
    def test_post_init_validates_endpoints_mapping(
        self,
        endpoints: dict[Any, Any],
    ) -> None:
        """
        Test that endpoint mapping must be string keys and non-empty string
        values.
        """
        with pytest.raises(ValueError, match='endpoints must map str'):
            EndpointClient(
                base_url=EXAMPLE_BASE_URL,
                endpoints=cast(Mapping[str, str], endpoints),
            )

    def test_url_raises_for_unknown_endpoint(self) -> None:
        """Test that unknown endpoint keys raise KeyError."""
        client = EndpointClient(
            base_url=EXAMPLE_BASE_URL,
            endpoints={'known': '/k'},
        )
        with pytest.raises(KeyError, match='Unknown endpoint_key'):
            client.url('missing')

    def test_url_raises_for_missing_path_placeholder(self) -> None:
        """
        Test that missing path placeholders raise KeyError with context.
        """
        client = EndpointClient(
            base_url=EXAMPLE_BASE_URL,
            endpoints={'item': '/items/{id}'},
        )
        with pytest.raises(KeyError, match='Missing path parameter'):
            client.url('item', path_parameters={})

    def test_url_raises_for_invalid_path_template(self) -> None:
        """Test that malformed path format strings raise ValueError."""
        client = EndpointClient(
            base_url=EXAMPLE_BASE_URL,
            endpoints={'bad': '/items/{id'},
        )
        with pytest.raises(ValueError, match='Invalid path template'):
            client.url('bad', path_parameters={'id': 1})


@pytest.mark.property
class TestUrlCompositionProperty:
    """
    Unit tests for property-based URL composition using Hypothesis.

    This suite covers:
    - Path parameter encoding for arbitrary strings
    - Query parameter encoding for arbitrary dictionaries

    Notes
    -----
    - Uses Hypothesis strategies for robust property-based testing.
    - Ensures all encoded URLs match expected output.
    """

    @given(
        id_value=st.text(
            alphabet=st.characters(blacklist_categories=('Cs',)),
            min_size=1,
        ),
    )
    def test_path_parameter_encoding_property(
        self,
        id_value: str,
        extract_stub_factory: Callable[..., Any],
    ) -> None:
        """Test path-parameter encoding in URLs."""
        with extract_stub_factory() as calls:  # type: ignore[call-arg]
            client = EndpointClient(
                base_url=EXAMPLE_BASE_URL,
                endpoints={'item': '/users/{id}'},
            )
            client.paginate(
                'item',
                path_parameters={'id': id_value},
                pagination=None,
            )
            assert calls['urls'], 'no URL captured'
            url = calls['urls'].pop()
            parsed = urlparse.urlparse(url)
            expected_id = urlparse.quote(id_value, safe='')
            assert parsed.path.endswith('/users/' + expected_id)

    @given(
        params=st.dictionaries(
            keys=_ascii_no_amp_eq().filter(lambda s: len(s) > 0),
            values=_ascii_no_amp_eq(),
            min_size=1,
            max_size=5,
        ),
    )
    def test_query_encoding_property(
        self,
        params: dict[str, str],
        extract_stub_factory: Callable[..., Any],
    ) -> None:
        """Test query-parameter encoding in URLs."""
        with extract_stub_factory() as calls:  # type: ignore[call-arg]
            client = EndpointClient(
                base_url=EXAMPLE_BASE_URL,
                endpoints={'e': '/ep'},
            )
            client.paginate('e', query_parameters=params, pagination=None)
            assert calls['urls'], 'no URL captured'
            url = calls['urls'].pop()
            parsed = urlparse.urlparse(url)
            round_params = dict(
                urlparse.parse_qsl(parsed.query, keep_blank_values=True),
            )
            assert round_params == params
