"""
tests/test_agents/test_routing.py
==================================
Unit tests for agents/routing_agent.py using stdlib unittest only.

Run standalone:
    python -m unittest tests/test_agents/test_routing.py -v
Run via runner:
    python tests/run_tests.py

Coverage:
- RoutingAgent._classify_rules()   — pure pattern matching, no I/O
- RoutingAgent.classify()          — empty query, media shortcut, high-conf rules
- LLM fallback path               — mocked LLM returning SQL / VLM / unclear / error
- classify_intent() public node    — state keys written, trace populated
"""

from __future__ import annotations

import sys
import logging
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

# ── bootstrap ────────────────────────────────────────────────────────────────
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))  # → tests/
import shared  # noqa: E402  (sets sys.path to vss/ and configures logging)

logger = logging.getLogger("vss.tests.routing")


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

def _state(query: str = "", media: dict | None = None):
    from orchestration.state import create_state
    s = create_state(query)
    if media:
        s["uploaded_media"] = media
    return s


# ─────────────────────────────────────────────────────────────────────────────
# 1. Rule-based classification — synchronous, no I/O
# ─────────────────────────────────────────────────────────────────────────────

class TestClassifyRules(unittest.TestCase):
    """Tests for RoutingAgent._classify_rules() — pure regex matching."""

    def setUp(self):
        from agents.routing_agent import RoutingAgent
        self.agent = RoutingAgent()

    def test_count_query_returns_sql(self):
        intent, conf, reason = self.agent._classify_rules("How many alerts yesterday?")
        self.assertEqual(intent, "sql")
        self.assertGreater(conf, 0.0)
        logger.debug("count_query → intent=%s conf=%.2f", intent, conf)

    def test_total_query_returns_sql(self):
        intent, conf, _ = self.agent._classify_rules("What is the total count of vehicles last 7 days?")
        self.assertEqual(intent, "sql")

    def test_trend_query_returns_sql(self):
        intent, conf, _ = self.agent._classify_rules("Show me the trend for people since last week")
        self.assertEqual(intent, "sql")

    def test_statistics_query_returns_sql(self):
        intent, conf, _ = self.agent._classify_rules("Give me statistics for Fire Safety")
        self.assertEqual(intent, "sql")

    def test_peak_count_query_returns_sql(self):
        intent, conf, _ = self.agent._classify_rules("What is the peak count per hour?")
        self.assertEqual(intent, "sql")

    def test_more_patterns_gives_higher_confidence(self):
        _, conf1, _ = self.agent._classify_rules("How many alerts?")
        _, conf2, _ = self.agent._classify_rules(
            "How many total alerts, what is the count and trend per day since yesterday?"
        )
        self.assertGreaterEqual(conf2, conf1,
            "More SQL keywords should yield equal-or-higher confidence")

    def test_empty_query_still_returns_sql(self):
        # Current implementation always returns "sql" as default
        intent, conf, reason = self.agent._classify_rules("")
        self.assertEqual(intent, "sql")
        logger.debug("empty query → intent=%s conf=%.2f", intent, conf)

    def test_confidence_never_exceeds_0_9(self):
        noisy = "how many count total sum average max min peak trend statistics yesterday"
        _, conf, _ = self.agent._classify_rules(noisy)
        self.assertLessEqual(conf, 0.9)

    def test_confidence_is_float(self):
        _, conf, _ = self.agent._classify_rules("How many fires last week?")
        self.assertIsInstance(conf, float)

    def test_reason_is_non_empty_string(self):
        _, _, reason = self.agent._classify_rules("How many alerts?")
        self.assertIsInstance(reason, str)
        self.assertGreater(len(reason), 0)


# ─────────────────────────────────────────────────────────────────────────────
# 2. classify() — async paths
# ─────────────────────────────────────────────────────────────────────────────

class TestClassifyAsync(unittest.IsolatedAsyncioTestCase):
    """Tests for RoutingAgent.classify() — async entry point."""

    async def test_empty_query_sets_unknown_intent(self):
        from agents.routing_agent import RoutingAgent
        agent = RoutingAgent()
        result = await agent.classify(_state(""))

        self.assertEqual(result["intent"], "unknown")
        self.assertEqual(result["intent_confidence"], 0.0)
        self.assertEqual(result["routing_reason"], "Empty query")
        logger.info("empty query classify → intent=%s", result["intent"])

    async def test_media_upload_routes_to_vlm(self):
        from agents.routing_agent import RoutingAgent
        agent = RoutingAgent()
        result = await agent.classify(
            _state("Describe what you see", media={"type": "image", "data": "abc"})
        )

        self.assertEqual(result["intent"], "vlm")
        self.assertEqual(result["intent_confidence"], 1.0)
        self.assertEqual(result["routing_reason"], "Media uploaded")
        logger.info("media upload → intent=%s conf=%.2f", result["intent"], result["intent_confidence"])

    async def test_media_upload_adds_trace(self):
        from agents.routing_agent import RoutingAgent
        agent = RoutingAgent()
        result = await agent.classify(
            _state("Describe scene", media={"type": "image", "data": "x"})
        )
        trace_text = " ".join(result["trace"]).lower()
        self.assertIn("vlm", trace_text)

    async def test_high_confidence_sql_skips_llm(self):
        """When rules fire at >= 0.8, classify() must NOT call the LLM."""
        from agents.routing_agent import RoutingAgent
        mock_llm = shared.make_mock_llm("SQL")
        agent = RoutingAgent()

        with patch("agents.routing_agent.get_llm", return_value=mock_llm):
            result = await agent.classify(_state("How many alerts yesterday?"))

        mock_llm.generate.assert_not_called()
        self.assertEqual(result["intent"], "sql")
        logger.info("high-conf rule → no LLM, intent=%s", result["intent"])

    async def test_rule_based_result_written_to_state(self):
        from agents.routing_agent import RoutingAgent
        agent = RoutingAgent()
        result = await agent.classify(_state("What is the total count per day?"))

        self.assertIn(result["intent"], ("sql", "vlm", "unknown"))
        self.assertIsInstance(result["intent_confidence"], float)
        self.assertIsInstance(result["routing_reason"], str)
        self.assertGreater(len(result["routing_reason"]), 0)


# ─────────────────────────────────────────────────────────────────────────────
# 3. LLM fallback — triggered when rule confidence < 0.8
# ─────────────────────────────────────────────────────────────────────────────

class TestClassifyLLMFallback(unittest.IsolatedAsyncioTestCase):
    """Forces the LLM path by patching _classify_rules to return conf < 0.8."""

    def _low_conf_rules(self, agent, intent: str = "sql", conf: float = 0.5):
        agent._classify_rules = MagicMock(return_value=(intent, conf, "mocked rule"))

    async def test_llm_returns_sql(self):
        from agents.routing_agent import RoutingAgent
        mock_llm = shared.make_mock_llm("SQL")
        agent = RoutingAgent()
        self._low_conf_rules(agent)

        with patch("agents.routing_agent.get_llm", return_value=mock_llm):
            result = await agent.classify(_state("ambiguous query"))

        self.assertEqual(result["intent"], "sql")
        self.assertEqual(result["intent_confidence"], 0.8)
        logger.info("LLM fallback SQL → intent=%s conf=%.2f", result["intent"], result["intent_confidence"])

    async def test_llm_returns_vlm(self):
        from agents.routing_agent import RoutingAgent
        mock_llm = shared.make_mock_llm("VLM")
        agent = RoutingAgent()
        self._low_conf_rules(agent)

        with patch("agents.routing_agent.get_llm", return_value=mock_llm):
            result = await agent.classify(_state("describe the scene"))

        self.assertEqual(result["intent"], "vlm")
        self.assertEqual(result["intent_confidence"], 0.8)

    async def test_llm_unclear_defaults_to_sql(self):
        from agents.routing_agent import RoutingAgent
        mock_llm = shared.make_mock_llm("GARBAGE_RESPONSE")
        agent = RoutingAgent()
        self._low_conf_rules(agent)

        with patch("agents.routing_agent.get_llm", return_value=mock_llm):
            result = await agent.classify(_state("ambiguous query"))

        self.assertEqual(result["intent"], "sql")
        self.assertEqual(result["intent_confidence"], 0.6)
        logger.info("LLM unclear → default sql conf=%.2f", result["intent_confidence"])

    async def test_llm_exception_defaults_to_sql(self):
        from agents.routing_agent import RoutingAgent
        mock_llm = shared.make_mock_llm()
        mock_llm.generate = AsyncMock(side_effect=RuntimeError("LLM down"))
        agent = RoutingAgent()
        self._low_conf_rules(agent)

        with patch("agents.routing_agent.get_llm", return_value=mock_llm):
            result = await agent.classify(_state("ambiguous query"))

        self.assertEqual(result["intent"], "sql")
        self.assertLessEqual(result["intent_confidence"], 0.6)
        logger.warning("LLM exception handled → fallback sql")


# ─────────────────────────────────────────────────────────────────────────────
# 4. classify_intent() public LangGraph node function
# ─────────────────────────────────────────────────────────────────────────────

class TestClassifyIntentNode(unittest.IsolatedAsyncioTestCase):
    """Tests for the module-level classify_intent() convenience function."""

    async def test_returns_all_required_keys(self):
        from agents.routing_agent import classify_intent
        result = await classify_intent(_state("How many people counted today?"))

        for key in ("intent", "intent_confidence", "routing_reason"):
            self.assertIn(key, result, f"Missing key: {key}")
        logger.info("classify_intent keys OK → intent=%s", result["intent"])

    async def test_trace_is_extended(self):
        from agents.routing_agent import classify_intent
        state = _state("How many vehicles yesterday?")
        initial_len = len(state["trace"])
        result = await classify_intent(state)

        self.assertGreater(len(result["trace"]), initial_len,
            "Trace should grow after classification")

    async def test_existing_trace_preserved(self):
        from agents.routing_agent import classify_intent
        state = _state("How many alerts?")
        state["trace"].append("[00:00:00] pre-existing entry")
        result = await classify_intent(state)

        combined = " ".join(result["trace"])
        self.assertIn("pre-existing entry", combined)

    async def test_routing_trace_entry_present(self):
        from agents.routing_agent import classify_intent
        result = await classify_intent(_state("How many vehicles yesterday?"))

        routing_traces = [t for t in result["trace"]
                          if "Routing" in t or "routing" in t.lower()]
        self.assertGreaterEqual(len(routing_traces), 1,
            "Expected at least one routing trace entry")


if __name__ == "__main__":
    unittest.main(verbosity=2)
