"""
Tests for the TTL cache in api/config.py — get_available_models().

Validates:
  - Cache hit within TTL window
  - TTL expiry triggers re-scan
  - Config mtime change invalidates cache before TTL check
  - copy.deepcopy() isolation (mutating returned dict doesn't pollute cache)
  - invalidate_models_cache() direct invalidation
"""
import time
from unittest.mock import patch

import api.config as config


def _reset_cache():
    """Reset TTL cache globals to a clean state."""
    config._available_models_cache = None
    config._available_models_cache_ts = 0.0


# ── 1. test_cache_hit_within_ttl ──────────────────────────────────────────

def test_cache_hit_within_ttl():
    """Call get_available_models() twice within the TTL window.
    The second call should return cached data without re-scanning providers.
    We verify this by patching reload_config (called when cache is cold)
    and asserting it is only invoked once.
    """
    _reset_cache()
    original_reload = config.reload_config

    call_count = 0

    def _counting_reload():
        nonlocal call_count
        call_count += 1
        return original_reload()

    with patch.object(config, "reload_config", wraps=original_reload, side_effect=_counting_reload):
        saved_mtime = config._cfg_mtime
        try:
            # Force mtime mismatch so the first call triggers reload_config + cache fill
            config._cfg_mtime = 0.0
            result1 = config.get_available_models()
            first_call_count = call_count

            # Sync _cfg_mtime to the actual file so the second call doesn't
            # re-trigger reload_config via mtime mismatch — we want it to hit the TTL cache.
            try:
                config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
            except OSError:
                config._cfg_mtime = 0.0

            result2 = config.get_available_models()

            # Both results should have the same structure
            assert "groups" in result1
            assert "groups" in result2

            # reload_config should not have been called again for the second invocation
            # (the TTL cache served it)
            assert call_count == first_call_count, (
                f"Expected no extra reload_config calls, but got "
                f"{call_count - first_call_count} extra"
            )
        finally:
            config._cfg_mtime = saved_mtime
    _reset_cache()


# ── 2. test_ttl_expiry ───────────────────────────────────────────────────

def test_ttl_expiry():
    """Populate the cache, then advance time.monotonic() past 60s.
    The next call should re-scan (not serve from cache).
    """
    _reset_cache()

    # Ensure _cfg_mtime matches file so mtime check doesn't invalidate
    try:
        config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
    except OSError:
        config._cfg_mtime = 0.0

    # First call populates cache
    result1 = config.get_available_models()
    assert config._available_models_cache is not None, "Cache should be populated"

    # Record the cache timestamp
    cache_ts = config._available_models_cache_ts

    # Advance time.monotonic() by more than the TTL
    original_monotonic = time.monotonic
    offset = config._AVAILABLE_MODELS_CACHE_TTL + 10.0  # 70s past the real monotonic

    with patch.object(time, "monotonic", side_effect=lambda: original_monotonic() + offset):
        result2 = config.get_available_models()

    # The cache should have been refreshed — the timestamp must be newer
    assert config._available_models_cache_ts > cache_ts, (
        "Cache should have been refreshed after TTL expiry"
    )

    _reset_cache()


# ── 3. test_mtime_invalidation ───────────────────────────────────────────

def test_mtime_invalidation():
    """Populate the cache, then change _cfg_mtime to simulate a config file
    change on disk. The next call should invalidate the cache and re-scan.
    """
    _reset_cache()

    # Ensure _cfg_mtime matches file so first call doesn't re-scan due to mtime
    try:
        real_mtime = config.Path(config._get_config_path()).stat().st_mtime
    except OSError:
        real_mtime = 0.0
    config._cfg_mtime = real_mtime

    # First call populates cache
    result1 = config.get_available_models()
    assert config._available_models_cache is not None

    # Simulate config.yaml changed on disk by setting _cfg_mtime to 0
    # (which won't match the actual file mtime)
    config._cfg_mtime = 0.0

    # The next call should detect mtime mismatch, reload, and invalidate cache
    old_cache = config._available_models_cache
    old_ts = config._available_models_cache_ts

    result2 = config.get_available_models()

    # Cache must have been refreshed — timestamp advanced since we reset it
    # to 0.0 on invalidation.
    assert config._available_models_cache_ts > 0.0, (
        "Cache timestamp should be updated after invalidation + rebuild"
    )

    # Restore
    config._cfg_mtime = real_mtime
    _reset_cache()


# ── 4. test_deepcopy_isolation ────────────────────────────────────────────

def test_deepcopy_isolation():
    """Mutating the returned dict from get_available_models() must not
    affect the cache or subsequent return values.
    """
    _reset_cache()

    # Ensure _cfg_mtime matches file so mtime check doesn't invalidate
    try:
        config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
    except OSError:
        config._cfg_mtime = 0.0

    # First call populates cache
    result1 = config.get_available_models()

    # Mutate the returned dict
    if result1["groups"]:
        result1["groups"][0]["models"].clear()
    result1["groups"].append({"provider": "FAKE", "models": [{"id": "fake-model"}]})
    result1["active_provider"] = "HACKED"

    # Second call should return an unmutated copy
    result2 = config.get_available_models()

    # The mutated keys must not appear in the second result
    assert result2["active_provider"] != "HACKED", "Mutation leaked into cache"
    assert not any(
        g.get("provider") == "FAKE" for g in result2["groups"]
    ), "Fake provider leaked into cache"

    # If there were groups originally, the first group's models should not be empty
    # (unless it genuinely had no models, which is unlikely)
    if result1["groups"] and result2["groups"]:
        # result1["groups"][0]["models"] was cleared, but result2 should be intact
        assert len(result2["groups"][0].get("models", [])) > 0, (
            "Mutation of result1 cleared models in result2 — deepcopy failed"
        )

    _reset_cache()


# ── 5. test_invalidate_models_cache_direct ───────────────────────────────

def test_invalidate_models_cache_direct():
    """Call invalidate_models_cache() after populating the cache.
    _AVAILABLE_MODELS_CACHE should be None and the next call should re-scan.
    """
    _reset_cache()

    # Ensure _cfg_mtime matches file so mtime check doesn't invalidate
    try:
        config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
    except OSError:
        config._cfg_mtime = 0.0

    # First call populates cache
    result1 = config.get_available_models()
    assert config._available_models_cache is not None, "Cache should be populated"
    first_ts = config._available_models_cache_ts

    # Directly invalidate
    config.invalidate_models_cache()

    # Cache must be cleared
    assert config._available_models_cache is None, (
        "invalidate_models_cache() should set _AVAILABLE_MODELS_CACHE to None"
    )

    # Next call should re-scan and produce a fresh cache
    result2 = config.get_available_models()
    assert config._available_models_cache is not None, "Cache should be re-populated"
    assert config._available_models_cache_ts >= first_ts, (
        "Cache timestamp should be updated after re-scan"
    )

    _reset_cache()
