"""Tests for entity resolution - ISIN lookup and Asset creation."""

from unittest.mock import MagicMock, Mock

import pytest
from httpx import ConnectError, HTTPStatusError, Request, Response, TimeoutException

from yukka.entities import Asset, EntityNotFound, EntityResolver


class TestEntityResolver:
    """Tests for EntityResolver class."""

    @pytest.fixture
    def resolver(self, mock_http_client: MagicMock) -> EntityResolver:
        """Create an EntityResolver with mocked HTTP client."""
        return EntityResolver(
            _client=mock_http_client,
            _token_getter=lambda: "test_token",
        )

    def test_resolve_single_isin_success(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """Single ISIN should be resolved successfully."""
        mock_http_client.post.return_value = mock_response_factory(
            json_data={"DE0005190003": {"compound_key": "company:bmw"}}
        )

        result = resolver.resolve_isin("DE0005190003")

        assert result == "company:bmw"
        mock_http_client.post.assert_called_once()

    def test_resolve_single_isin_not_found(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """ISIN not in ontology should raise EntityNotFound."""
        mock_http_client.post.return_value = mock_response_factory(json_data={"INVALID123456": None})

        with pytest.raises(EntityNotFound) as exc_info:
            resolver.resolve_isin("INVALID123456")

        assert "INVALID123456" in str(exc_info.value)

    def test_resolve_isin_defensive_guard_when_resolve_isins_returns_empty(self, resolver: EntityResolver):
        """Defensive guard in resolve_isin raises EntityNotFound if resolve_isins omits the ISIN."""
        resolver.resolve_isins = lambda isins: {}

        with pytest.raises(EntityNotFound, match="DE0005190003"):
            resolver.resolve_isin("DE0005190003")

    def test_resolve_single_isin_empty_response(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """Empty response for ISIN should raise EntityNotFound."""
        mock_http_client.post.return_value = mock_response_factory(json_data={"DE0005190003": {}})

        with pytest.raises(EntityNotFound):
            resolver.resolve_isin("DE0005190003")

    def test_resolve_multiple_isins_success(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """Multiple ISINs should be resolved successfully."""
        mock_http_client.post.return_value = mock_response_factory(
            json_data={
                "DE0005190003": {"compound_key": "company:bmw"},
                "US0378331005": {"compound_key": "company:apple"},
            }
        )

        result = resolver.resolve_isins(["DE0005190003", "US0378331005"])

        assert result == {
            "DE0005190003": "company:bmw",
            "US0378331005": "company:apple",
        }

    def test_resolve_multiple_isins_partial_failure(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """Partial ISIN resolution failure should raise EntityNotFound."""
        mock_http_client.post.return_value = mock_response_factory(
            json_data={
                "DE0005190003": {"compound_key": "company:bmw"},
                "INVALID12345": None,
            }
        )

        with pytest.raises(EntityNotFound) as exc_info:
            resolver.resolve_isins(["DE0005190003", "INVALID12345"])

        assert "INVALID12345" in str(exc_info.value)

    def test_resolve_multiple_isins_all_failed(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """All ISINs failing should raise EntityNotFound with all ISINs."""
        mock_http_client.post.return_value = mock_response_factory(
            json_data={
                "INVALID1": None,
                "INVALID2": None,
            }
        )

        with pytest.raises(EntityNotFound) as exc_info:
            resolver.resolve_isins(["INVALID1", "INVALID2"])

        error_msg = str(exc_info.value)
        assert "INVALID1" in error_msg
        assert "INVALID2" in error_msg

    def test_resolve_isins_missing_compound_key(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """Response without compound_key should raise EntityNotFound."""
        mock_http_client.post.return_value = mock_response_factory(json_data={"DE0005190003": {"other_field": "value"}})

        with pytest.raises(EntityNotFound):
            resolver.resolve_isins(["DE0005190003"])

    def test_resolve_isins_null_value_in_response(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """Null value in response should be treated as not found."""
        mock_http_client.post.return_value = mock_response_factory(json_data={"DE0005190003": None})

        with pytest.raises(EntityNotFound):
            resolver.resolve_isins(["DE0005190003"])

    def test_resolve_isins_empty_list(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """Empty ISIN list should return empty dict."""
        mock_http_client.post.return_value = mock_response_factory(json_data={})

        result = resolver.resolve_isins([])
        assert result == {}

    def test_resolver_uses_correct_auth_headers(self, mock_http_client: MagicMock, mock_response_factory):
        """Resolver should include correct Authorization header."""
        mock_http_client.post.return_value = mock_response_factory(
            json_data={"DE0005190003": {"compound_key": "company:bmw"}}
        )

        resolver = EntityResolver(
            _client=mock_http_client,
            _token_getter=lambda: "my_secret_token",
        )
        resolver.resolve_isin("DE0005190003")

        call_kwargs = mock_http_client.post.call_args[1]
        assert "headers" in call_kwargs
        assert call_kwargs["headers"]["Authorization"] == "Bearer my_secret_token"

    def test_resolver_calls_correct_endpoint(
        self, resolver: EntityResolver, mock_http_client: MagicMock, mock_response_factory
    ):
        """Resolver should call correct Metadata API endpoint."""
        mock_http_client.post.return_value = mock_response_factory(
            json_data={"DE0005190003": {"compound_key": "company:bmw"}}
        )

        resolver.resolve_isin("DE0005190003")

        call_args = mock_http_client.post.call_args[0]
        assert "isin_to_entity" in call_args[0]
        assert "metadata.api.yukkalab.com" in call_args[0]


class TestEntityResolverHTTPErrors:
    """Tests for HTTP error handling in EntityResolver."""

    @pytest.fixture
    def resolver(self, mock_http_client: MagicMock) -> EntityResolver:
        """Create an EntityResolver with mocked HTTP client."""
        return EntityResolver(
            _client=mock_http_client,
            _token_getter=lambda: "test_token",
        )

    def test_resolve_isin_401_unauthorized(self, resolver: EntityResolver, mock_http_client: MagicMock):
        """401 error should propagate as HTTPStatusError."""
        mock_request = Mock(spec=Request)
        mock_response = Mock(spec=Response)
        mock_response.status_code = 401
        mock_response.raise_for_status.side_effect = HTTPStatusError(
            "Unauthorized", request=mock_request, response=mock_response
        )
        mock_http_client.post.return_value = mock_response

        with pytest.raises(HTTPStatusError) as exc_info:
            resolver.resolve_isin("DE0005190003")

        assert exc_info.value.response.status_code == 401

    def test_resolve_isin_403_forbidden(self, resolver: EntityResolver, mock_http_client: MagicMock):
        """403 error should propagate as HTTPStatusError."""
        mock_request = Mock(spec=Request)
        mock_response = Mock(spec=Response)
        mock_response.status_code = 403
        mock_response.raise_for_status.side_effect = HTTPStatusError(
            "Forbidden", request=mock_request, response=mock_response
        )
        mock_http_client.post.return_value = mock_response

        with pytest.raises(HTTPStatusError) as exc_info:
            resolver.resolve_isin("DE0005190003")

        assert exc_info.value.response.status_code == 403

    def test_resolve_isin_500_server_error(self, resolver: EntityResolver, mock_http_client: MagicMock):
        """500 error should propagate as HTTPStatusError."""
        mock_request = Mock(spec=Request)
        mock_response = Mock(spec=Response)
        mock_response.status_code = 500
        mock_response.raise_for_status.side_effect = HTTPStatusError(
            "Internal Server Error", request=mock_request, response=mock_response
        )
        mock_http_client.post.return_value = mock_response

        with pytest.raises(HTTPStatusError) as exc_info:
            resolver.resolve_isin("DE0005190003")

        assert exc_info.value.response.status_code == 500

    def test_resolve_isin_429_rate_limit(self, resolver: EntityResolver, mock_http_client: MagicMock):
        """429 rate limit error should propagate as HTTPStatusError."""
        mock_request = Mock(spec=Request)
        mock_response = Mock(spec=Response)
        mock_response.status_code = 429
        mock_response.raise_for_status.side_effect = HTTPStatusError(
            "Too Many Requests", request=mock_request, response=mock_response
        )
        mock_http_client.post.return_value = mock_response

        with pytest.raises(HTTPStatusError) as exc_info:
            resolver.resolve_isin("DE0005190003")

        assert exc_info.value.response.status_code == 429


class TestEntityResolverConnectionErrors:
    """Tests for connection error handling in EntityResolver."""

    @pytest.fixture
    def resolver(self, mock_http_client: MagicMock) -> EntityResolver:
        """Create an EntityResolver with mocked HTTP client."""
        return EntityResolver(
            _client=mock_http_client,
            _token_getter=lambda: "test_token",
        )

    def test_resolve_isin_connection_error(self, resolver: EntityResolver, mock_http_client: MagicMock):
        """Connection error should propagate."""
        mock_http_client.post.side_effect = ConnectError("Connection refused")

        with pytest.raises(ConnectError):
            resolver.resolve_isin("DE0005190003")

    def test_resolve_isin_timeout_error(self, resolver: EntityResolver, mock_http_client: MagicMock):
        """Timeout error should propagate."""
        mock_http_client.post.side_effect = TimeoutException("Request timed out")

        with pytest.raises(TimeoutException):
            resolver.resolve_isin("DE0005190003")


class TestAsset:
    """Tests for Asset class."""

    def test_asset_from_yukka_id_single(self):
        """Single yukka_id should create single Asset."""
        asset = Asset.from_yukka_id("company:bmw")

        assert isinstance(asset, Asset)
        assert asset.yukka_id == "company:bmw"
        assert asset.isin is None

    def test_asset_from_yukka_id_list(self):
        """List of yukka_ids should create list of Assets."""
        result = Asset.from_yukka_id(["company:bmw", "company:apple"])

        assert isinstance(result, list)
        assets: list[Asset] = result  # type: ignore[assignment]
        assert len(assets) == 2
        assert assets[0].yukka_id == "company:bmw"
        assert assets[1].yukka_id == "company:apple"

    def test_asset_from_yukka_id_empty_list(self):
        """Empty list should return empty list."""
        assets = Asset.from_yukka_id([])

        assert assets == []

    def test_asset_from_isin_single(self, mock_http_client: MagicMock, mock_response_factory):
        """Single ISIN should create single Asset."""
        mock_http_client.post.return_value = mock_response_factory(
            json_data={"DE0005190003": {"compound_key": "company:bmw"}}
        )
        resolver = EntityResolver(_client=mock_http_client, _token_getter=lambda: "token")

        asset = Asset.from_isin("DE0005190003", resolver)

        assert isinstance(asset, Asset)
        assert asset.yukka_id == "company:bmw"
        assert asset.isin == "DE0005190003"

    def test_asset_from_isin_list(self, mock_http_client: MagicMock, mock_response_factory):
        """List of ISINs should create list of Assets."""
        mock_http_client.post.return_value = mock_response_factory(
            json_data={
                "DE0005190003": {"compound_key": "company:bmw"},
                "US0378331005": {"compound_key": "company:apple"},
            }
        )
        resolver = EntityResolver(_client=mock_http_client, _token_getter=lambda: "token")

        result = Asset.from_isin(["DE0005190003", "US0378331005"], resolver)

        assert isinstance(result, list)
        assets: list[Asset] = result  # type: ignore[assignment]
        assert len(assets) == 2
        assert assets[0].yukka_id == "company:bmw"
        assert assets[0].isin == "DE0005190003"
        assert assets[1].yukka_id == "company:apple"
        assert assets[1].isin == "US0378331005"

    def test_asset_from_isin_not_found(self, mock_http_client: MagicMock, mock_response_factory):
        """ISIN not found should raise EntityNotFound."""
        mock_http_client.post.return_value = mock_response_factory(json_data={"INVALID123456": None})
        resolver = EntityResolver(_client=mock_http_client, _token_getter=lambda: "token")

        with pytest.raises(EntityNotFound):
            Asset.from_isin("INVALID123456", resolver)

    def test_asset_is_frozen(self):
        """Asset should be immutable (frozen dataclass)."""
        result = Asset.from_yukka_id("company:bmw")
        assert isinstance(result, Asset)
        asset = result

        with pytest.raises(AttributeError):
            asset.yukka_id = "company:apple"  # type: ignore[misc]

    def test_asset_equality(self):
        """Assets with same data should be equal."""
        asset1 = Asset(yukka_id="company:bmw", isin="DE0005190003")
        asset2 = Asset(yukka_id="company:bmw", isin="DE0005190003")

        assert asset1 == asset2

    def test_asset_hash(self):
        """Assets should be hashable (usable in sets/dicts)."""
        asset1 = Asset(yukka_id="company:bmw", isin="DE0005190003")
        asset2 = Asset(yukka_id="company:bmw", isin="DE0005190003")

        asset_set = {asset1, asset2}
        assert len(asset_set) == 1

    def test_asset_from_ric_single(self):
        """Single RIC code should create single Asset."""
        asset = Asset.from_ric("AAPL.OQ")

        assert isinstance(asset, Asset)
        assert asset.yukka_id == "company:apple"
        assert asset.ric_code == "AAPL.OQ"
        assert asset.isin is None

    def test_asset_from_ric_list(self):
        """List of RIC codes should create list of Assets."""
        result = Asset.from_ric(["AAPL.OQ", "ADBE.OQ"])

        assert isinstance(result, list)
        assets: list[Asset] = result  # type: ignore[assignment]
        assert len(assets) == 2
        assert assets[0].yukka_id == "company:apple"
        assert assets[0].ric_code == "AAPL.OQ"
        assert assets[1].yukka_id == "company:adobe_systems"
        assert assets[1].ric_code == "ADBE.OQ"

    def test_asset_from_ric_not_found(self):
        """RIC code not in master file should raise EntityNotFound."""
        with pytest.raises(EntityNotFound) as exc_info:
            Asset.from_ric("INVALID.XX")

        assert "INVALID.XX" in str(exc_info.value)

    def test_asset_from_ric_list_partial_failure(self, capsys):
        """List with some invalid RIC codes should return resolved ones and print warning."""
        result = Asset.from_ric(["AAPL.OQ", "INVALID.XX", "ADBE.OQ"])

        assert isinstance(result, list)
        assets: list[Asset] = result  # type: ignore[assignment]
        assert len(assets) == 2
        assert assets[0].ric_code == "AAPL.OQ"
        assert assets[1].ric_code == "ADBE.OQ"

        captured = capsys.readouterr()
        assert "INVALID.XX" in captured.out

    def test_asset_from_ric_empty_list(self):
        """Empty RIC list should return empty list."""
        assets = Asset.from_ric([])

        assert assets == []

    def test_asset_from_ric_list_all_invalid(self, capsys):
        """List with all invalid RIC codes should return empty list and print warning."""
        result = Asset.from_ric(["INVALID1.XX", "INVALID2.XX"])

        assert isinstance(result, list)
        assert len(result) == 0

        captured = capsys.readouterr()
        assert "INVALID1.XX" in captured.out
        assert "INVALID2.XX" in captured.out


class TestEntityNotFound:
    """Tests for EntityNotFound exception."""

    def test_entity_not_found_is_value_error(self):
        """EntityNotFound should be subclass of ValueError."""
        assert issubclass(EntityNotFound, ValueError)

    def test_entity_not_found_contains_identifier(self):
        """EntityNotFound should contain the failed identifier."""
        with pytest.raises(EntityNotFound, match="DE0005190003"):
            raise EntityNotFound("Could not resolve ISIN: DE0005190003")

    def test_entity_not_found_with_multiple_identifiers(self):
        """EntityNotFound can contain multiple identifiers."""
        failed = ["ISIN1", "ISIN2", "ISIN3"]
        with pytest.raises(EntityNotFound, match="ISIN1"):
            raise EntityNotFound(f"Could not resolve ISINs: {failed}")
