"""Tests for the short-TTL search result cache in api/main.py.

Covers:
  - _cache_key stability (same inputs → same key)
  - _cache_key uniqueness (different inputs → different keys)
  - cache hit path returns early without scraping (async worker)
  - cache miss path stores result in _search_result_cache
  - refresh=True bypasses cache read (still writes fresh result)
  - TTL expiry: expired entries are not returned as hits
  - _evict_expired_cache removes expired entries
"""
from __future__ import annotations

import os
import time
from unittest.mock import MagicMock, patch

import pytest

# ── Helpers ───────────────────────────────────────────────────────────────────

def _clear_cache():
    """Reset module-level cache state between tests."""
    import api.main as _main
    _main._search_result_cache.clear()
    _main._last_eviction_ts = 0.0


@pytest.fixture(autouse=True)
def isolated_cache():
    """Ensure each test starts with an empty cache."""
    _clear_cache()
    yield
    _clear_cache()


@pytest.fixture
def client(tmp_path):
    """TestClient backed by a fresh tmp DB."""
    os.environ["SNIPE_DB"] = str(tmp_path / "snipe.db")
    from fastapi.testclient import TestClient

    from api.main import app
    return TestClient(app, raise_server_exceptions=False)


def _make_mock_listing(listing_id: str = "123456789", seller_id: str = "test_seller"):
    """Return a MagicMock listing (for use where asdict() is NOT called on it)."""
    m = MagicMock()
    m.platform_listing_id = listing_id
    m.seller_platform_id = seller_id
    m.title = "Test GPU"
    m.price = 100.0
    m.currency = "USD"
    m.condition = "Used"
    m.url = f"https://www.ebay.com/itm/{listing_id}"
    m.photo_urls = []
    m.listing_age_days = 5
    m.buying_format = "fixed_price"
    m.ends_at = None
    m.fetched_at = None
    m.trust_score_id = None
    m.id = 1
    m.category_name = None
    return m


def _make_real_listing(listing_id: str = "123456789", seller_id: str = "test_seller"):
    """Return a real Listing dataclass instance (for use where asdict() is called)."""
    from app.db.models import Listing
    return Listing(
        platform="ebay",
        platform_listing_id=listing_id,
        title="Test GPU",
        price=100.0,
        currency="USD",
        condition="Used",
        seller_platform_id=seller_id,
        url=f"https://www.ebay.com/itm/{listing_id}",
        photo_urls=[],
        listing_age_days=5,
        buying_format="fixed_price",
        id=None,
    )


# ── _cache_key unit tests ─────────────────────────────────────────────────────

def test_cache_key_stable_for_same_inputs():
    """The same parameter set always produces the same key."""
    from api.main import _cache_key
    k1 = _cache_key("rtx 3080", 400.0, 100.0, 2, "rtx,3080", "all", "mining", "27386")
    k2 = _cache_key("rtx 3080", 400.0, 100.0, 2, "rtx,3080", "all", "mining", "27386")
    assert k1 == k2


def test_cache_key_case_normalised():
    """Query is normalised to lower-case + stripped before hashing."""
    from api.main import _cache_key
    k1 = _cache_key("RTX 3080", None, None, 1, "", "all", "", "")
    k2 = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
    assert k1 == k2


def test_cache_key_differs_on_query_change():
    """Different query strings must produce different keys."""
    from api.main import _cache_key
    k1 = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
    k2 = _cache_key("gtx 1080", None, None, 1, "", "all", "", "")
    assert k1 != k2


def test_cache_key_differs_on_price_filter():
    """Different max_price must produce a different key."""
    from api.main import _cache_key
    k1 = _cache_key("gpu", 400.0, None, 1, "", "all", "", "")
    k2 = _cache_key("gpu", 500.0, None, 1, "", "all", "", "")
    assert k1 != k2


def test_cache_key_differs_on_min_price():
    """Different min_price must produce a different key."""
    from api.main import _cache_key
    k1 = _cache_key("gpu", None, 50.0, 1, "", "all", "", "")
    k2 = _cache_key("gpu", None, 100.0, 1, "", "all", "", "")
    assert k1 != k2


def test_cache_key_differs_on_pages():
    """Different page count must produce a different key."""
    from api.main import _cache_key
    k1 = _cache_key("gpu", None, None, 1, "", "all", "", "")
    k2 = _cache_key("gpu", None, None, 2, "", "all", "", "")
    assert k1 != k2


def test_cache_key_differs_on_must_include():
    """Different must_include terms must produce a different key."""
    from api.main import _cache_key
    k1 = _cache_key("gpu", None, None, 1, "rtx", "all", "", "")
    k2 = _cache_key("gpu", None, None, 1, "gtx", "all", "", "")
    assert k1 != k2


def test_cache_key_differs_on_must_exclude():
    """Different must_exclude terms must produce a different key."""
    from api.main import _cache_key
    k1 = _cache_key("gpu", None, None, 1, "", "all", "mining", "")
    k2 = _cache_key("gpu", None, None, 1, "", "all", "defective", "")
    assert k1 != k2


def test_cache_key_differs_on_category_id():
    """Different category_id must produce a different key."""
    from api.main import _cache_key
    k1 = _cache_key("gpu", None, None, 1, "", "all", "", "27386")
    k2 = _cache_key("gpu", None, None, 1, "", "all", "", "12345")
    assert k1 != k2


def test_cache_key_is_16_chars():
    """Key must be exactly 16 hex characters."""
    from api.main import _cache_key
    k = _cache_key("gpu", None, None, 1, "", "all", "", "")
    assert len(k) == 16
    assert all(c in "0123456789abcdef" for c in k)


# ── TTL / eviction unit tests ─────────────────────────────────────────────────

def test_expired_entry_is_not_returned_as_hit():
    """An entry past its TTL must not be treated as a cache hit."""
    import api.main as _main
    from api.main import _cache_key

    key = _cache_key("gpu", None, None, 1, "", "all", "", "")
    # Write an already-expired entry.
    _main._search_result_cache[key] = (
        {"listings": [], "market_price": None},
        time.time() - 1.0,  # expired 1 second ago
    )

    cached = _main._search_result_cache.get(key)
    assert cached is not None
    payload, expiry = cached
    # Simulate the hit-check used in main.py
    assert expiry <= time.time(), "Entry should be expired"


def test_evict_expired_cache_removes_stale_entries():
    """_evict_expired_cache must remove entries whose expiry has passed."""
    import api.main as _main
    from api.main import _cache_key, _evict_expired_cache

    key_expired = _cache_key("old query", None, None, 1, "", "all", "", "")
    key_valid = _cache_key("new query", None, None, 1, "", "all", "", "")

    _main._search_result_cache[key_expired] = (
        {"listings": [], "market_price": None},
        time.time() - 10.0,  # already expired
    )
    _main._search_result_cache[key_valid] = (
        {"listings": [], "market_price": 99.0},
        time.time() + 300.0,  # valid for 5 min
    )

    # Reset throttle so eviction runs immediately.
    _main._last_eviction_ts = 0.0
    _evict_expired_cache()

    assert key_expired not in _main._search_result_cache
    assert key_valid in _main._search_result_cache


def test_evict_is_rate_limited():
    """_evict_expired_cache should skip eviction if called within 60 s."""
    import api.main as _main
    from api.main import _cache_key, _evict_expired_cache

    key_expired = _cache_key("stale", None, None, 1, "", "all", "", "")
    _main._search_result_cache[key_expired] = (
        {"listings": [], "market_price": None},
        time.time() - 5.0,
    )

    # Pretend eviction just ran.
    _main._last_eviction_ts = time.time()
    _evict_expired_cache()

    # Entry should still be present because eviction was throttled.
    assert key_expired in _main._search_result_cache


# ── Integration tests — async endpoint cache hit ──────────────────────────────

def test_async_cache_hit_skips_scraper(client, tmp_path):
    """On a warm cache hit the scraper adapter must not be called."""
    import threading

    import api.main as _main
    from api.main import _cache_key

    # Pre-seed a valid cache entry.
    key = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
    _main._search_result_cache[key] = (
        {"listings": [], "market_price": 250.0},
        time.time() + 300.0,
    )

    scraper_called = threading.Event()

    def _fake_search(query, filters):
        scraper_called.set()
        return []

    with (
        patch("api.main._make_adapter") as mock_adapter_factory,
        patch("api.main._trigger_scraper_enrichment"),
        patch("api.main.TrustScorer") as mock_scorer_cls,
        patch("api.main.Store") as mock_store_cls,
    ):
        mock_adapter = MagicMock()
        mock_adapter.search.side_effect = _fake_search
        mock_adapter.get_completed_sales.return_value = None
        mock_adapter_factory.return_value = mock_adapter

        mock_scorer = MagicMock()
        mock_scorer.score_batch.return_value = []
        mock_scorer_cls.return_value = mock_scorer

        mock_store = MagicMock()
        mock_store.get_listings_staged.return_value = {}
        mock_store.refresh_seller_categories.return_value = 0
        mock_store.save_listings.return_value = None
        mock_store.save_trust_scores.return_value = None
        mock_store.get_market_comp.return_value = None
        mock_store.get_seller.return_value = None
        mock_store.get_user_preference.return_value = None
        mock_store_cls.return_value = mock_store

        resp = client.get("/api/search/async?q=rtx+3080")
        assert resp.status_code == 202

        # Give the background worker a moment to run.
        scraper_called.wait(timeout=3.0)

    # Scraper must NOT have been called on a cache hit.
    assert not scraper_called.is_set(), "Scraper was called despite a warm cache hit"


def test_async_cache_miss_stores_result(client, tmp_path):
    """After a cache miss the result must be stored in _search_result_cache."""
    import threading

    import api.main as _main
    from api.main import _cache_key

    search_done = threading.Event()
    real_listing = _make_real_listing()

    def _fake_search(query, filters):
        return [real_listing]

    with (
        patch("api.main._make_adapter") as mock_adapter_factory,
        patch("api.main._trigger_scraper_enrichment") as mock_enrich,
        patch("api.main.TrustScorer") as mock_scorer_cls,
        patch("api.main.Store") as mock_store_cls,
    ):
        mock_adapter = MagicMock()
        mock_adapter.search.side_effect = _fake_search
        mock_adapter.get_completed_sales.return_value = None
        mock_adapter_factory.return_value = mock_adapter

        mock_scorer = MagicMock()
        mock_scorer.score_batch.return_value = []
        mock_scorer_cls.return_value = mock_scorer

        mock_store = MagicMock()
        mock_store.get_listings_staged.return_value = {
            real_listing.platform_listing_id: real_listing
        }
        mock_store.refresh_seller_categories.return_value = 0
        mock_store.save_listings.return_value = None
        mock_store.save_trust_scores.return_value = None
        mock_store.get_market_comp.return_value = None
        mock_store.get_seller.return_value = None
        mock_store.get_user_preference.return_value = None
        mock_store_cls.return_value = mock_store

        def _enrich_side_effect(*args, **kwargs):
            search_done.set()

        mock_enrich.side_effect = _enrich_side_effect

        resp = client.get("/api/search/async?q=rtx+3080")
        assert resp.status_code == 202

        # Wait until the background worker reaches _trigger_scraper_enrichment.
        search_done.wait(timeout=5.0)

    assert search_done.is_set(), "Background search worker never completed"

    key = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
    assert key in _main._search_result_cache, "Result was not stored in cache after miss"
    payload, expiry = _main._search_result_cache[key]
    assert expiry > time.time(), "Cache entry has already expired"
    assert "listings" in payload


# ── Integration tests — async endpoint refresh=True ──────────────────────────

def test_async_refresh_bypasses_cache_read(client, tmp_path):
    """refresh=True must bypass cache read and invoke the scraper."""
    import threading

    import api.main as _main
    from api.main import _cache_key

    # Seed a valid cache entry so we can confirm it is bypassed.
    key = _cache_key("rtx 3080", None, None, 1, "", "all", "", "")
    _main._search_result_cache[key] = (
        {"listings": [], "market_price": 100.0},
        time.time() + 300.0,
    )

    scraper_called = threading.Event()

    def _fake_search(query, filters):
        scraper_called.set()
        return []

    with (
        patch("api.main._make_adapter") as mock_adapter_factory,
        patch("api.main._trigger_scraper_enrichment"),
        patch("api.main.TrustScorer") as mock_scorer_cls,
        patch("api.main.Store") as mock_store_cls,
    ):
        mock_adapter = MagicMock()
        mock_adapter.search.side_effect = _fake_search
        mock_adapter.get_completed_sales.return_value = None
        mock_adapter_factory.return_value = mock_adapter

        mock_scorer = MagicMock()
        mock_scorer.score_batch.return_value = []
        mock_scorer_cls.return_value = mock_scorer

        mock_store = MagicMock()
        mock_store.get_listings_staged.return_value = {}
        mock_store.refresh_seller_categories.return_value = 0
        mock_store.save_listings.return_value = None
        mock_store.save_trust_scores.return_value = None
        mock_store.get_market_comp.return_value = None
        mock_store.get_seller.return_value = None
        mock_store.get_user_preference.return_value = None
        mock_store_cls.return_value = mock_store

        resp = client.get("/api/search/async?q=rtx+3080&refresh=true")
        assert resp.status_code == 202

        scraper_called.wait(timeout=5.0)

    assert scraper_called.is_set(), "Scraper was not called even though refresh=True"
