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

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

Notes
-----
- Exercises both flat and profiled API shapes.
- Uses factories for building profile defaults mappings.
- Verifies precedence and propagation of headers and ``base_path``.
"""

from __future__ import annotations

from collections.abc import Callable
from typing import Any
from typing import cast

import pytest

import etlplus.api.config as config_module
from etlplus.api import ApiConfig
from etlplus.api import ApiProfileConfig
from etlplus.api import EndpointConfig
from etlplus.api import HttpMethod
from etlplus.api import PaginationConfig
from etlplus.api import RateLimitConfig

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


class TestApiConfig:
    """
    Unit tests for :class:`ApiConfig`.

    Notes
    -----
    Tests mapping of rate limit, header precedence, base_path propagation, and
    profile/default behaviors for API configuration.
    """

    @pytest.mark.parametrize(
        'sleep,max_per',
        [
            (0.5, 2),
            (0.1, 10),
        ],
        ids=['basic', 'higher'],
    )
    def test_api_profile_defaults_rate_limit_mapped(
        self,
        base_url: str,
        api_config_factory: Callable[[dict], ApiConfig],
        sleep: float,
        max_per: int,
    ) -> None:
        """
        Test that API profile defaults for rate limit are mapped correctly.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        api_config_factory : Callable[[dict], ApiConfig]
            Factory for building ApiConfig from dicts.
        sleep : float
            Sleep seconds for rate limit.
        max_per : int
            Max per second for rate limit.

        Returns
        -------
        None
        """
        obj = {
            'profiles': {
                'default': {
                    'base_url': base_url,
                    'defaults': {
                        'rate_limit': {
                            'sleep_seconds': sleep,
                            'max_per_sec': max_per,
                        },
                    },
                },
            },
            'endpoints': {},
        }
        cfg = api_config_factory(obj)
        prof = cfg.profiles['default']
        rdef = getattr(prof, 'rate_limit_defaults', None)
        assert rdef is not None
        assert rdef.sleep_seconds == sleep
        assert rdef.max_per_sec == max_per

    def test_effective_base_url_and_build_endpoint_url(
        self,
        base_url: str,
        api_obj_factory: Callable[..., dict[str, Any]],
        api_config_factory: Callable[[dict[str, Any]], ApiConfig],
    ) -> None:
        """
        Test that effective_base_url and build_endpoint_url compose URLs
        correctly.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        api_obj_factory : Callable[..., dict[str, Any]]
            Factory for building API config objects.
        api_config_factory : Callable[[dict[str, Any]], ApiConfig]
            Factory for building :class:`ApiConfig` from dicts.
        """
        obj = api_obj_factory(
            use_profiles=True,
            base_path='/v1',
            endpoints={'users': {'path': '/users'}},
        )
        cfg = api_config_factory(obj)

        # Effective base URL composes base_url + base_path.
        expected_base = f'{base_url}/v1'
        assert cfg.effective_base_url() == expected_base
        url = cfg.build_endpoint_url(cfg.endpoints['users'])
        assert url == f'{expected_base}/users'

    def test_flat_shape_supported(
        self,
        base_url: str,
        api_obj_factory: Callable[..., dict[str, Any]],
        api_config_factory: Callable[[dict[str, Any]], ApiConfig],
    ) -> None:
        """
        Test that flat API config shape is supported and headers/endpoints
        are parsed.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        api_obj_factory : Callable[..., dict[str, Any]]
            Factory for building API config objects.
        api_config_factory : Callable[[dict[str, Any]], ApiConfig]
            Factory for building :class:`ApiConfig` from dicts.
        """
        obj = api_obj_factory(
            use_profiles=False,
            base_path=None,
            headers={'X-Token': 'abc'},
            endpoints={'ping': '/ping'},
        )
        cfg = api_config_factory(obj)
        assert cfg.base_url == base_url
        assert cfg.headers.get('X-Token') == 'abc'
        assert 'ping' in cfg.endpoints

    def test_parses_profiles_and_sets_defaults(
        self,
        base_url: str,
        api_config_factory: Callable[[dict[str, Any]], ApiConfig],
    ) -> None:
        """
        Test that profiles are parsed and default values are set correctly.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        api_config_factory : Callable[[dict[str, Any]], ApiConfig]
            Factory for building :class:`ApiConfig` from dicts.
        """
        obj = {
            'profiles': {
                'default': {
                    'base_url': f'{base_url}/v1',
                    'headers': {'Accept': 'application/json'},
                },
                'prod': {
                    'base_url': f'{base_url}/v2',
                    'headers': {'Accept': 'application/json'},
                },
            },
            'endpoints': {'list': {'path': '/items'}},
        }
        cfg = api_config_factory(obj)

        # Default base_url/headers should be derived from the 'default'
        # profile.

        assert cfg.base_url == f'{base_url}/v1'
        assert cfg.headers.get('Accept') == 'application/json'

        # Profiles should be preserved.
        assert {'default', 'prod'} <= set(cfg.profiles.keys())

        # Endpoint should parse.
        assert 'list' in cfg.endpoints

    def test_profile_attr_with_default(
        self,
        base_url: str,
        api_config_factory: Callable[[dict[str, Any]], ApiConfig],
    ) -> None:
        """
        Test that profile attributes with defaults are handled correctly.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        api_config_factory : Callable[[dict[str, Any]], ApiConfig]
            Factory for building :class:`ApiConfig` from dicts.
        """
        obj = {
            'profiles': {
                'default': {
                    'base_url': base_url,
                    'base_path': '/v1',
                    'defaults': {
                        'pagination': {'type': 'page'},
                        'rate_limit': {'sleep_seconds': 0.1},
                    },
                },
                'other': {'base_url': 'https://api.other'},
            },
            'endpoints': {},
        }
        cfg = api_config_factory(obj)

        # Effective getters rely on the internal helper; verify behavior.
        assert cfg.effective_base_path() == '/v1'
        assert cfg.effective_pagination_defaults() is not None

    def test_profile_attr_without_profiles_returns_none(
        self,
        base_url: str,
        api_config_factory: Callable[[dict[str, Any]], ApiConfig],
    ) -> None:
        """
        Test that profile attribute access returns None when profiles are
        absent.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        api_config_factory : Callable[[dict[str, Any]], ApiConfig]
            Factory for building :class:`ApiConfig` from dicts.
        """
        obj = {'base_url': base_url, 'endpoints': {}}
        cfg = api_config_factory(obj)
        assert cfg.effective_base_path() is None

    def test_profile_defaults_headers_and_fields(
        self,
        base_url: str,
        api_config_factory: Callable[[dict[str, Any]], ApiConfig],
    ) -> None:
        """
        Test that header precedence and profile fields are handled correctly.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        api_config_factory : Callable[[dict[str, Any]], ApiConfig]
            Factory for building :class:`ApiConfig` from dicts.
        """
        obj = {
            'profiles': {
                'default': {
                    'base_url': f'{base_url}/v1',
                    'defaults': {
                        'headers': {
                            'Accept': 'application/json',
                            'X-From-Defaults': '1',
                        },
                    },
                    'headers': {
                        'Authorization': 'Bearer token',
                        'X-From-Defaults': '2',
                    },
                    'base_path': '/v1',
                    'auth': {'type': 'bearer', 'token': 'abc'},
                },
            },
            'headers': {'X-Top': 't'},
            'endpoints': {},
        }
        cfg = api_config_factory(obj)

        # Headers: defaults.headers < profile.headers < top-level.
        assert cfg.headers['Accept'] == 'application/json'
        assert cfg.headers['Authorization'] == 'Bearer token'

        # Profile.headers overrides defaults.
        assert cfg.headers['X-From-Defaults'] == '2'

        # Top-level overrides/augments.
        assert cfg.headers['X-Top'] == 't'

        # Profile extras captured.
        prof = cfg.profiles['default']
        assert prof.base_path == '/v1'
        assert prof.auth.get('type') == 'bearer'

    def test_profile_defaults_pagination_mapped(
        self,
        base_url: str,
        api_config_factory: Callable[[dict[str, Any]], ApiConfig],
    ) -> None:
        """
        Test that pagination defaults are mapped correctly in profiles.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        api_config_factory : Callable[[dict[str, Any]], ApiConfig]
            Factory for building :class:`ApiConfig` from dicts.
        """
        obj = {
            'profiles': {
                'default': {
                    'base_url': base_url,
                    'defaults': {
                        'pagination': {
                            'type': 'page',
                            'params': {
                                'page': 'page',
                                'per_page': 'per_page',
                                'cursor': 'cursor',
                                'limit': 'limit',
                            },
                            'response': {
                                'items_path': 'data.items',
                                'next_cursor_path': 'meta.next_cursor',
                            },
                            'defaults': {'per_page': 25},
                            'max_pages': 10,
                        },
                    },
                },
            },
            'endpoints': {},
        }

        cfg = api_config_factory(obj)
        prof = cfg.profiles['default']
        pdef = getattr(prof, 'pagination_defaults', None)
        assert pdef is not None
        assert pdef.type == 'page'
        assert pdef.page_param == 'page'
        assert pdef.size_param == 'per_page'
        assert pdef.cursor_param == 'cursor'
        assert pdef.cursor_path == 'meta.next_cursor'
        assert pdef.records_path == 'data.items'
        assert pdef.page_size == 25
        assert pdef.max_pages == 10


class TestApiProfileConfig:
    """
    Unit tests for :class:`ApiProfileConfig`.

    Notes
    -----
    Tests parsing and precedence of defaults, headers, and required fields in
    API profile configuration.
    """

    @pytest.mark.parametrize(
        'defaults',
        [
            {'pagination': 'not-a-dict'},
            {'pagination': {'type': 123}},
            {'rate_limit': 'oops'},
            {'rate_limit': {'sleep_seconds': 'x', 'max_per_sec': []}},
        ],
        ids=[
            'pagination-str',
            'pagination-type-bad',
            'rate-limit-str',
            'rate-limit-bad-values',
        ],
    )
    def test_invalid_defaults_blocks(
        self,
        base_url: str,
        defaults: dict[str, object],
        profile_config_factory: Callable[[dict[str, Any]], ApiProfileConfig],
    ) -> None:
        """
        Test that invalid defaults blocks yield None or sanitized values.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        defaults : dict[str, object]
            Defaults block to test.
        profile_config_factory : Callable[[dict[str, Any]], ApiProfileConfig]
            Factory for building :class:`ApiProfileConfig` from dicts.
        """
        obj = {
            'base_url': base_url,
            'defaults': defaults,
        }
        prof = profile_config_factory(obj)

        # Invalid blocks should yield None defaults objects or sanitized
        # values.
        if 'pagination' in defaults:
            assert getattr(prof, 'pagination_defaults', None) in (
                None,
                prof.pagination_defaults,
            )
        if 'rate_limit' in defaults:
            assert getattr(prof, 'rate_limit_defaults', None) in (
                None,
                prof.rate_limit_defaults,
            )

    def test_merges_headers_defaults_low_precedence(
        self,
        base_url: str,
        profile_config_factory: Callable[[dict[str, Any]], ApiProfileConfig],
    ) -> None:
        """
        Test that headers from defaults are merged with low precedence.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        profile_config_factory : Callable[[dict[str, Any]], ApiProfileConfig]
            Factory for building :class:`ApiProfileConfig` from dicts.
        """
        obj = {
            'base_url': base_url,
            'headers': {'B': '2', 'A': '9'},
            'defaults': {'headers': {'A': '1'}},
        }
        prof = profile_config_factory(obj)
        assert prof.base_url == base_url
        assert prof.headers == {'A': '9', 'B': '2'}

    def test_parses_defaults_blocks(
        self,
        base_url: str,
        profile_config_factory: Callable[[dict[str, Any]], ApiProfileConfig],
        api_profile_defaults_factory: Callable[..., dict[str, Any]],
    ) -> None:
        """
        Test that defaults blocks are parsed and types are correct.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        profile_config_factory : Callable[[dict[str, Any]], ApiProfileConfig]
            Factory for building ApiProfileConfig from dicts.
        api_profile_defaults_factory : Callable[..., dict[str, Any]]
            Factory for building defaults blocks.
        """
        obj = {
            'base_url': base_url,
            'defaults': api_profile_defaults_factory(
                pagination={
                    'type': 'page',
                    'page_param': 'p',
                    'size_param': 's',
                },
                rate_limit={'sleep_seconds': 0.1, 'max_per_sec': 5},
            ),
        }
        prof = profile_config_factory(obj)

        # Ensure types are parsed.
        assert isinstance(
            prof.pagination_defaults,
            (PaginationConfig, type(None)),
        )
        assert isinstance(
            prof.rate_limit_defaults,
            (RateLimitConfig, type(None)),
        )

        # Spot-check key fields.
        if prof.pagination_defaults is not None:
            assert prof.pagination_defaults.type == 'page'
            assert prof.pagination_defaults.page_param == 'p'
            assert prof.pagination_defaults.size_param == 's'
        if prof.rate_limit_defaults is not None:
            assert prof.rate_limit_defaults.sleep_seconds == 0.1
            assert prof.rate_limit_defaults.max_per_sec == 5

    def test_passthrough_fields(
        self,
        base_url: str,
        profile_config_factory: Callable[[dict[str, Any]], ApiProfileConfig],
    ) -> None:
        """
        Test that passthrough fields (base_path, auth) are preserved.

        Parameters
        ----------
        base_url : str
            Common base URL used across tests.
        profile_config_factory : Callable[[dict[str, Any]], ApiProfileConfig]
            Factory for building :class:`ApiProfileConfig` from dicts.
        """
        obj = {
            'base_url': base_url,
            'base_path': '/v1',
            'auth': {'token': 'abc'},
        }
        prof = profile_config_factory(obj)
        assert prof.base_url == base_url
        assert prof.base_path == '/v1'
        assert prof.auth == {'token': 'abc'}

    def test_requires_base_url(
        self,
        profile_config_factory: Callable[[dict[str, Any]], ApiProfileConfig],
    ) -> None:
        """
        Test that base_url is required for :class:`ApiProfileConfig`.

        Parameters
        ----------
        profile_config_factory : Callable[[dict[str, Any]], ApiProfileConfig]
            Factory for building :class:`ApiProfileConfig` from dicts.
        """
        with pytest.raises(TypeError):
            profile_config_factory({})


class TestEndpointConfig:
    """
    Unit tests for :class:`EndpointConfig`.

    Notes
    -----
    Tests parsing and validation of endpoint configuration fields and error
    handling.
    """

    def test_captures_path_params_and_body(
        self,
        endpoint_config_factory: Callable[[dict[str, Any]], EndpointConfig],
    ) -> None:
        """
        Test that path_params, query_params, and body are captured correctly.

        Parameters
        ----------
        endpoint_config_factory : Callable[[dict[str, Any]], EndpointConfig]
            Factory for building :class:`EndpointConfig` from dicts.
        """
        ep = endpoint_config_factory(
            {
                'method': 'POST',
                'path': '/users/{id}/avatar',
                'path_params': {'id': 'int'},
                'query_params': {'size': 'large'},
                'body': {'type': 'file', 'file_path': './x.png'},
            },
        )
        assert ep.method == 'POST'
        assert ep.path_params == {'id': 'int'}
        assert isinstance(ep.body, dict) and ep.body['type'] == 'file'
        assert ep.query_params == {'size': 'large'}

    def test_from_str_sets_no_method(
        self,
        endpoint_config_factory: Callable[[str], EndpointConfig],
    ) -> None:
        """
        Test that from_str sets no method for :class:`EndpointConfig`.

        Parameters
        ----------
        endpoint_config_factory : Callable[[str], EndpointConfig]
            Factory for building :class:`EndpointConfig` from string.
        """
        ep = endpoint_config_factory('/ping')
        assert ep.path == '/ping'
        assert ep.method is None

    @pytest.mark.parametrize(
        'payload, expected_exception',
        [
            ({'method': 'GET'}, TypeError),  # missing path
            ({'path': 123}, TypeError),  # path wrong type
            (
                {'path': '/x', 'path_params': 'id'},
                ValueError,
            ),  # string -> dict() raises ValueError
            (
                {'path': '/x', 'query_params': 1},
                TypeError,
            ),  # int -> dict() raises TypeError
        ],
        ids=[
            'missing-path',
            'path-not-str',
            'path_params-not-mapping',
            'query_params-not-mapping',
        ],
    )
    def test_invalid_payloads_raise(
        self,
        payload: dict[str, object],
        expected_exception: type[Exception],
        endpoint_config_factory: Callable[[str], EndpointConfig],
    ) -> None:
        """
        Test that invalid payloads raise the expected exceptions.

        Parameters
        ----------
        payload : dict[str, object]
            Payload to test for error handling.
        expected_exception : type[Exception]
            Expected exception type.
        endpoint_config_factory : Callable[[str], EndpointConfig]
            Factory for building :class:`EndpointConfig` from string.
        """
        with pytest.raises(expected_exception):
            endpoint_config_factory(payload)  # type: ignore[arg-type]

    def test_lenient_fields_do_not_raise(
        self,
        endpoint_config_factory: Callable[[dict[str, Any]], EndpointConfig],
    ) -> None:
        """
        Test that lenient fields (method/body) do not raise errors and are
        permissive.

        Parameters
        ----------
        endpoint_config_factory : Callable[[dict[str, Any]], EndpointConfig]
            Factory for building :class:`EndpointConfig` from dicts.
        """
        # Lenient fields (method/body) accept any type and pass through.
        ep_method = endpoint_config_factory({'method': 200, 'path': '/x'})
        assert ep_method.method == 200  # library currently permissive
        ep_body = endpoint_config_factory({'path': '/x', 'body': 'json'})
        assert ep_body.body == 'json'

    def test_parses_method(
        self,
        endpoint_config_factory: Callable[[dict[str, Any]], EndpointConfig],
    ) -> None:
        """
        Test that method and query_params are parsed correctly in
        :class:`EndpointConfig`.

        Parameters
        ----------
        endpoint_config_factory : Callable[[dict[str, Any]], EndpointConfig]
            Factory for building :class:`EndpointConfig` from dicts.
        """
        ep = endpoint_config_factory(
            {
                'method': 'GET',
                'path': '/users',
                'query_params': {'active': True},
            },
        )
        assert ep.path == '/users'
        assert ep.method == 'GET'
        assert ep.query_params.get('active') is True


class TestConfigInternalBranches:
    """Targeted branch tests for internal config helpers."""

    # pylint: disable=protected-access

    def test_api_profile_config_from_obj_requires_mapping(self) -> None:
        """ApiProfileConfig.from_obj should fail for non-mapping inputs."""
        with pytest.raises(TypeError, match='must be a mapping'):
            ApiProfileConfig.from_obj(cast(Any, 1))

    def test_api_config_from_obj_requires_mapping(self) -> None:
        """ApiConfig.from_obj should fail on non-mapping values."""
        with pytest.raises(TypeError, match='must be a mapping'):
            ApiConfig.from_obj(cast(Any, 1))

    def test_effective_defaults_requires_string_base_url(self) -> None:
        """Fallback base URL must be a string when profiles are absent."""
        with pytest.raises(TypeError, match='base_url'):
            config_module._effective_service_defaults(
                profiles={},
                fallback_base=123,
                fallback_headers={},
            )

    def test_endpoint_config_from_obj_rejects_invalid_shape(self) -> None:
        """EndpointConfig.from_obj accepts only string or mapping inputs."""
        with pytest.raises(TypeError, match='expected str or mapping'):
            EndpointConfig.from_obj(cast(Any, [1, 2, 3]))

    def test_effective_rate_limit_defaults_returns_selected_profile_value(
        self,
        base_url: str,
        api_config_factory: Callable[[dict[str, Any]], ApiConfig],
    ) -> None:
        """Rate-limit defaults should come from selected profile."""
        cfg = api_config_factory(
            {
                'profiles': {
                    'default': {
                        'base_url': base_url,
                        'defaults': {
                            'rate_limit': {'sleep_seconds': 0.25},
                        },
                    },
                },
                'endpoints': {},
            },
        )
        rate_limit = cfg.effective_rate_limit_defaults()
        assert rate_limit is not None
        assert rate_limit.sleep_seconds == 0.25

    def test_normalize_method_branches(self) -> None:
        """Method normalizer handles enum, blank, and invalid values."""
        assert config_module._normalize_method(HttpMethod.GET) == 'GET'
        assert config_module._normalize_method('   ') is None
        with pytest.raises(ValueError, match='Unsupported HTTP method'):
            config_module._normalize_method('tracee')

    def test_parse_profiles_skips_non_mapping_entries(
        self,
        base_url: str,
    ) -> None:
        """Skip profile entries that are not mappings."""
        raw = {
            'good': {'base_url': base_url},
            'bad': ['not', 'mapping'],
        }
        parsed = config_module._parse_profiles(raw)
        assert list(parsed.keys()) == ['good']
