"""
Familiar — Capacity Tracking, Smart Ollama Fallback & Notification Tests
=========================================================================
Tests for the three features added in the smart provider fallback plan:
  1. Smart Ollama model selection (get_best_ollama_model, _extract_param_size)
  2. Per-provider rate limit capacity tracking (ProviderCapacityTracker)
  3. In-chat fallback notification (fallback_notice prepended to response)

Run with:
    pytest familiar/tests/test_capacity.py -v
"""

import json
import sys
import time
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent))

from familiar.core.providers import (
    LLMProvider,
    LLMResponse,
    _extract_param_size,
    get_best_ollama_model,
)
from familiar.core.resilience import ProviderCapacityTracker

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


@pytest.fixture(autouse=True)
def isolate_sessions(tmp_path, monkeypatch):
    """Redirect session storage to a temp directory so tests never touch
    the real ~/.familiar/data/sessions/ directory."""
    sessions_dir = tmp_path / "sessions"
    sessions_dir.mkdir()
    monkeypatch.setattr("familiar.core.security.SESSIONS_DIR", sessions_dir)
    return sessions_dir


@pytest.fixture
def config():
    """Default config with safe test values."""
    from familiar.core.config import load_config

    cfg = load_config()
    cfg.llm.default_provider = "ollama"
    cfg.agent.memory_enabled = False
    cfg.agent.scheduler_enabled = False
    return cfg


@pytest.fixture
def mock_provider():
    """A mock LLMProvider with sensible defaults."""
    p = MagicMock(spec=LLMProvider)
    p.name = "Gemini"
    p.model_name = "gemini-pro"
    p.supports_tools = True
    p.supports_streaming = False
    p.chat.return_value = LLMResponse(
        text="Test response",
        tool_calls=[],
        stop_reason="end_turn",
        usage={"input_tokens": 10, "output_tokens": 8},
    )
    return p


@pytest.fixture
def agent(config, mock_provider):
    """An Agent wired to the mock provider."""
    from familiar.core.agent import Agent

    with patch("familiar.core.provider_manager.get_provider", return_value=mock_provider):
        a = Agent(config=config)
    a.provider = mock_provider
    return a


# ---------------------------------------------------------------------------
# 1. _extract_param_size
# ---------------------------------------------------------------------------


class TestExtractParamSize:
    """Test parameter size extraction from model names and metadata."""

    def test_from_ollama_metadata_billions(self):
        info = {"details": {"parameter_size": "7.6B"}}
        assert _extract_param_size("qwen2.5:7b", info) == 7.6

    def test_from_ollama_metadata_millions(self):
        info = {"details": {"parameter_size": "360M"}}
        assert _extract_param_size("smollm2:360m", info) == pytest.approx(0.36)

    def test_from_tag_billions(self):
        assert _extract_param_size("qwen2.5:7b") == 7.0

    def test_from_tag_billions_decimal(self):
        assert _extract_param_size("llama3.2:3.2b") == 3.2

    def test_from_tag_millions(self):
        assert _extract_param_size("smollm2:135m") == pytest.approx(0.135)

    def test_no_tag(self):
        assert _extract_param_size("llama3.2") == 0.0

    def test_latest_tag(self):
        assert _extract_param_size("qwen2.5:latest") == 0.0

    def test_empty_metadata(self):
        assert _extract_param_size("qwen2.5:7b", {}) == 7.0

    def test_metadata_preferred_over_tag(self):
        """Metadata should take priority over tag parsing."""
        info = {"details": {"parameter_size": "7.6B"}}
        # Tag says 7b but metadata says 7.6B — metadata wins
        assert _extract_param_size("qwen2.5:7b", info) == 7.6


# ---------------------------------------------------------------------------
# 2. get_best_ollama_model
# ---------------------------------------------------------------------------


class TestGetBestOllamaModel:
    """Test smart Ollama fallback model selection."""

    def _make_ollama_response(self, models):
        """Build a mock /api/tags response."""
        return json.dumps({"models": models}).encode()

    @patch.dict("os.environ", {"OLLAMA_FALLBACK_MODEL": "mistral:7b"}, clear=False)
    def test_env_var_override(self, config):
        """OLLAMA_FALLBACK_MODEL env var takes priority."""
        with patch("familiar.core.providers.OllamaProvider") as MockOllama:
            instance = MagicMock()
            instance.name = "Ollama (mistral:7b)"
            MockOllama.return_value = instance
            result = get_best_ollama_model(config)
            assert result is not None
            MockOllama.assert_called_once_with("mistral:7b", config.llm.ollama_base_url.rstrip("/"))

    def test_selects_largest_tool_capable(self, config, monkeypatch):
        """Should pick the largest model that supports tools."""
        monkeypatch.delenv("OLLAMA_FALLBACK_MODEL", raising=False)
        models = [
            {"name": "qwen2.5:3b", "details": {"parameter_size": "3.1B"}},
            {"name": "qwen2.5:7b", "details": {"parameter_size": "7.6B"}},
            {"name": "llama3.2:1b", "details": {"parameter_size": "1.2B"}},
        ]
        response_data = json.dumps({"models": models}).encode()

        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_resp = MagicMock()
            mock_resp.read.return_value = response_data
            mock_resp.__enter__ = MagicMock(return_value=mock_resp)
            mock_resp.__exit__ = MagicMock(return_value=False)
            mock_urlopen.return_value = mock_resp

            result = get_best_ollama_model(config)

        # Should pick qwen2.5:7b (largest)
        assert result is not None
        assert "7b" in result.model

    def test_no_models_returns_none(self, config, monkeypatch):
        """When Ollama has no models, return None."""
        monkeypatch.delenv("OLLAMA_FALLBACK_MODEL", raising=False)
        response_data = json.dumps({"models": []}).encode()

        with patch("urllib.request.urlopen") as mock_urlopen:
            mock_resp = MagicMock()
            mock_resp.read.return_value = response_data
            mock_resp.__enter__ = MagicMock(return_value=mock_resp)
            mock_resp.__exit__ = MagicMock(return_value=False)
            mock_urlopen.return_value = mock_resp

            result = get_best_ollama_model(config)
        assert result is None

    def test_ollama_not_running_returns_none(self, config, monkeypatch):
        """When Ollama isn't running, return None gracefully."""
        monkeypatch.delenv("OLLAMA_FALLBACK_MODEL", raising=False)

        with patch("urllib.request.urlopen", side_effect=ConnectionError("refused")):
            result = get_best_ollama_model(config)
        assert result is None


# ---------------------------------------------------------------------------
# 3. ProviderCapacityTracker
# ---------------------------------------------------------------------------


class TestProviderCapacityTracker:
    """Test per-provider rate limit capacity tracking."""

    def test_initially_not_rate_limited(self):
        tracker = ProviderCapacityTracker()
        assert tracker.is_rate_limited("gemini") is False

    def test_single_event_below_threshold(self):
        tracker = ProviderCapacityTracker(threshold=2)
        tracker.record_rate_limit("gemini")
        assert tracker.is_rate_limited("gemini") is False

    def test_threshold_reached(self):
        tracker = ProviderCapacityTracker(threshold=2)
        tracker.record_rate_limit("gemini")
        tracker.record_rate_limit("gemini")
        assert tracker.is_rate_limited("gemini") is True

    def test_window_expiry(self):
        tracker = ProviderCapacityTracker(window_seconds=0.1, threshold=2)
        tracker.record_rate_limit("gemini")
        tracker.record_rate_limit("gemini")
        assert tracker.is_rate_limited("gemini") is True
        time.sleep(0.15)
        assert tracker.is_rate_limited("gemini") is False

    def test_case_insensitive(self):
        tracker = ProviderCapacityTracker(threshold=2)
        tracker.record_rate_limit("Gemini")
        tracker.record_rate_limit("GEMINI")
        assert tracker.is_rate_limited("gemini") is True

    def test_separate_providers(self):
        tracker = ProviderCapacityTracker(threshold=2)
        tracker.record_rate_limit("gemini")
        tracker.record_rate_limit("gemini")
        assert tracker.is_rate_limited("gemini") is True
        assert tracker.is_rate_limited("anthropic") is False

    def test_get_status(self):
        tracker = ProviderCapacityTracker(threshold=2)
        tracker.record_rate_limit("gemini")
        status = tracker.get_status()
        assert "gemini" in status
        assert status["gemini"]["events_in_window"] == 1
        assert status["gemini"]["is_rate_limited"] is False

    def test_thread_safety(self):
        """Verify tracker doesn't crash under concurrent access."""
        import threading

        tracker = ProviderCapacityTracker(threshold=100)
        errors = []

        def record_many():
            try:
                for _ in range(50):
                    tracker.record_rate_limit("gemini")
                    tracker.is_rate_limited("gemini")
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=record_many) for _ in range(4)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert not errors
        # All 200 events should be recorded
        status = tracker.get_status()
        assert status["gemini"]["events_in_window"] == 200


# ---------------------------------------------------------------------------
# 4. Fallback notification
# ---------------------------------------------------------------------------


class TestFallbackNotification:
    """Test that fallback notices are prepended to responses."""

    def test_reactive_fallback_includes_notice(self, agent, mock_provider):
        """When primary provider fails, response should include a fallback notice."""
        # Make primary provider fail with a rate limit error
        mock_provider.chat.side_effect = Exception("RateLimitError: 429 Too Many Requests")
        mock_provider.name = "Gemini"

        # Create a fallback provider that succeeds
        fallback = MagicMock(spec=LLMProvider)
        fallback.name = "Ollama (qwen2.5:7b)"
        fallback.model_name = "qwen2.5:7b"
        fallback.supports_tools = True
        fallback.chat.return_value = LLMResponse(
            text="Fallback response",
            tool_calls=[],
            stop_reason="end_turn",
            usage={"input_tokens": 10, "output_tokens": 5},
        )

        with patch.object(agent, "_provider_fallback", return_value=fallback):
            result = agent.chat("hello", user_id="test_user")

        assert "[Gemini unavailable — using Ollama (qwen2.5:7b)]" in result
        assert "Fallback response" in result

    def test_no_notice_on_normal_response(self, agent, mock_provider):
        """Normal responses should not have a fallback notice."""
        result = agent.chat("hello", user_id="test_user")
        assert result is not None
        assert "unavailable" not in result


# ---------------------------------------------------------------------------
# 5. Proactive skip
# ---------------------------------------------------------------------------


class TestProactiveSkip:
    """Test proactive provider skip when capacity tracker says rate-limited."""

    def test_proactive_skip_avoids_retries(self, agent, mock_provider):
        """Pre-recorded rate limits should trigger fallback without burning retries."""
        # Pre-record rate limits for "gemini"
        agent.capacity_tracker.record_rate_limit("gemini")
        agent.capacity_tracker.record_rate_limit("gemini")
        assert agent.capacity_tracker.is_rate_limited("gemini") is True

        # Primary provider is Gemini — should be skipped proactively
        mock_provider.name = "Gemini"

        # Fallback provider
        fallback = MagicMock(spec=LLMProvider)
        fallback.name = "Ollama (qwen2.5:7b)"
        fallback.model_name = "qwen2.5:7b"
        fallback.supports_tools = True
        fallback.chat.return_value = LLMResponse(
            text="Proactive fallback",
            tool_calls=[],
            stop_reason="end_turn",
            usage={"input_tokens": 10, "output_tokens": 5},
        )

        with patch.object(agent, "_provider_fallback", return_value=fallback):
            result = agent.chat("hello", user_id="test_user")

        # Primary provider.chat should NOT have been called (proactive skip)
        mock_provider.chat.assert_not_called()
        assert "rate limited" in result
        assert "Proactive fallback" in result
