"""Tests for llmhosts.router.knn -- Tier 1 kNN embedding router.

Covers ScoredRoute / KNNRoutingResult models, KNNRouter lifecycle,
route decisions (escalate / explore / route / low-confidence), text
extraction, multi-factor scoring, outcome recording, feedback, and
readiness checks.

Since ``KNN_AVAILABLE`` depends on optional imports (faiss, numpy,
onnxruntime), we patch it to ``True`` for most tests and only test the
False path explicitly.
"""

from __future__ import annotations

import time
from typing import Any
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch

import pytest

try:
    import numpy as np

    HAS_NUMPY = True
except ImportError:
    HAS_NUMPY = False

pytestmark = pytest.mark.skipif(not HAS_NUMPY, reason="numpy not installed (optional [smart] tier)")

from llmhosts.proxy.models import UnifiedMessage, UnifiedRequest  # noqa: E402
from llmhosts.router.knn import (  # noqa: E402
    _FACTOR_WEIGHTS,
    KNNRouter,
    KNNRoutingResult,
    ScoredRoute,
)

# ============================================================================
# Helpers -- lightweight stand-ins for EmbeddingMetadata / SearchResult
# ============================================================================


class FakeMetadata:
    """Minimal stand-in for ``EmbeddingMetadata``."""

    def __init__(
        self,
        *,
        model_used: str = "llama3",
        backend_type: str = "ollama",
        latency_ms: float = 100.0,
        cost: float = 0.001,
        user_feedback: float | None = None,
        timestamp: float | None = None,
        request_id: str = "req-1",
        routing_tier: str = "knn",
        was_exploration: bool = False,
    ) -> None:
        self.model_used = model_used
        self.backend_type = backend_type
        self.latency_ms = latency_ms
        self.cost = cost
        self.user_feedback = user_feedback
        self.timestamp = timestamp if timestamp is not None else time.time()
        self.request_id = request_id
        self.routing_tier = routing_tier
        self.was_exploration = was_exploration


class FakeSearchResult:
    """Minimal stand-in for ``SearchResult``."""

    def __init__(self, score: float, metadata: FakeMetadata) -> None:
        self.score = score
        self.metadata = metadata


# ============================================================================
# Fixtures
# ============================================================================


def _make_request(
    model: str = "llama3",
    messages: list[dict[str, Any]] | None = None,
) -> UnifiedRequest:
    """Build a ``UnifiedRequest`` with sensible defaults."""
    if messages is None:
        messages = [{"role": "user", "content": "Hello, world!"}]
    return UnifiedRequest(
        model=model,
        messages=[UnifiedMessage(**m) for m in messages],
    )


def _mock_pipeline(embedding: Any = None) -> MagicMock:
    """Return a mock ``EmbeddingPipeline`` whose ``embed`` returns *embedding*."""
    if embedding is None:
        embedding = np.zeros(384, dtype=np.float32)  # type: ignore[union-attr]
    pipeline = MagicMock()
    pipeline.embed.return_value = embedding
    type(pipeline).is_ready = PropertyMock(return_value=True)
    return pipeline


def _mock_store(
    size: int = 50,
    search_results: list[FakeSearchResult] | None = None,
    is_ready: bool = True,
) -> MagicMock:
    """Return a mock ``EmbeddingStore``."""
    store = MagicMock()
    type(store).size = PropertyMock(return_value=size)
    type(store).is_ready = PropertyMock(return_value=is_ready)
    store.search = AsyncMock(return_value=search_results or [])
    store.add = AsyncMock(return_value=1)
    store.save = AsyncMock()
    store.update_feedback = AsyncMock(return_value=True)
    return store


# ============================================================================
# 1. Pydantic model creation
# ============================================================================


class TestScoredRoute:
    def test_creation_with_defaults(self) -> None:
        route = ScoredRoute(model="llama3", backend="ollama", score=0.92)
        assert route.model == "llama3"
        assert route.backend == "ollama"
        assert route.score == 0.92
        assert route.factor_scores == {}

    def test_creation_with_factor_scores(self) -> None:
        factors = {"model_fit": 0.9, "latency": 0.8, "cost": 0.7, "quality": 0.6, "recency": 0.5}
        route = ScoredRoute(model="gpt-4", backend="openai", score=0.85, factor_scores=factors)
        assert route.factor_scores == factors
        assert route.score == 0.85


class TestKNNRoutingResult:
    def test_route_action(self) -> None:
        result = KNNRoutingResult(
            action="route",
            confidence=0.95,
            suggested_model="llama3",
            suggested_backend="ollama",
            reasoning="High confidence",
            neighbor_count=10,
            scored_routes=[ScoredRoute(model="llama3", backend="ollama", score=0.95)],
            latency_ms=5.2,
        )
        assert result.action == "route"
        assert result.confidence == 0.95
        assert result.suggested_model == "llama3"
        assert result.exploration is False
        assert len(result.scored_routes) == 1

    def test_escalate_action_defaults(self) -> None:
        result = KNNRoutingResult(action="escalate", confidence=0.0, reasoning="Not enough data")
        assert result.suggested_model is None
        assert result.suggested_backend is None
        assert result.neighbor_count == 0
        assert result.exploration is False
        assert result.scored_routes == []
        assert result.latency_ms == 0.0

    def test_explore_action(self) -> None:
        result = KNNRoutingResult(action="explore", confidence=0.0, reasoning="Exploration", exploration=True)
        assert result.exploration is True
        assert result.action == "explore"


# ============================================================================
# 2. KNNRouter.__init__ when KNN_AVAILABLE is False
# ============================================================================


class TestKNNRouterInit:
    def test_raises_when_knn_unavailable(self) -> None:
        with (
            patch("llmhosts.router.knn.KNN_AVAILABLE", False),
            pytest.raises(RuntimeError, match="KNNRouter requires faiss-cpu"),
        ):
            KNNRouter(MagicMock(), MagicMock())

    def test_succeeds_when_knn_available(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            pipeline = _mock_pipeline()
            store = _mock_store()
            router = KNNRouter(pipeline, store)
            assert router._pipeline is pipeline
            assert router._store is store


# ============================================================================
# 3-8. route() decision paths
# ============================================================================


class TestRouteInsufficientData:
    @pytest.mark.asyncio
    async def test_escalate_when_store_too_small(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            router = KNNRouter(_mock_pipeline(), _mock_store(size=5))
            request = _make_request()
            result = await router.route(request)

            assert result.action == "escalate"
            assert result.confidence == 0.0
            assert "Insufficient training data" in result.reasoning
            assert "5/20" in result.reasoning
            assert result.neighbor_count == 0
            assert result.latency_ms >= 0

    @pytest.mark.asyncio
    async def test_escalate_at_boundary(self) -> None:
        """Store size == MIN_STORE_SIZE - 1 should still escalate."""
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            router = KNNRouter(_mock_pipeline(), _mock_store(size=19))
            result = await router.route(_make_request())
            assert result.action == "escalate"
            assert "19/20" in result.reasoning


class TestRouteExploration:
    @pytest.mark.asyncio
    async def test_explore_when_random_below_threshold(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True), patch("llmhosts.router.knn.random") as mock_random:
            mock_random.random.return_value = 0.01  # below 0.05
            router = KNNRouter(_mock_pipeline(), _mock_store(size=50))
            result = await router.route(_make_request())

            assert result.action == "explore"
            assert result.exploration is True
            assert result.confidence == 0.0
            assert "exploration" in result.reasoning.lower()
            assert result.latency_ms >= 0

    @pytest.mark.asyncio
    async def test_no_explore_when_random_above_threshold(self) -> None:
        """Exploration should NOT trigger when random >= 0.05."""
        now = time.time()
        neighbors = [
            FakeSearchResult(0.95, FakeMetadata(model_used="llama3", backend_type="ollama", timestamp=now)),
        ]
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True), patch("llmhosts.router.knn.random") as mock_random:
            mock_random.random.return_value = 0.5  # above 0.05
            store = _mock_store(size=50, search_results=neighbors)
            router = KNNRouter(_mock_pipeline(), store)
            result = await router.route(_make_request())

            assert result.action != "explore"


class TestRouteNoNeighbors:
    @pytest.mark.asyncio
    async def test_escalate_when_no_neighbors(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True), patch("llmhosts.router.knn.random") as mock_random:
            mock_random.random.return_value = 0.5  # skip exploration
            store = _mock_store(size=50, search_results=[])
            router = KNNRouter(_mock_pipeline(), store)
            result = await router.route(_make_request())

            assert result.action == "escalate"
            assert "No neighbours found" in result.reasoning
            assert result.neighbor_count == 0


class TestRouteHighConfidence:
    @pytest.mark.asyncio
    async def test_route_when_confident(self) -> None:
        now = time.time()
        # All neighbors point to the same model/backend with high similarity.
        # Use latency_ms=0 and cost=0 so those factors score 1.0 (the else
        # branch in the source returns 1.0 when avg <= 0).
        neighbors = [
            FakeSearchResult(
                0.98,
                FakeMetadata(
                    model_used="llama3",
                    backend_type="ollama",
                    latency_ms=0.0,
                    cost=0.0,
                    user_feedback=0.95,
                    timestamp=now,
                ),
            ),
            FakeSearchResult(
                0.96,
                FakeMetadata(
                    model_used="llama3",
                    backend_type="ollama",
                    latency_ms=0.0,
                    cost=0.0,
                    user_feedback=0.95,
                    timestamp=now,
                ),
            ),
        ]
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True), patch("llmhosts.router.knn.random") as mock_random:
            mock_random.random.return_value = 0.5
            store = _mock_store(size=50, search_results=neighbors)
            router = KNNRouter(_mock_pipeline(), store)
            result = await router.route(_make_request())

            assert result.action == "route"
            assert result.confidence >= KNNRouter.CONFIDENCE_THRESHOLD
            assert result.suggested_model == "llama3"
            assert result.suggested_backend == "ollama"
            assert result.neighbor_count == 2
            assert len(result.scored_routes) >= 1
            assert "kNN confident" in result.reasoning
            assert result.latency_ms >= 0


class TestRouteLowConfidence:
    @pytest.mark.asyncio
    async def test_escalate_with_suggestion_when_uncertain(self) -> None:
        now = time.time()
        # Low similarity scores produce low composite confidence
        neighbors = [
            FakeSearchResult(
                0.40,
                FakeMetadata(
                    model_used="llama3",
                    backend_type="ollama",
                    latency_ms=200.0,
                    cost=0.01,
                    user_feedback=0.3,
                    timestamp=now - 200000,  # old
                ),
            ),
            FakeSearchResult(
                0.35,
                FakeMetadata(
                    model_used="gpt-4",
                    backend_type="openai",
                    latency_ms=300.0,
                    cost=0.05,
                    user_feedback=0.4,
                    timestamp=now - 300000,
                ),
            ),
        ]
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True), patch("llmhosts.router.knn.random") as mock_random:
            mock_random.random.return_value = 0.5
            store = _mock_store(size=50, search_results=neighbors)
            router = KNNRouter(_mock_pipeline(), store)
            result = await router.route(_make_request())

            assert result.action == "escalate"
            assert result.confidence < KNNRouter.CONFIDENCE_THRESHOLD
            # Should still suggest the best candidate
            assert result.suggested_model is not None
            assert result.suggested_backend is not None
            assert "uncertain" in result.reasoning.lower()
            assert "escalating to Tier 2" in result.reasoning


class TestRouteNoScorableRoutes:
    @pytest.mark.asyncio
    async def test_escalate_when_scoring_returns_empty(self) -> None:
        """If ``_score_neighbors`` returns an empty list, we should escalate."""
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True), patch("llmhosts.router.knn.random") as mock_random:
            mock_random.random.return_value = 0.5
            neighbors = [FakeSearchResult(0.9, FakeMetadata())]
            store = _mock_store(size=50, search_results=neighbors)
            router = KNNRouter(_mock_pipeline(), store)
            # Patch _score_neighbors to return empty
            with patch.object(router, "_score_neighbors", return_value=[]):
                result = await router.route(_make_request())

            assert result.action == "escalate"
            assert "No scorable routes" in result.reasoning
            assert result.neighbor_count == 1


# ============================================================================
# 9. _request_to_text
# ============================================================================


class TestRequestToText:
    def _router(self) -> KNNRouter:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            return KNNRouter(_mock_pipeline(), _mock_store())

    def test_basic_text_extraction(self) -> None:
        router = self._router()
        request = _make_request(model="llama3", messages=[{"role": "user", "content": "Hello"}])
        text = router._request_to_text(request)
        assert "[model:llama3]" in text
        assert "[user] Hello" in text

    def test_multi_message(self) -> None:
        router = self._router()
        request = _make_request(
            model="gpt-4",
            messages=[
                {"role": "system", "content": "You are helpful."},
                {"role": "user", "content": "What is AI?"},
                {"role": "assistant", "content": "AI is..."},
                {"role": "user", "content": "Tell me more."},
            ],
        )
        text = router._request_to_text(request)
        assert "[model:gpt-4]" in text
        assert "[system] You are helpful." in text
        assert "[user] What is AI?" in text
        assert "[assistant] AI is..." in text
        assert "[user] Tell me more." in text

    def test_truncation_to_last_five_messages(self) -> None:
        router = self._router()
        messages = [{"role": "user", "content": f"Message {i}"} for i in range(8)]
        request = _make_request(messages=messages)
        text = router._request_to_text(request)
        # Should only include messages 3-7 (last 5)
        assert "Message 0" not in text
        assert "Message 1" not in text
        assert "Message 2" not in text
        assert "Message 3" in text
        assert "Message 7" in text

    def test_multi_part_content(self) -> None:
        """Multi-part content (e.g. vision) extracts text blocks."""
        router = self._router()
        request = _make_request(
            messages=[
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": "What is in this image?"},
                        {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
                        {"type": "text", "text": "Describe it."},
                    ],
                },
            ],
        )
        text = router._request_to_text(request)
        assert "What is in this image?" in text
        assert "Describe it." in text
        # Image URL should not be extracted
        assert "example.com" not in text

    def test_none_content_skipped(self) -> None:
        router = self._router()
        request = _make_request(
            messages=[
                {"role": "assistant", "content": None},
                {"role": "user", "content": "Hello"},
            ],
        )
        text = router._request_to_text(request)
        assert "[user] Hello" in text
        # None content should be skipped, so no "[assistant]"
        assert "[assistant]" not in text

    def test_empty_content_skipped(self) -> None:
        router = self._router()
        request = _make_request(
            messages=[
                {"role": "user", "content": ""},
                {"role": "user", "content": "Actual question"},
            ],
        )
        text = router._request_to_text(request)
        assert "Actual question" in text


# ============================================================================
# 10. _score_neighbors
# ============================================================================


class TestScoreNeighbors:
    def _router(self) -> KNNRouter:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            return KNNRouter(_mock_pipeline(), _mock_store())

    def test_single_group(self) -> None:
        """All neighbors same model/backend -> single ScoredRoute."""
        now = time.time()
        neighbors = [
            FakeSearchResult(0.9, FakeMetadata(model_used="llama3", backend_type="ollama", timestamp=now)),
            FakeSearchResult(0.8, FakeMetadata(model_used="llama3", backend_type="ollama", timestamp=now)),
        ]
        router = self._router()
        scored = router._score_neighbors(neighbors, _make_request())
        assert len(scored) == 1
        assert scored[0].model == "llama3"
        assert scored[0].backend == "ollama"
        assert 0.0 <= scored[0].score <= 1.0
        # Factor scores should all be present
        assert set(scored[0].factor_scores.keys()) == {"model_fit", "latency", "cost", "quality", "recency"}

    def test_multiple_groups_sorted_descending(self) -> None:
        """Different models group separately and sort by score descending."""
        now = time.time()
        neighbors = [
            # High-scoring group
            FakeSearchResult(
                0.95,
                FakeMetadata(
                    model_used="llama3",
                    backend_type="ollama",
                    latency_ms=50,
                    cost=0.0,
                    user_feedback=0.9,
                    timestamp=now,
                ),
            ),
            FakeSearchResult(
                0.93,
                FakeMetadata(
                    model_used="llama3",
                    backend_type="ollama",
                    latency_ms=40,
                    cost=0.0,
                    user_feedback=0.85,
                    timestamp=now,
                ),
            ),
            # Lower-scoring group
            FakeSearchResult(
                0.60,
                FakeMetadata(
                    model_used="gpt-4",
                    backend_type="openai",
                    latency_ms=200,
                    cost=0.05,
                    user_feedback=0.5,
                    timestamp=now - 86400,
                ),
            ),
        ]
        router = self._router()
        scored = router._score_neighbors(neighbors, _make_request())
        assert len(scored) == 2
        # First should be the higher-scored route
        assert scored[0].score >= scored[1].score

    def test_latency_scoring_lower_is_better(self) -> None:
        """Route with lower latency should score higher on the latency factor."""
        now = time.time()
        neighbors = [
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="fast-model", backend_type="local", latency_ms=10, cost=0.001, timestamp=now),
            ),
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="slow-model", backend_type="cloud", latency_ms=500, cost=0.001, timestamp=now),
            ),
        ]
        router = self._router()
        scored = router._score_neighbors(neighbors, _make_request())
        fast = next(r for r in scored if r.model == "fast-model")
        slow = next(r for r in scored if r.model == "slow-model")
        assert fast.factor_scores["latency"] > slow.factor_scores["latency"]

    def test_cost_scoring_cheaper_is_better(self) -> None:
        """Route with lower cost should score higher on the cost factor."""
        now = time.time()
        neighbors = [
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="cheap", backend_type="local", latency_ms=100, cost=0.001, timestamp=now),
            ),
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="expensive", backend_type="cloud", latency_ms=100, cost=0.10, timestamp=now),
            ),
        ]
        router = self._router()
        scored = router._score_neighbors(neighbors, _make_request())
        cheap = next(r for r in scored if r.model == "cheap")
        expensive = next(r for r in scored if r.model == "expensive")
        assert cheap.factor_scores["cost"] > expensive.factor_scores["cost"]

    def test_quality_scoring_with_feedback(self) -> None:
        """Quality factor uses user_feedback when available."""
        now = time.time()
        neighbors = [
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="good", backend_type="local", user_feedback=0.95, timestamp=now),
            ),
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="bad", backend_type="local", user_feedback=0.2, timestamp=now),
            ),
        ]
        router = self._router()
        scored = router._score_neighbors(neighbors, _make_request())
        good = next(r for r in scored if r.model == "good")
        bad = next(r for r in scored if r.model == "bad")
        assert good.factor_scores["quality"] > bad.factor_scores["quality"]

    def test_quality_defaults_to_half_without_feedback(self) -> None:
        """Missing feedback defaults quality to 0.5."""
        now = time.time()
        neighbors = [
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="m1", backend_type="ollama", user_feedback=None, timestamp=now),
            ),
        ]
        router = self._router()
        scored = router._score_neighbors(neighbors, _make_request())
        assert scored[0].factor_scores["quality"] == 0.5

    def test_recency_decay(self) -> None:
        """Recent neighbors score higher on recency than old ones."""
        now = time.time()
        neighbors = [
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="recent", backend_type="local", timestamp=now),
            ),
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="old", backend_type="local", timestamp=now - 86400 * 7),  # 7 days ago
            ),
        ]
        router = self._router()
        scored = router._score_neighbors(neighbors, _make_request())
        recent = next(r for r in scored if r.model == "recent")
        old = next(r for r in scored if r.model == "old")
        assert recent.factor_scores["recency"] > old.factor_scores["recency"]

    def test_scores_clamped_0_to_1(self) -> None:
        """All composite scores should be in [0, 1]."""
        now = time.time()
        neighbors = [
            FakeSearchResult(1.0, FakeMetadata(model_used="m1", backend_type="b1", timestamp=now)),
            FakeSearchResult(0.01, FakeMetadata(model_used="m2", backend_type="b2", timestamp=now - 86400 * 30)),
        ]
        router = self._router()
        scored = router._score_neighbors(neighbors, _make_request())
        for route in scored:
            assert 0.0 <= route.score <= 1.0

    def test_zero_latency_and_cost_neighbors(self) -> None:
        """When all neighbors have zero latency/cost, latency/cost scores should be 1.0."""
        now = time.time()
        neighbors = [
            FakeSearchResult(
                0.9,
                FakeMetadata(model_used="m1", backend_type="local", latency_ms=0.0, cost=0.0, timestamp=now),
            ),
        ]
        router = self._router()
        scored = router._score_neighbors(neighbors, _make_request())
        # When latency is 0, the code returns 1.0 (because avg_latency <= 0 triggers the else)
        assert scored[0].factor_scores["latency"] == 1.0
        assert scored[0].factor_scores["cost"] == 1.0


# ============================================================================
# 11. record_outcome
# ============================================================================


def _patch_faiss_import():
    """Context manager that injects a mock ``faiss`` into ``sys.modules``.

    ``record_outcome`` does a lazy ``from llmhosts.embeddings.store import
    EmbeddingMetadata`` which triggers ``llmhosts.embeddings.__init__``
    which does a top-level ``import faiss``.  Since faiss is not installed
    in the test environment we pre-populate ``sys.modules`` so the import
    chain succeeds.
    """
    import sys

    needs_cleanup: list[str] = []
    for mod_name in ("faiss", "llmhosts.embeddings", "llmhosts.embeddings.store"):
        if mod_name not in sys.modules:
            needs_cleanup.append(mod_name)

    # Only inject faiss if it is truly missing
    if "faiss" not in sys.modules:
        sys.modules["faiss"] = MagicMock()

    return needs_cleanup


def _cleanup_faiss_import(keys: list[str]) -> None:
    import sys

    for k in keys:
        sys.modules.pop(k, None)


class TestRecordOutcome:
    @pytest.mark.asyncio
    async def test_delegates_to_store_add(self) -> None:
        import numpy as np

        keys = _patch_faiss_import()
        try:
            with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
                store = _mock_store(size=50)
                router = KNNRouter(_mock_pipeline(), store)
                embedding = np.zeros(384, dtype=np.float32)

                await router.record_outcome(
                    request_id="req-123",
                    embedding=embedding,
                    model_used="llama3",
                    backend="ollama",
                    latency_ms=42.5,
                    cost=0.001,
                    routing_tier="knn",
                    feedback=0.9,
                    was_exploration=False,
                )

                store.add.assert_awaited_once()
                # Verify the metadata passed to store.add has the right fields
                call_args = store.add.await_args
                metadata_arg = call_args[0][1]  # second positional arg
                assert metadata_arg.request_id == "req-123"
                assert metadata_arg.model_used == "llama3"
                assert metadata_arg.backend_type == "ollama"
                assert metadata_arg.latency_ms == 42.5
                assert metadata_arg.cost == 0.001
                assert metadata_arg.user_feedback == 0.9
        finally:
            _cleanup_faiss_import(keys)

    @pytest.mark.asyncio
    async def test_auto_save_at_100(self) -> None:
        """Store.save() is called when store.size is a multiple of 100."""
        import numpy as np

        keys = _patch_faiss_import()
        try:
            with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
                store = _mock_store(size=100)  # size % 100 == 0
                router = KNNRouter(_mock_pipeline(), store)
                embedding = np.zeros(384, dtype=np.float32)

                await router.record_outcome(
                    request_id="req-100",
                    embedding=embedding,
                    model_used="llama3",
                    backend="ollama",
                    latency_ms=50.0,
                    cost=0.0,
                )

                store.save.assert_awaited_once()
        finally:
            _cleanup_faiss_import(keys)

    @pytest.mark.asyncio
    async def test_no_save_at_non_multiple_of_100(self) -> None:
        """Store.save() is NOT called when size is not a multiple of 100."""
        import numpy as np

        keys = _patch_faiss_import()
        try:
            with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
                store = _mock_store(size=57)  # 57 % 100 != 0
                router = KNNRouter(_mock_pipeline(), store)
                embedding = np.zeros(384, dtype=np.float32)

                await router.record_outcome(
                    request_id="req-57",
                    embedding=embedding,
                    model_used="llama3",
                    backend="ollama",
                    latency_ms=50.0,
                    cost=0.0,
                )

                store.save.assert_not_awaited()
        finally:
            _cleanup_faiss_import(keys)


# ============================================================================
# 12. update_feedback
# ============================================================================


class TestUpdateFeedback:
    @pytest.mark.asyncio
    async def test_delegates_to_store(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            store = _mock_store()
            router = KNNRouter(_mock_pipeline(), store)
            result = await router.update_feedback("req-42", 0.85)
            store.update_feedback.assert_awaited_once_with("req-42", 0.85)
            assert result is True

    @pytest.mark.asyncio
    async def test_returns_false_when_not_found(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            store = _mock_store()
            store.update_feedback = AsyncMock(return_value=False)
            router = KNNRouter(_mock_pipeline(), store)
            result = await router.update_feedback("nonexistent", 0.5)
            assert result is False


# ============================================================================
# 13. _format_factors
# ============================================================================


class TestFormatFactors:
    def test_formats_sorted_key_value_pairs(self) -> None:
        factors = {"quality": 0.80, "cost": 0.70, "latency": 0.90, "model_fit": 0.95, "recency": 0.60}
        formatted = KNNRouter._format_factors(factors)
        assert formatted == "cost=0.70, latency=0.90, model_fit=0.95, quality=0.80, recency=0.60"

    def test_empty_dict(self) -> None:
        assert KNNRouter._format_factors({}) == ""

    def test_single_factor(self) -> None:
        assert KNNRouter._format_factors({"model_fit": 0.123}) == "model_fit=0.12"


# ============================================================================
# 14. is_ready
# ============================================================================


class TestIsReady:
    def test_both_ready(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            pipeline = _mock_pipeline()
            store = _mock_store(is_ready=True)
            router = KNNRouter(pipeline, store)
            assert router.is_ready is True

    def test_pipeline_not_ready(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            pipeline = _mock_pipeline()
            type(pipeline).is_ready = PropertyMock(return_value=False)
            store = _mock_store(is_ready=True)
            router = KNNRouter(pipeline, store)
            assert router.is_ready is False

    def test_store_not_ready(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            pipeline = _mock_pipeline()
            store = _mock_store(is_ready=False)
            router = KNNRouter(pipeline, store)
            assert router.is_ready is False

    def test_neither_ready(self) -> None:
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            pipeline = _mock_pipeline()
            type(pipeline).is_ready = PropertyMock(return_value=False)
            store = _mock_store(is_ready=False)
            router = KNNRouter(pipeline, store)
            assert router.is_ready is False


# ============================================================================
# 15. _FACTOR_WEIGHTS sum to 1.0
# ============================================================================


class TestFactorWeights:
    def test_weights_sum_to_one(self) -> None:
        total = sum(_FACTOR_WEIGHTS.values())
        assert abs(total - 1.0) < 1e-9, f"Factor weights sum to {total}, expected 1.0"

    def test_all_expected_factors_present(self) -> None:
        expected = {"model_fit", "latency", "cost", "quality", "recency"}
        assert set(_FACTOR_WEIGHTS.keys()) == expected

    def test_all_weights_positive(self) -> None:
        for name, weight in _FACTOR_WEIGHTS.items():
            assert weight > 0, f"Weight for {name} is not positive: {weight}"


# ============================================================================
# Additional edge-case tests
# ============================================================================


class TestRouteLatencyTracking:
    @pytest.mark.asyncio
    async def test_latency_ms_populated(self) -> None:
        """All result paths should populate latency_ms with a non-negative value."""
        with patch("llmhosts.router.knn.KNN_AVAILABLE", True):
            # Insufficient data path
            router = KNNRouter(_mock_pipeline(), _mock_store(size=0))
            result = await router.route(_make_request())
            assert result.latency_ms >= 0.0


class TestRouteConstants:
    def test_confidence_threshold(self) -> None:
        assert KNNRouter.CONFIDENCE_THRESHOLD == 0.85

    def test_exploration_rate(self) -> None:
        assert KNNRouter.EXPLORATION_RATE == 0.05

    def test_k_neighbors(self) -> None:
        assert KNNRouter.K_NEIGHBORS == 10

    def test_min_store_size(self) -> None:
        assert KNNRouter.MIN_STORE_SIZE == 20
