"""
Tests for uncovered code paths in PubChemSearchEngine.

Targets:
- _get_compound_properties: error handling, rate limit, empty properties
- _get_compound_description: 404, no description, rate limit
- _get_compound_synonyms: entire method (untested)
- get_compound: entire method (untested)
- search_by_formula: entire method (untested)
- _get_previews: direct lookup fallback, deduplication, compound processing errors
"""

from unittest.mock import Mock, patch

import pytest

from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
    PubChemSearchEngine,
)
from local_deep_research.web_search_engines.rate_limiting import RateLimitError

MODULE = "local_deep_research.web_search_engines.engines.search_engine_pubchem"


@pytest.fixture
def engine():
    """Create a PubChem engine with mocked rate tracker."""
    eng = PubChemSearchEngine(max_results=5, include_synonyms=True)
    eng.rate_tracker = Mock()
    eng.rate_tracker.apply_rate_limit.return_value = 0
    return eng


def _mock_response(status_code=200, json_data=None):
    resp = Mock()
    resp.status_code = status_code
    resp.json.return_value = json_data or {}
    resp.raise_for_status = Mock()
    if status_code >= 400:
        resp.raise_for_status.side_effect = Exception(f"HTTP {status_code}")
    return resp


# ---------------------------------------------------------------------------
# _get_compound_properties
# ---------------------------------------------------------------------------


class TestGetCompoundProperties:
    @patch(f"{MODULE}.safe_get")
    def test_successful_properties(self, mock_get, engine):
        """Returns first property dict from successful response."""
        mock_get.return_value = _mock_response(
            200,
            {
                "PropertyTable": {
                    "Properties": [
                        {"CID": 2519, "MolecularFormula": "C8H10N4O2"}
                    ]
                }
            },
        )
        result = engine._get_compound_properties(2519)
        assert result["MolecularFormula"] == "C8H10N4O2"

    @patch(f"{MODULE}.safe_get")
    def test_empty_properties(self, mock_get, engine):
        """Returns empty dict when Properties list is empty."""
        mock_get.return_value = _mock_response(
            200, {"PropertyTable": {"Properties": []}}
        )
        result = engine._get_compound_properties(999)
        assert result == {}

    @patch(f"{MODULE}.safe_get")
    def test_missing_property_table(self, mock_get, engine):
        """Returns empty dict when PropertyTable is missing."""
        mock_get.return_value = _mock_response(200, {})
        result = engine._get_compound_properties(999)
        assert result == {}

    @patch(f"{MODULE}.safe_get")
    def test_http_error_returns_empty(self, mock_get, engine):
        """Returns empty dict on HTTP error."""
        mock_get.return_value = _mock_response(500)
        result = engine._get_compound_properties(999)
        assert result == {}

    @patch(f"{MODULE}.safe_get")
    def test_rate_limit_error_propagates(self, mock_get, engine):
        """RateLimitError is re-raised, not caught."""
        resp = _mock_response(429)
        mock_get.return_value = resp
        engine._raise_if_rate_limit = Mock(side_effect=RateLimitError("429"))

        with pytest.raises(RateLimitError):
            engine._get_compound_properties(2519)


# ---------------------------------------------------------------------------
# _get_compound_description
# ---------------------------------------------------------------------------


class TestGetCompoundDescription:
    @patch(f"{MODULE}.safe_get")
    def test_successful_description(self, mock_get, engine):
        """Returns first description from response."""
        mock_get.return_value = _mock_response(
            200,
            {
                "InformationList": {
                    "Information": [
                        {
                            "CID": 2519,
                            "Description": "Caffeine is a stimulant.",
                        },
                        {
                            "CID": 2519,
                            "Title": "Some title without description",
                        },
                    ]
                }
            },
        )
        result = engine._get_compound_description(2519)
        assert result == "Caffeine is a stimulant."

    @patch(f"{MODULE}.safe_get")
    def test_404_returns_empty(self, mock_get, engine):
        """Returns empty string on 404."""
        resp = _mock_response(404)
        resp.raise_for_status = Mock()  # Don't raise for 404
        mock_get.return_value = resp
        result = engine._get_compound_description(99999)
        assert result == ""

    @patch(f"{MODULE}.safe_get")
    def test_no_description_in_info(self, mock_get, engine):
        """Returns empty string when no Information has Description field."""
        mock_get.return_value = _mock_response(
            200,
            {
                "InformationList": {
                    "Information": [
                        {"CID": 2519, "Title": "No desc here"},
                    ]
                }
            },
        )
        result = engine._get_compound_description(2519)
        assert result == ""

    @patch(f"{MODULE}.safe_get")
    def test_empty_information_list(self, mock_get, engine):
        """Returns empty string when InformationList is empty."""
        mock_get.return_value = _mock_response(
            200, {"InformationList": {"Information": []}}
        )
        result = engine._get_compound_description(2519)
        assert result == ""

    @patch(f"{MODULE}.safe_get")
    def test_rate_limit_propagates(self, mock_get, engine):
        """RateLimitError propagates from description fetch."""
        resp = _mock_response(429)
        mock_get.return_value = resp
        engine._raise_if_rate_limit = Mock(side_effect=RateLimitError("429"))

        with pytest.raises(RateLimitError):
            engine._get_compound_description(2519)

    @patch(f"{MODULE}.safe_get")
    def test_exception_returns_empty(self, mock_get, engine):
        """Other exceptions return empty string."""
        mock_get.side_effect = ConnectionError("Network error")
        result = engine._get_compound_description(2519)
        assert result == ""


# ---------------------------------------------------------------------------
# _get_compound_synonyms
# ---------------------------------------------------------------------------


class TestGetCompoundSynonyms:
    @patch(f"{MODULE}.safe_get")
    def test_successful_synonyms(self, mock_get, engine):
        """Returns synonym list from response."""
        mock_get.return_value = _mock_response(
            200,
            {
                "InformationList": {
                    "Information": [
                        {
                            "CID": 2519,
                            "Synonym": [
                                "Caffeine",
                                "1,3,7-Trimethylxanthine",
                                "Theine",
                                "Guaranine",
                            ],
                        }
                    ]
                }
            },
        )
        result = engine._get_compound_synonyms(2519, limit=3)
        assert len(result) == 3
        assert result[0] == "Caffeine"

    @patch(f"{MODULE}.safe_get")
    def test_404_returns_empty_list(self, mock_get, engine):
        """Returns empty list on 404."""
        resp = _mock_response(404)
        resp.raise_for_status = Mock()
        mock_get.return_value = resp
        result = engine._get_compound_synonyms(99999)
        assert result == []

    @patch(f"{MODULE}.safe_get")
    def test_empty_information(self, mock_get, engine):
        """Returns empty list when Information is empty."""
        mock_get.return_value = _mock_response(
            200, {"InformationList": {"Information": []}}
        )
        result = engine._get_compound_synonyms(2519)
        assert result == []

    @patch(f"{MODULE}.safe_get")
    def test_no_synonym_key(self, mock_get, engine):
        """Returns empty list when no Synonym key in first info entry."""
        mock_get.return_value = _mock_response(
            200, {"InformationList": {"Information": [{"CID": 2519}]}}
        )
        result = engine._get_compound_synonyms(2519)
        assert result == []

    @patch(f"{MODULE}.safe_get")
    def test_respects_limit(self, mock_get, engine):
        """Respects the limit parameter."""
        synonyms = [f"Syn{i}" for i in range(20)]
        mock_get.return_value = _mock_response(
            200,
            {
                "InformationList": {
                    "Information": [{"CID": 2519, "Synonym": synonyms}]
                }
            },
        )
        result = engine._get_compound_synonyms(2519, limit=5)
        assert len(result) == 5

    @patch(f"{MODULE}.safe_get")
    def test_rate_limit_propagates(self, mock_get, engine):
        """RateLimitError propagates."""
        resp = _mock_response(429)
        mock_get.return_value = resp
        engine._raise_if_rate_limit = Mock(side_effect=RateLimitError("429"))

        with pytest.raises(RateLimitError):
            engine._get_compound_synonyms(2519)

    @patch(f"{MODULE}.safe_get")
    def test_exception_returns_empty_list(self, mock_get, engine):
        """Other exceptions return empty list."""
        mock_get.side_effect = ConnectionError("fail")
        result = engine._get_compound_synonyms(2519)
        assert result == []


# ---------------------------------------------------------------------------
# get_compound
# ---------------------------------------------------------------------------


class TestGetCompound:
    def test_successful_compound(self, engine):
        """Returns compound dict with all fields."""
        engine._get_compound_properties = Mock(
            return_value={"MolecularFormula": "C8H10N4O2"}
        )
        engine._get_compound_description = Mock(return_value="A stimulant")
        engine._get_compound_synonyms = Mock(
            return_value=["Caffeine", "Theine"]
        )

        result = engine.get_compound(2519)

        assert result is not None
        assert result["cid"] == 2519
        assert result["properties"]["MolecularFormula"] == "C8H10N4O2"
        assert result["description"] == "A stimulant"
        assert result["synonyms"] == ["Caffeine", "Theine"]

    def test_rate_limit_propagates(self, engine):
        """RateLimitError propagates from get_compound."""
        engine._get_compound_properties = Mock(
            side_effect=RateLimitError("429")
        )

        with pytest.raises(RateLimitError):
            engine.get_compound(2519)

    def test_exception_returns_none(self, engine):
        """Other exceptions return None."""
        engine._get_compound_properties = Mock(side_effect=ValueError("bad"))

        result = engine.get_compound(2519)
        assert result is None


# ---------------------------------------------------------------------------
# search_by_formula
# ---------------------------------------------------------------------------


class TestSearchByFormula:
    @patch(f"{MODULE}.safe_get")
    def test_successful_search(self, mock_get, engine):
        """Returns compounds for matching formula."""
        mock_get.return_value = _mock_response(
            200, {"IdentifierList": {"CID": [2519, 1234]}}
        )
        engine.get_compound = Mock(
            side_effect=[
                {
                    "cid": 2519,
                    "properties": {},
                    "description": "",
                    "synonyms": [],
                },
                {
                    "cid": 1234,
                    "properties": {},
                    "description": "",
                    "synonyms": [],
                },
            ]
        )

        result = engine.search_by_formula("C8H10N4O2")

        assert len(result) == 2
        assert result[0]["cid"] == 2519

    @patch(f"{MODULE}.safe_get")
    def test_404_returns_empty(self, mock_get, engine):
        """Returns empty list when formula not found."""
        resp = _mock_response(404)
        resp.raise_for_status = Mock()
        mock_get.return_value = resp

        result = engine.search_by_formula("INVALID")
        assert result == []

    @patch(f"{MODULE}.safe_get")
    def test_respects_max_results(self, mock_get, engine):
        """Only processes up to max_results CIDs."""
        engine.max_results = 2
        mock_get.return_value = _mock_response(
            200, {"IdentifierList": {"CID": [1, 2, 3, 4, 5]}}
        )
        engine.get_compound = Mock(
            return_value={
                "cid": 1,
                "properties": {},
                "description": "",
                "synonyms": [],
            }
        )

        engine.search_by_formula("H2O")

        # Should only call get_compound for first 2 CIDs
        assert engine.get_compound.call_count == 2

    @patch(f"{MODULE}.safe_get")
    def test_skips_none_compounds(self, mock_get, engine):
        """Skips compounds that return None."""
        mock_get.return_value = _mock_response(
            200, {"IdentifierList": {"CID": [1, 2]}}
        )
        engine.get_compound = Mock(
            side_effect=[
                None,
                {"cid": 2, "properties": {}, "description": "", "synonyms": []},
            ]
        )

        result = engine.search_by_formula("H2O")
        assert len(result) == 1

    @patch(f"{MODULE}.safe_get")
    def test_rate_limit_propagates(self, mock_get, engine):
        """RateLimitError propagates."""
        resp = _mock_response(429)
        mock_get.return_value = resp
        engine._raise_if_rate_limit = Mock(side_effect=RateLimitError("429"))

        with pytest.raises(RateLimitError):
            engine.search_by_formula("H2O")

    @patch(f"{MODULE}.safe_get")
    def test_exception_returns_empty(self, mock_get, engine):
        """Other exceptions return empty list."""
        mock_get.side_effect = ConnectionError("fail")
        result = engine.search_by_formula("H2O")
        assert result == []


# ---------------------------------------------------------------------------
# _get_previews: direct lookup fallback and deduplication
# ---------------------------------------------------------------------------


class TestGetPreviewsEdgeCases:
    def test_direct_lookup_fallback(self, engine):
        """Falls back to direct lookup when autocomplete returns nothing."""
        # _search_compounds returns empty -> triggers direct lookup
        engine._search_compounds = Mock(return_value=[])
        engine._get_compound_by_name = Mock(
            return_value={
                "cid": 2519,
                "properties": {
                    "MolecularFormula": "C8H10N4O2",
                    "IUPACName": "caffeine",
                },
                "description": "A stimulant",
            }
        )

        previews = engine._get_previews("caffeine")
        assert len(previews) == 1
        assert "2519" in str(previews[0]["id"])

    def test_deduplication_by_cid(self, engine):
        """Compounds with duplicate CIDs are deduplicated."""
        engine._search_compounds = Mock(return_value=["Caffeine", "caffeine"])
        engine._get_compound_by_name = Mock(
            return_value={
                "cid": 2519,
                "properties": {
                    "MolecularFormula": "C8H10N4O2",
                    "IUPACName": "caffeine",
                },
                "description": "A stimulant",
            }
        )

        previews = engine._get_previews("caffeine")

        # Both names resolve to same CID, so only one preview
        assert len(previews) == 1

    def test_stops_at_max_results(self, engine):
        """Processing stops at max_results."""
        engine.max_results = 2
        names = [f"Compound{i}" for i in range(10)]
        engine._search_compounds = Mock(return_value=names)
        engine._get_compound_by_name = Mock(
            side_effect=lambda n: {
                "cid": hash(n) % 10000,
                "properties": {"IUPACName": n},
                "description": "",
            }
        )

        previews = engine._get_previews("test")
        assert len(previews) == 2

    def test_compound_processing_error_continues(self, engine):
        """Error processing one compound doesn't break the loop."""
        engine._search_compounds = Mock(return_value=["Bad", "Good"])

        def get_by_name(name):
            if name == "Bad":
                raise ValueError("Parse error")
            return {
                "cid": 123,
                "properties": {"IUPACName": "good"},
                "description": "ok",
            }

        engine._get_compound_by_name = Mock(side_effect=get_by_name)

        previews = engine._get_previews("test")
        assert len(previews) == 1
