"""Tests for geo_service and GeoCache."""

from __future__ import annotations

import asyncio
from collections.abc import Mapping, Sequence
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from app.models.geo import GeoInfo
from app.services.geo_cache import (
    _BATCH_DELAY,
    _BATCH_MAX_RETRIES,
    _BATCH_SIZE,
    _NEG_CACHE_TTL,
    GeoCache,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_session(response_json: dict[str, object], status: int = 200) -> MagicMock:
    """Build a mock aiohttp.ClientSession that returns *response_json*.

    Args:
        response_json: The dict that the mock response's ``json()`` returns.
        status: HTTP status code for the mock response.

    Returns:
        A :class:`MagicMock` that behaves like an
        ``aiohttp.ClientSession`` in an ``async with`` context.
    """
    mock_resp = AsyncMock()
    mock_resp.status = status
    mock_resp.json = AsyncMock(return_value=response_json)

    mock_ctx = AsyncMock()
    mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp)
    mock_ctx.__aexit__ = AsyncMock(return_value=False)

    session = MagicMock()
    session.get = MagicMock(return_value=mock_ctx)
    return session


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
async def geo_cache() -> GeoCache:
    """Provide a fresh GeoCache instance for each test with HTTP fallback enabled.

    Most tests expect HTTP API to be available and do not set up MMDB.
    For testing MMDB-first behavior, use geo_cache_mmdb_only fixture instead.
    """
    return GeoCache(allow_http_fallback=True)


@pytest.fixture
async def geo_cache_mmdb_only() -> GeoCache:
    """Provide a fresh GeoCache instance with HTTP fallback disabled (MMDB-only mode)."""
    return GeoCache(allow_http_fallback=False)


def test_init_geoip_is_startup_only(geo_cache: GeoCache, tmp_path) -> None:
    """A second init_geoip() call raises when the reader was already loaded."""
    path = tmp_path / "GeoLite2-Country.mmdb"
    path.write_text("dummy")

    with patch("geoip2.database.Reader", MagicMock(name="Reader")) as mock_reader:
        geo_cache.init_geoip(str(path))
        assert geo_cache._geoip_reader is not None
        assert geo_cache._geoip_initialized is True

        with pytest.raises(RuntimeError, match="already initialised"):
            geo_cache.init_geoip(str(path))

    assert mock_reader.call_count == 1


def test_init_geoip_no_path_leaves_reader_uninitialised(geo_cache: GeoCache) -> None:
    """No active reader is created when no path is supplied."""
    geo_cache.init_geoip("")
    assert geo_cache._geoip_reader is None
    assert geo_cache._geoip_initialized is False


# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------


class TestLookupSuccess:
    """geo_service.lookup() under normal conditions."""

    async def test_returns_country_code(self, geo_cache: GeoCache) -> None:
        """country_code is populated from the ``countryCode`` field."""
        session = _make_session(
            {
                "status": "success",
                "countryCode": "DE",
                "country": "Germany",
                "as": "AS3320 Deutsche Telekom AG",
                "org": "AS3320 Deutsche Telekom AG",
            }
        )
        result = await geo_cache.lookup("1.2.3.4", session)

        assert result is not None
        assert result.country_code == "DE"

    async def test_returns_country_name(self, geo_cache: GeoCache) -> None:
        """country_name is populated from the ``country`` field."""
        session = _make_session(
            {
                "status": "success",
                "countryCode": "US",
                "country": "United States",
                "as": "AS15169 Google LLC",
                "org": "Google LLC",
            }
        )
        result = await geo_cache.lookup("8.8.8.8", session)

        assert result is not None
        assert result.country_name == "United States"

    async def test_asn_extracted_without_org_suffix(self, geo_cache: GeoCache) -> None:
        """The ASN field contains only the ``AS<N>`` prefix, not the full string."""
        session = _make_session(
            {
                "status": "success",
                "countryCode": "DE",
                "country": "Germany",
                "as": "AS3320 Deutsche Telekom AG",
                "org": "Deutsche Telekom",
            }
        )
        result = await geo_cache.lookup("1.2.3.4", session)

        assert result is not None
        assert result.asn == "AS3320"

    async def test_org_populated(self, geo_cache: GeoCache) -> None:
        """org field is populated from the ``org`` key."""
        session = _make_session(
            {
                "status": "success",
                "countryCode": "US",
                "country": "United States",
                "as": "AS15169 Google LLC",
                "org": "Google LLC",
            }
        )
        result = await geo_cache.lookup("8.8.8.8", session)

        assert result is not None
        assert result.org == "Google LLC"


# ---------------------------------------------------------------------------
# Cache behaviour
# ---------------------------------------------------------------------------


class TestLookupCaching:
    """Verify that results are cached and the cache can be cleared."""

    async def test_second_call_uses_cache(self, geo_cache: GeoCache) -> None:
        """Subsequent lookups for the same IP do not make additional HTTP requests."""
        session = _make_session(
            {
                "status": "success",
                "countryCode": "DE",
                "country": "Germany",
                "as": "AS3320 Deutsche Telekom AG",
                "org": "Deutsche Telekom",
            }
        )

        await geo_cache.lookup("1.2.3.4", session)
        await geo_cache.lookup("1.2.3.4", session)

        # The session.get() should only have been called once.
        assert session.get.call_count == 1

    async def test_clear_cache_forces_refetch(self, geo_cache: GeoCache) -> None:
        """After clearing the cache a new HTTP request is made."""
        session = _make_session(
            {
                "status": "success",
                "countryCode": "DE",
                "country": "Germany",
                "as": "AS3320",
                "org": "Telekom",
            }
        )

        await geo_cache.lookup("2.3.4.5", session)
        await geo_cache.clear()
        await geo_cache.lookup("2.3.4.5", session)

        assert session.get.call_count == 2

    async def test_negative_result_stored_in_neg_cache(self, geo_cache: GeoCache) -> None:
        """A failed lookup is stored in the negative cache, so the second call is blocked."""
        session = _make_session({"status": "fail", "message": "reserved range"})

        await geo_cache.lookup("192.168.1.1", session)
        await geo_cache.lookup("192.168.1.1", session)

        # Second call is blocked by the negative cache — only one API hit.
        assert session.get.call_count == 1


# ---------------------------------------------------------------------------
# Failure modes
# ---------------------------------------------------------------------------


class TestLookupFailures:
    """geo_service.lookup() when things go wrong."""

    async def test_non_200_response_returns_null_geo_info(self, geo_cache: GeoCache) -> None:
        """A 429 or 500 status returns GeoInfo with null fields (not None)."""
        session = _make_session({}, status=429)
        result = await geo_cache.lookup("1.2.3.4", session)
        assert result is not None
        assert isinstance(result, GeoInfo)
        assert result.country_code is None

    async def test_network_error_returns_null_geo_info(self, geo_cache: GeoCache) -> None:
        """A network exception returns GeoInfo with null fields (not None)."""
        session = MagicMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(side_effect=OSError("connection refused"))
        mock_ctx.__aexit__ = AsyncMock(return_value=False)
        session.get = MagicMock(return_value=mock_ctx)

        result = await geo_cache.lookup("10.0.0.1", session)
        assert result is not None
        assert isinstance(result, GeoInfo)
        assert result.country_code is None

    async def test_failed_status_returns_geo_info_with_nulls(self, geo_cache: GeoCache) -> None:
        """When ip-api returns ``status=fail`` a GeoInfo with null fields is returned (but not cached)."""
        session = _make_session({"status": "fail", "message": "private range"})
        result = await geo_cache.lookup("10.0.0.1", session)

        assert result is not None
        assert isinstance(result, GeoInfo)
        assert result.country_code is None
        assert result.country_name is None


# ---------------------------------------------------------------------------
# Negative cache
# ---------------------------------------------------------------------------


class TestNegativeCache:
    """Verify the negative cache throttles retries for failing IPs."""

    async def test_neg_cache_blocks_second_lookup(self, geo_cache: GeoCache) -> None:
        """After a failed lookup the second call is served from the neg cache."""
        session = _make_session({"status": "fail", "message": "private range"})

        r1 = await geo_cache.lookup("192.0.2.1", session)
        r2 = await geo_cache.lookup("192.0.2.1", session)

        # Only one HTTP call should have been made; second served from neg cache.
        assert session.get.call_count == 1
        assert r1 is not None and r1.country_code is None
        assert r2 is not None and r2.country_code is None

    async def test_neg_cache_retries_after_ttl(self, geo_cache: GeoCache) -> None:
        """When the neg-cache entry is older than the TTL a new API call is made."""
        session = _make_session({"status": "fail", "message": "private range"})

        await geo_cache.lookup("192.0.2.2", session)

        # Manually expire the neg-cache entry.
        geo_cache._neg_cache["192.0.2.2"] -= _NEG_CACHE_TTL + 1

        await geo_cache.lookup("192.0.2.2", session)

        # Both calls should have hit the API.
        assert session.get.call_count == 2

    async def test_clear_neg_cache_allows_immediate_retry(self, geo_cache: GeoCache) -> None:
        """After clearing the neg cache the IP is eligible for a new API call."""
        session = _make_session({"status": "fail", "message": "private range"})

        await geo_cache.lookup("192.0.2.3", session)
        await geo_cache.clear_neg_cache()
        await geo_cache.lookup("192.0.2.3", session)

        assert session.get.call_count == 2

    async def test_successful_lookup_does_not_pollute_neg_cache(self, geo_cache: GeoCache) -> None:
        """A successful lookup must not create a neg-cache entry."""
        session = _make_session(
            {
                "status": "success",
                "countryCode": "DE",
                "country": "Germany",
                "as": "AS3320",
                "org": "Telekom",
            }
        )

        await geo_cache.lookup("1.2.3.4", session)

        assert "1.2.3.4" not in geo_cache._neg_cache


# ---------------------------------------------------------------------------
# GeoIP2 (MaxMind) fallback
# ---------------------------------------------------------------------------


class TestGeoipFallback:
    """Verify the MaxMind GeoLite2 is used as the primary resolver.

    With the new implementation, MMDB (MaxMind GeoLite2-Country) is the primary
    resolver, tried first before the HTTP API. HTTP is only used if MMDB is
    unavailable or returns no result (and allow_http_fallback is enabled).
    """

    def _make_geoip_reader(self, iso_code: str, name: str) -> MagicMock:
        """Build a mock geoip2.database.Reader that returns *iso_code*."""
        country_mock = MagicMock()
        country_mock.iso_code = iso_code
        country_mock.name = name

        response_mock = MagicMock()
        response_mock.country = country_mock

        reader = MagicMock()
        reader.country = MagicMock(return_value=response_mock)
        return reader

    async def test_geoip_primary_resolver_success(self, geo_cache: GeoCache) -> None:
        """MMDB is used as the primary resolver and HTTP API is skipped on success."""
        session = _make_session(
            {
                "status": "success",
                "countryCode": "JP",
                "country": "Japan",
                "as": "AS12345",
                "org": "NTT",
            }
        )
        mock_reader = self._make_geoip_reader("DE", "Germany")

        with patch.object(geo_cache, "_geoip_reader", mock_reader):
            result = await geo_cache.lookup("1.2.3.4", session)

        # MMDB should be called (primary resolver).
        mock_reader.country.assert_called_once_with("1.2.3.4")
        # HTTP API should NOT be called since MMDB succeeded.
        session.get.assert_not_called()
        # Result should be from MMDB, not HTTP.
        assert result is not None
        assert result.country_code == "DE"
        assert result.country_name == "Germany"

    async def test_geoip_fallback_when_http_fails(self, geo_cache: GeoCache) -> None:
        """When HTTP API fails, MMDB is used (it's already tried first)."""
        session = _make_session({"status": "fail", "message": "reserved range"})
        mock_reader = self._make_geoip_reader("DE", "Germany")

        with patch.object(geo_cache, "_geoip_reader", mock_reader):
            result = await geo_cache.lookup("1.2.3.4", session)

        mock_reader.country.assert_called_once_with("1.2.3.4")
        assert result is not None
        assert result.country_code == "DE"
        assert result.country_name == "Germany"

    async def test_geoip_fallback_result_stored_in_cache(self, geo_cache: GeoCache) -> None:
        """A successful geoip2 result is stored in the positive cache."""
        session = _make_session({"status": "fail", "message": "reserved range"})
        mock_reader = self._make_geoip_reader("US", "United States")

        with patch.object(geo_cache, "_geoip_reader", mock_reader):
            await geo_cache.lookup("8.8.8.8", session)
            # Second call must be served from positive cache without hitting API or MMDB.
            await geo_cache.lookup("8.8.8.8", session)

        # MMDB should only be called once (not on second call due to cache).
        assert mock_reader.country.call_count == 1
        # HTTP API should never be called since MMDB succeeded.
        assert session.get.call_count == 0
        assert "8.8.8.8" in geo_cache._cache

    async def test_geoip_fallback_not_called_when_no_reader(self, geo_cache: GeoCache) -> None:
        """When no geoip2 reader is configured, resolution falls back to HTTP (if enabled)."""
        session = _make_session({"status": "fail", "message": "private range"})

        with patch.object(geo_cache, "_geoip_reader", None):
            result = await geo_cache.lookup("10.0.0.1", session)

        assert result is not None
        # With HTTP fallback enabled but no result from HTTP, country_code is None.
        assert result.country_code is None


# ---------------------------------------------------------------------------
# Batch single-commit behaviour (Task 1)
# ---------------------------------------------------------------------------


def _make_batch_session(batch_response: Sequence[Mapping[str, object]]) -> MagicMock:
    """Build a mock aiohttp.ClientSession for batch POST calls.

    Args:
        batch_response: The list that the mock response's ``json()`` returns.

    Returns:
        A :class:`MagicMock` with a ``post`` method wired as an async context.
    """
    mock_resp = AsyncMock()
    mock_resp.status = 200
    mock_resp.json = AsyncMock(return_value=batch_response)

    mock_ctx = AsyncMock()
    mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp)
    mock_ctx.__aexit__ = AsyncMock(return_value=False)

    session = MagicMock()
    session.post = MagicMock(return_value=mock_ctx)
    return session


def _make_async_db() -> MagicMock:
    """Build a minimal mock :class:`aiosqlite.Connection`.

    Returns:
        MagicMock with ``execute``, ``executemany``, ``commit``, and ``rollback``
        wired as async coroutines.  ``execute`` is an async function that returns
        an async context manager yielding a cursor.
    """
    db = MagicMock()

    # Build a mock cursor for count_unresolved and similar queries.
    mock_cursor = MagicMock()
    mock_cursor.fetchone = AsyncMock(return_value=(0,))
    mock_cursor.fetchall = AsyncMock(return_value=[])

    # Build an async context manager wrapping the cursor.
    mock_ctx = MagicMock()
    mock_ctx.__aenter__ = AsyncMock(return_value=mock_cursor)
    mock_ctx.__aexit__ = AsyncMock(return_value=None)

    # For BEGIN statements (transaction start), return a no-op ctx manager.
    class _BeginCtx:
        async def __aenter__(self) -> None:
            return None

        async def __aexit__(self, *args: object) -> None:
            return None

    async def fake_execute(sql: str, *args: object, **kwargs: object) -> MagicMock:  # type: ignore[no-untyped-def]
        if isinstance(sql, str) and sql.startswith("BEGIN"):
            return MagicMock(__aenter__=AsyncMock(return_value=None), __aexit__=AsyncMock(return_value=None))
        return mock_ctx

    db.execute = AsyncMock(side_effect=fake_execute)
    db.executemany = AsyncMock()
    db.commit = AsyncMock()
    db.rollback = AsyncMock()
    return db


class TestLookupBatchSingleCommit:
    """lookup_batch() issues exactly one commit per call, not one per IP."""

    async def test_single_commit_for_multiple_ips(self, geo_cache: GeoCache) -> None:
        """A batch of N IPs produces exactly one db.commit(), not N."""
        ips = ["1.1.1.1", "2.2.2.2", "3.3.3.3"]
        batch_response = [
            {"query": ip, "status": "success", "countryCode": "DE", "country": "Germany", "as": "AS1", "org": "Org"}
            for ip in ips
        ]
        session = _make_batch_session(batch_response)
        db = _make_async_db()

        await geo_cache.lookup_batch(ips, session, db=db)

        db.commit.assert_awaited_once()

    async def test_commit_called_even_on_failed_lookups(self, geo_cache: GeoCache) -> None:
        """A batch with all-failed lookups still triggers one commit."""
        ips = ["10.0.0.1", "10.0.0.2"]
        batch_response = [{"query": ip, "status": "fail", "message": "private range"} for ip in ips]
        session = _make_batch_session(batch_response)
        db = _make_async_db()

        await geo_cache.lookup_batch(ips, session, db=db)

        db.commit.assert_awaited_once()

    async def test_no_commit_when_db_is_none(self, geo_cache: GeoCache) -> None:
        """When db=None, no commit is attempted."""
        ips = ["1.1.1.1"]
        batch_response = [
            {
                "query": "1.1.1.1",
                "status": "success",
                "countryCode": "US",
                "country": "United States",
                "as": "AS15169",
                "org": "Google LLC",
            },
        ]
        session = _make_batch_session(batch_response)

        # Should not raise; without db there is nothing to commit.
        result = await geo_cache.lookup_batch(ips, session, db=None)

        assert result["1.1.1.1"].country_code == "US"

    async def test_no_commit_for_all_cached_ips(self, geo_cache: GeoCache) -> None:
        """When all IPs are already cached, no HTTP call and no commit occur."""
        geo_cache._cache["5.5.5.5"] = GeoInfo(country_code="FR", country_name="France", asn="AS1", org="ISP")
        db = _make_async_db()
        session = _make_batch_session([])

        result = await geo_cache.lookup_batch(["5.5.5.5"], session, db=db)

        assert result["5.5.5.5"].country_code == "FR"
        db.commit.assert_not_awaited()
        session.post.assert_not_called()


# ---------------------------------------------------------------------------
# Dirty-set tracking and flush_dirty (Task 3)
# ---------------------------------------------------------------------------


class TestDirtySetTracking:
    """_store() marks successfully resolved IPs as dirty."""

    async def test_successful_resolution_adds_to_dirty(self, geo_cache: GeoCache) -> None:
        """Storing a GeoInfo with a country_code adds the IP to _dirty."""
        info = GeoInfo(country_code="DE", country_name="Germany", asn="AS1", org="ISP")
        await geo_cache._store("1.2.3.4", info)

        assert "1.2.3.4" in geo_cache._dirty

    async def test_null_country_does_not_add_to_dirty(self, geo_cache: GeoCache) -> None:
        """Storing a GeoInfo with country_code=None must not pollute _dirty."""
        info = GeoInfo(country_code=None, country_name=None, asn=None, org=None)
        await geo_cache._store("10.0.0.1", info)

        assert "10.0.0.1" not in geo_cache._dirty

    async def test_clear_cache_also_clears_dirty(self, geo_cache: GeoCache) -> None:
        """clear_cache() must discard any pending dirty entries."""
        info = GeoInfo(country_code="US", country_name="United States", asn="AS1", org="ISP")
        await geo_cache._store("8.8.8.8", info)
        assert geo_cache._dirty

        await geo_cache.clear()

        assert not geo_cache._dirty

    async def test_lookup_batch_populates_dirty(self, geo_cache: GeoCache) -> None:
        """After lookup_batch() with db=None, resolved IPs appear in _dirty."""
        ips = ["1.1.1.1", "2.2.2.2"]
        batch_response = [
            {"query": ip, "status": "success", "countryCode": "JP", "country": "Japan", "as": "AS7500", "org": "IIJ"}
            for ip in ips
        ]
        session = _make_batch_session(batch_response)

        await geo_cache.lookup_batch(ips, session, db=None)

        for ip in ips:
            assert ip in geo_cache._dirty


class TestFlushDirty:
    """flush_dirty() persists dirty entries and clears the set."""

    async def test_flush_writes_and_clears_dirty(self, geo_cache: GeoCache) -> None:
        """flush_dirty() inserts all dirty IPs and clears _dirty afterwards."""
        info = GeoInfo(country_code="GB", country_name="United Kingdom", asn="AS2856", org="BT")
        await geo_cache._store("100.0.0.1", info)
        assert "100.0.0.1" in geo_cache._dirty

        db = _make_async_db()
        count = await geo_cache.flush_dirty(db)

        assert count == 1
        db.executemany.assert_awaited_once()
        db.commit.assert_awaited_once()
        assert "100.0.0.1" not in geo_cache._dirty

    async def test_flush_returns_zero_when_nothing_dirty(self, geo_cache: GeoCache) -> None:
        """flush_dirty() returns 0 and makes no DB calls when _dirty is empty."""
        db = _make_async_db()
        count = await geo_cache.flush_dirty(db)

        assert count == 0
        db.executemany.assert_not_awaited()
        db.commit.assert_not_awaited()

    async def test_flush_re_adds_to_dirty_on_db_error(self, geo_cache: GeoCache) -> None:
        """When the DB write fails, entries are re-added to _dirty for retry."""
        info = GeoInfo(country_code="AU", country_name="Australia", asn="AS1", org="ISP")
        await geo_cache._store("200.0.0.1", info)

        db = _make_async_db()
        db.executemany = AsyncMock(side_effect=OSError("disk full"))

        count = await geo_cache.flush_dirty(db)

        assert count == 0
        assert "200.0.0.1" in geo_cache._dirty

    async def test_flush_batch_and_lookup_batch_integration(self, geo_cache: GeoCache) -> None:
        """lookup_batch() populates _dirty; flush_dirty() then persists them."""
        ips = ["10.1.2.3", "10.1.2.4"]
        batch_response = [
            {"query": ip, "status": "success", "countryCode": "CA", "country": "Canada", "as": "AS812", "org": "Bell"}
            for ip in ips
        ]
        session = _make_batch_session(batch_response)

        # Resolve without DB to populate only in-memory cache and _dirty.
        await geo_cache.lookup_batch(ips, session, db=None)
        assert geo_cache._dirty == set(ips)

        # Now flush to the DB.
        db = _make_async_db()
        count = await geo_cache.flush_dirty(db)

        assert count == 2
        assert not geo_cache._dirty
        db.commit.assert_awaited_once()


# ---------------------------------------------------------------------------
# Rate-limit throttling and retry tests (Task 5)
# ---------------------------------------------------------------------------


class TestLookupBatchThrottling:
    """Verify the inter-batch delay, retry, and give-up behaviour."""

    async def test_lookup_batch_throttles_between_chunks(self, geo_cache: GeoCache) -> None:
        """When more than _BATCH_SIZE IPs are sent, asyncio.sleep is called
        between consecutive batch HTTP calls with at least _BATCH_DELAY."""
        # Generate _BATCH_SIZE + 1 IPs so we get exactly 2 batch calls.
        batch_size: int = _BATCH_SIZE
        ips = [f"10.0.{i // 256}.{i % 256}" for i in range(batch_size + 1)]

        def _make_result(chunk: list[str], _session: object) -> dict[str, GeoInfo]:
            return {ip: GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None) for ip in chunk}

        with (
            patch.object(
                geo_cache,
                "_batch_api_call",
                new_callable=AsyncMock,
                side_effect=_make_result,
            ) as mock_batch,
            patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
        ):
            await geo_cache.lookup_batch(ips, MagicMock())

        # Two chunks → one sleep between them.
        assert mock_batch.call_count == 2
        mock_sleep.assert_awaited_once()
        delay_arg: float = mock_sleep.call_args[0][0]
        assert delay_arg >= _BATCH_DELAY

    async def test_lookup_batch_retries_on_full_chunk_failure(self, geo_cache: GeoCache) -> None:
        """When a chunk returns all-None on first try, it retries and succeeds."""
        ips = ["1.2.3.4", "5.6.7.8"]

        _empty = GeoInfo(country_code=None, country_name=None, asn=None, org=None)
        _success = {
            "1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None),
            "5.6.7.8": GeoInfo(country_code="US", country_name="United States", asn=None, org=None),
        }
        _failure: dict[str, GeoInfo] = dict.fromkeys(ips, _empty)

        call_count = 0

        async def _side_effect(chunk: list[str], _session: object) -> dict[str, GeoInfo]:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return _failure
            return _success

        with (
            patch.object(
                geo_cache,
                "_batch_api_call",
                new_callable=AsyncMock,
                side_effect=_side_effect,
            ),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            result = await geo_cache.lookup_batch(ips, MagicMock())

        assert call_count == 2
        assert result["1.2.3.4"].country_code == "DE"
        assert result["5.6.7.8"].country_code == "US"

    async def test_lookup_batch_gives_up_after_max_retries(self, geo_cache: GeoCache) -> None:
        """After _BATCH_MAX_RETRIES + 1 attempts, IPs end up in the neg cache."""
        ips = ["9.9.9.9"]
        _empty = GeoInfo(country_code=None, country_name=None, asn=None, org=None)
        _failure: dict[str, GeoInfo] = dict.fromkeys(ips, _empty)

        max_retries: int = _BATCH_MAX_RETRIES

        with (
            patch.object(
                geo_cache,
                "_batch_api_call",
                new_callable=AsyncMock,
                return_value=_failure,
            ) as mock_batch,
            patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
        ):
            result = await geo_cache.lookup_batch(ips, MagicMock())

        # Initial attempt + max_retries retries.
        assert mock_batch.call_count == max_retries + 1
        # IP should have no country.
        assert result["9.9.9.9"].country_code is None
        # Negative cache should contain the IP.
        assert "9.9.9.9" in geo_cache._neg_cache
        # Sleep called for each retry with exponential backoff.
        assert mock_sleep.call_count == max_retries
        backoff_values = [call.args[0] for call in mock_sleep.call_args_list]
        batch_delay: float = _BATCH_DELAY
        for i, val in enumerate(backoff_values):
            expected = batch_delay * (2 ** (i + 1))
            assert val == pytest.approx(expected)


# ---------------------------------------------------------------------------
# Error logging improvements (Task 2)
# ---------------------------------------------------------------------------


class TestErrorLogging:
    """Verify that exception details are properly captured in log events.

    Previously ``str(exc)`` was used which yields an empty string for
    aiohttp exceptions such as ``ServerDisconnectedError`` that carry no
    message.  The fix uses ``repr(exc)`` so the exception class name is
    always present, and adds an ``exc_type`` field for easy log filtering.
    """

    async def test_empty_message_exception_logs_exc_type(self, geo_cache: GeoCache) -> None:
        """When HTTP exception str() is empty, exc_type and repr are still logged."""

        class _EmptyMessageError(OSError):
            """Exception whose str() representation is empty."""

            def __str__(self) -> str:
                return ""

        session = MagicMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(side_effect=_EmptyMessageError())
        mock_ctx.__aexit__ = AsyncMock(return_value=False)
        session.get = MagicMock(return_value=mock_ctx)

        from tests.logging_capture import capture_logs

        with capture_logs() as captured, patch.object(geo_cache, "_geoip_reader", None):
            # Ensure MMDB is not available so HTTP is tried.
            result = await geo_cache.lookup("197.221.98.153", session)

        assert result is not None
        assert result.country_code is None

        request_failed = [e for e in captured if e.get("event") == "geo_lookup_http_request_failed"]
        assert len(request_failed) == 1
        event = request_failed[0]
        # exc_type must name the exception class — never empty.
        assert event["exc_type"] == "_EmptyMessageError"
        # repr() must include the class name even when str() is empty.
        assert "_EmptyMessageError" in event["error"]

    async def test_connection_error_logs_exc_type(self, geo_cache: GeoCache) -> None:
        """A standard OSError with message is logged in HTTP request failure log."""
        session = MagicMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(side_effect=OSError("connection refused"))
        mock_ctx.__aexit__ = AsyncMock(return_value=False)
        session.get = MagicMock(return_value=mock_ctx)

        from tests.logging_capture import capture_logs

        with capture_logs() as captured, patch.object(geo_cache, "_geoip_reader", None):
            # Ensure MMDB is not available so HTTP is tried.
            await geo_cache.lookup("10.0.0.1", session)

        request_failed = [e for e in captured if e.get("event") == "geo_lookup_http_request_failed"]
        assert len(request_failed) == 1
        event = request_failed[0]
        assert event["exc_type"] == "OSError"
        assert "connection refused" in event["error"]

    async def test_batch_empty_message_exception_logs_exc_type(self, geo_cache: GeoCache) -> None:
        """Batch API call: empty-message exceptions include exc_type in the log."""

        class _EmptyMessageError(OSError):
            def __str__(self) -> str:
                return ""

        session = MagicMock()
        mock_ctx = AsyncMock()
        mock_ctx.__aenter__ = AsyncMock(side_effect=_EmptyMessageError())
        mock_ctx.__aexit__ = AsyncMock(return_value=False)
        session.post = MagicMock(return_value=mock_ctx)

        from tests.logging_capture import capture_logs

        with capture_logs() as captured:
            result = await geo_cache._batch_api_call(["1.2.3.4"], session)

        assert result["1.2.3.4"].country_code is None

        batch_failed = [e for e in captured if e.get("event") == "geo_batch_request_failed"]
        assert len(batch_failed) == 1
        event = batch_failed[0]
        assert event["exc_type"] == "_EmptyMessageError"
        assert "_EmptyMessageError" in event["error"]


# ---------------------------------------------------------------------------
# lookup_cached_only (Task 3)
# ---------------------------------------------------------------------------


class TestLookupCachedOnly:
    """lookup_cached_only() returns cache hits without making API calls."""

    def test_returns_cached_ips(self, geo_cache: GeoCache) -> None:
        """IPs already in the cache are returned in the geo_map."""
        geo_cache._cache["1.1.1.1"] = GeoInfo(
            country_code="AU", country_name="Australia", asn="AS13335", org="Cloudflare"
        )
        geo_map, uncached = geo_cache.lookup_cached_only(["1.1.1.1"])

        assert "1.1.1.1" in geo_map
        assert geo_map["1.1.1.1"].country_code == "AU"
        assert uncached == []

    def test_returns_uncached_ips(self, geo_cache: GeoCache) -> None:
        """IPs not in the cache appear in the uncached list."""
        geo_map, uncached = geo_cache.lookup_cached_only(["9.9.9.9"])

        assert "9.9.9.9" not in geo_map
        assert "9.9.9.9" in uncached

    def test_neg_cached_ips_excluded_from_uncached(self, geo_cache: GeoCache) -> None:
        """IPs in the negative cache within TTL are not re-queued as uncached."""
        import time

        geo_cache._neg_cache["10.0.0.1"] = time.monotonic()

        geo_map, uncached = geo_cache.lookup_cached_only(["10.0.0.1"])

        assert "10.0.0.1" not in geo_map
        assert "10.0.0.1" not in uncached

    def test_expired_neg_cache_requeued(self, geo_cache: GeoCache) -> None:
        """IPs whose neg-cache entry has expired are listed as uncached."""
        import time

        # Set neg_cache entry to a time that is definitely expired (300s TTL + 1s margin)
        geo_cache._neg_cache["10.0.0.2"] = time.monotonic() - 301.0

        _geo_map, uncached = geo_cache.lookup_cached_only(["10.0.0.2"])

        assert "10.0.0.2" in uncached

    def test_mixed_ips(self, geo_cache: GeoCache) -> None:
        """A mix of cached, neg-cached, and unknown IPs is split correctly."""
        geo_cache._cache["1.2.3.4"] = GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None)
        import time

        geo_cache._neg_cache["5.5.5.5"] = time.monotonic()

        geo_map, uncached = geo_cache.lookup_cached_only(["1.2.3.4", "5.5.5.5", "9.9.9.9"])

        assert list(geo_map.keys()) == ["1.2.3.4"]
        assert uncached == ["9.9.9.9"]

    def test_deduplication(self, geo_cache: GeoCache) -> None:
        """Duplicate IPs in the input appear at most once in the output."""
        geo_cache._cache["1.2.3.4"] = GeoInfo(country_code="US", country_name="United States", asn=None, org=None)

        geo_map, uncached = geo_cache.lookup_cached_only(["9.9.9.9", "9.9.9.9", "1.2.3.4", "1.2.3.4"])

        assert len([ip for ip in geo_map if ip == "1.2.3.4"]) == 1
        assert uncached.count("9.9.9.9") == 1


class TestReResolveAll:
    """Tests for :func:`~app.services.geo_cache.re_resolve_all`."""

    async def test_returns_zero_when_no_unresolved_ips(self, geo_cache: GeoCache) -> None:
        """The service returns zero counts when there are no unresolved IPs."""
        db = MagicMock()
        session = MagicMock()

        with (
            patch(
                "app.repositories.geo_cache_repo.get_unresolved_ips",
                AsyncMock(return_value=[]),
            ),
            patch.object(
                geo_cache,
                "lookup_batch",
                AsyncMock(),
            ) as mock_lookup,
            patch.object(
                geo_cache,
                "clear_neg_cache",
                AsyncMock(),
            ) as mock_clear,
        ):
            result = await geo_cache.re_resolve_all(db, session)

        assert result == {"resolved": 0, "total": 0}
        mock_clear.assert_not_called()
        mock_lookup.assert_not_called()

    async def test_clears_neg_cache_and_returns_counts(self, geo_cache: GeoCache) -> None:
        """The service clears negative cache and returns resolved and total counts."""
        db = MagicMock()
        session = MagicMock()
        ips = ["1.1.1.1", "2.2.2.2"]
        geo_map = {
            "1.1.1.1": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None),
            "2.2.2.2": GeoInfo(country_code=None, country_name=None, asn=None, org=None),
        }

        with (
            patch(
                "app.repositories.geo_cache_repo.get_unresolved_ips",
                AsyncMock(return_value=ips),
            ),
            patch.object(
                geo_cache,
                "lookup_batch",
                AsyncMock(return_value=geo_map),
            ) as mock_lookup,
            patch.object(
                geo_cache,
                "clear_neg_cache",
                AsyncMock(),
            ) as mock_clear,
        ):
            result = await geo_cache.re_resolve_all(db, session)

        assert result == {"resolved": 1, "total": 2}
        mock_clear.assert_called_once()
        mock_lookup.assert_awaited_once_with(ips, session, db=db)


# ---------------------------------------------------------------------------
# Bulk DB writes via executemany (Task 3)
# ---------------------------------------------------------------------------


class TestLookupBatchBulkWrites:
    """lookup_batch() uses executemany for bulk DB writes, not per-IP execute."""

    async def test_executemany_called_for_successful_ips(self, geo_cache: GeoCache) -> None:
        """When multiple IPs resolve successfully, a single executemany write occurs."""
        ips = ["1.1.1.1", "2.2.2.2", "3.3.3.3"]
        batch_response = [
            {
                "query": ip,
                "status": "success",
                "countryCode": "DE",
                "country": "Germany",
                "as": "AS3320",
                "org": "Telekom",
            }
            for ip in ips
        ]
        session = _make_batch_session(batch_response)
        db = _make_async_db()

        await geo_cache.lookup_batch(ips, session, db=db)

        # One executemany for the positive rows.
        assert db.executemany.await_count >= 1
        # BEGIN IMMEDIATE is called for transaction wrapper.
        assert db.execute.await_count == 1

    async def test_executemany_called_for_failed_ips(self, geo_cache: GeoCache) -> None:
        """When IPs fail resolution, a single executemany write covers neg entries."""
        ips = ["10.0.0.1", "10.0.0.2"]
        batch_response = [{"query": ip, "status": "fail", "message": "private range"} for ip in ips]
        session = _make_batch_session(batch_response)
        db = _make_async_db()

        await geo_cache.lookup_batch(ips, session, db=db)

        assert db.executemany.await_count >= 1
        # BEGIN IMMEDIATE is called for transaction wrapper.
        assert db.execute.await_count == 1

    async def test_mixed_results_two_executemany_calls(self, geo_cache: GeoCache) -> None:
        """A mix of successful and failed IPs produces two executemany calls."""
        ips = ["1.1.1.1", "10.0.0.1"]
        batch_response = [
            {
                "query": "1.1.1.1",
                "status": "success",
                "countryCode": "AU",
                "country": "Australia",
                "as": "AS13335",
                "org": "Cloudflare",
            },
            {"query": "10.0.0.1", "status": "fail", "message": "private range"},
        ]
        session = _make_batch_session(batch_response)
        db = _make_async_db()

        await geo_cache.lookup_batch(ips, session, db=db)

        # One executemany for positives, one for negatives.
        assert db.executemany.await_count == 2
        # BEGIN IMMEDIATE is called for transaction wrapper.
        assert db.execute.await_count == 1


# ---------------------------------------------------------------------------
# Cache metrics (Issue #15)
# ---------------------------------------------------------------------------


class TestCacheMetrics:
    """Metrics counters track cache hit/miss ratios."""

    async def test_cache_hit_increments_hits(self) -> None:
        """lookup() with a cached IP increments _hits."""
        geo_cache = GeoCache(allow_http_fallback=True)
        geo_cache._cache["1.1.1.1"] = GeoInfo(country_code="AU", country_name="Australia", asn=None, org=None)

        await geo_cache.lookup("1.1.1.1", MagicMock())

        assert geo_cache._hits == 1
        assert geo_cache._misses == 0

    async def test_cache_miss_increments_misses(self) -> None:
        """lookup() with allow_http_fallback=False increments _misses on MMDB miss."""
        geo_cache = GeoCache(allow_http_fallback=False)

        await geo_cache.lookup("10.255.255.1", MagicMock())

        assert geo_cache._hits == 0
        assert geo_cache._misses == 1

    async def test_batch_hits_count_cached_ips(self) -> None:
        """lookup_batch increments _hits for IPs already in cache."""
        geo_cache = GeoCache(allow_http_fallback=True)

        session = _make_batch_session(
            [
                {
                    "query": "1.1.1.1",
                    "status": "success",
                    "countryCode": "AU",
                    "country": "Australia",
                    "as": "AS13335",
                    "org": "Cloudflare",
                },
            ]
        )
        # First: populate cache for 1.1.1.1.
        await geo_cache.lookup_batch(["1.1.1.1"], session)

        # Second call: 1.1.1.1 is cache hit; 2.2.2.2 is not cached → HTTP call.
        session2 = _make_batch_session(
            [
                {
                    "query": "2.2.2.2",
                    "status": "success",
                    "countryCode": "BR",
                    "country": "Brazil",
                    "as": "AS1",
                    "org": "Org",
                },
            ]
        )
        await geo_cache.lookup_batch(["1.1.1.1", "2.2.2.2"], session2)

        assert geo_cache._hits == 1, f"Expected 1 hit, got {geo_cache._hits}"
        assert geo_cache._misses == 2, f"Expected 2 misses (both IPs needed resolution), got {geo_cache._misses}"

    async def test_cache_stats_includes_hits_and_misses(self) -> None:
        """cache_stats() returns hits and misses counters."""
        geo_cache = GeoCache()
        db = MagicMock()

        # Patch count_unresolved to avoid the async context manager issue.
        from app.repositories import geo_cache_repo

        with patch.object(
            geo_cache_repo,
            "count_unresolved",
            AsyncMock(return_value=0),
        ):
            stats = await geo_cache.cache_stats(db)

        assert "hits" in stats
        assert "misses" in stats
        assert stats["hits"] == geo_cache._hits
        assert stats["misses"] == geo_cache._misses

    async def test_clear_resets_hits_and_misses(self) -> None:
        """clear() resets _hits and _misses to zero."""
        geo_cache = GeoCache()
        geo_cache._hits = 42
        geo_cache._misses = 99

        await geo_cache.clear()

        assert geo_cache._hits == 0
        assert geo_cache._misses == 0


# ---------------------------------------------------------------------------
# Pre-warming (Issue #15)
# ---------------------------------------------------------------------------


class TestPrewarm:
    """prewarm() loads geo data without blocking the caller."""

    async def test_prewarm_fires_and_forgets(self) -> None:
        """prewarm() returns None immediately (fire-and-forget)."""
        geo_cache = GeoCache(allow_http_fallback=True)
        session = _make_batch_session(
            [
                {
                    "query": "1.1.1.1",
                    "status": "success",
                    "countryCode": "AU",
                    "country": "Australia",
                    "as": "AS13335",
                    "org": "Cloudflare",
                },
            ]
        )

        result = geo_cache.prewarm(["1.1.1.1"], session)
        assert result is None
        # Let the fire-and-forget task complete.
        await asyncio.sleep(0.05)

    async def test_prewarm_populates_cache_eventually(self) -> None:
        """prewarm() eventually populates the cache via lookup_batch."""
        geo_cache = GeoCache(allow_http_fallback=True)
        session = _make_batch_session(
            [
                {
                    "query": "1.1.1.1",
                    "status": "success",
                    "countryCode": "AU",
                    "country": "Australia",
                    "as": "AS13335",
                    "org": "Cloudflare",
                },
            ]
        )

        geo_cache.prewarm(["1.1.1.1"], session)
        # Let the fire-and-forget task run.
        await asyncio.sleep(0.05)

        assert "1.1.1.1" in geo_cache._cache

    async def test_prewarm_accepts_empty_list(self) -> None:
        """prewarm() with an empty list does not raise."""
        geo_cache = GeoCache()
        result = geo_cache.prewarm([], MagicMock())
        assert result is None
        await asyncio.sleep(0.01)


# ---------------------------------------------------------------------------
# Large ban list performance (Issue #15)
# ---------------------------------------------------------------------------


class TestLargeBanList:
    """lookup_batch handles large IP lists without O(n) per-IP overhead."""

    async def test_batch_processes_1000_ips_single_db_write(self) -> None:
        """1000 IPs should result in a single bulk DB write per chunk."""
        geo_cache = GeoCache(allow_http_fallback=True)
        ips = [f"1.1.1.{i % 256}" for i in range(1000)]
        batch_response = [
            {
                "query": ip,
                "status": "success",
                "countryCode": "US",
                "country": "United States",
                "as": "AS1",
                "org": "Org1",
            }
            for ip in ips
        ]
        session = _make_batch_session(batch_response)
        db = _make_async_db()

        await geo_cache.lookup_batch(ips, session, db=db)

        assert db.executemany.await_count >= 1

    async def test_batch_deduplicates_ips(self) -> None:
        """lookup_batch deduplicates input IPs to avoid redundant work."""
        geo_cache = GeoCache(allow_http_fallback=True)
        ips = ["1.1.1.1", "1.1.1.1", "1.1.1.1"]

        session = _make_batch_session(
            [
                {
                    "query": "1.1.1.1",
                    "status": "success",
                    "countryCode": "AU",
                    "country": "Australia",
                    "as": "AS13335",
                    "org": "Cloudflare",
                },
            ]
        )

        result = await geo_cache.lookup_batch(ips, session)

        assert len(result) == 1
        assert "1.1.1.1" in result
