"""
tests/test_e2e/test_api.py
============================
API endpoint tests using FastAPI's TestClient.

No uvicorn or running server is needed — the ASGI app is exercised
in-process via starlette.testclient.TestClient (synchronous wrapper).
External services (SQL agent, LLM, ClickHouse) are mocked at the boundary.
VLM is NOT tested here.

Run standalone:
    python -m unittest tests/test_e2e/test_api.py -v
Run via runner:
    python tests/run_tests.py

Coverage:
- POST /api/v1/query   — SQL path, trace flag, bad request, error propagation
- GET  /api/v1/health  — healthy / degraded / unhealthy combinations
- Routing integration  — confirm correct intent in response metadata
"""

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

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


# ─────────────────────────────────────────────────────────────────────────────
# Helper — build mock orchestrator whose run() returns a pre-built state
# ─────────────────────────────────────────────────────────────────────────────

def _mock_orchestrator(state: dict) -> MagicMock:
    """Return a mock VSSOrchestrator whose run() coroutine returns `state`."""
    orch = MagicMock()
    orch.run = AsyncMock(return_value=state)
    return orch


# ─────────────────────────────────────────────────────────────────────────────
# Base test case — creates TestClient once per class, patches setup_logging
# so lifespan startup doesn't interfere with test logging.
# ─────────────────────────────────────────────────────────────────────────────

class _APIBase(unittest.TestCase):
    """
    Base class that creates a TestClient wrapping the real FastAPI app.
    setup_logging() is patched to a no-op so the app lifespan doesn't
    add duplicate log handlers on top of those in shared.py.
    """

    _patcher = None
    client = None

    @classmethod
    def setUpClass(cls):
        # Patch setup_logging before importing main so lifespan is a no-op
        cls._patcher = patch("config.logging_config.setup_logging")
        cls._patcher.start()

        from starlette.testclient import TestClient
        from main import app
        cls.client = TestClient(app, raise_server_exceptions=True)

    @classmethod
    def tearDownClass(cls):
        if cls._patcher:
            cls._patcher.stop()


# ─────────────────────────────────────────────────────────────────────────────
# 1. POST /api/v1/query — SQL path
# ─────────────────────────────────────────────────────────────────────────────

class TestQueryEndpointSQL(_APIBase):
    """POST /api/v1/query — SQL agent responses via mocked orchestrator."""

    def test_successful_sql_query_returns_200(self):
        state = shared.make_completed_state("How many fire alerts yesterday?")

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query",
                json={"query": "How many fire alerts yesterday?"},
            )

        self.assertEqual(resp.status_code, 200)
        logger.info("POST /query → 200 ✓")

    def test_response_contains_answer(self):
        state = shared.make_completed_state(
            final_response="There were 42 fire alerts yesterday."
        )

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query",
                json={"query": "How many fire alerts yesterday?"},
            )

        data = resp.json()
        self.assertIn("42", data["response"])
        logger.info("response contains answer: %r", data["response"][:60])

    def test_status_field_is_success(self):
        state = shared.make_completed_state()

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query", json={"query": "How many alerts?"}
            )

        self.assertEqual(resp.json()["status"], "success")

    def test_metadata_contains_intent(self):
        state = shared.make_completed_state()

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query", json={"query": "How many alerts?"}
            )

        meta = resp.json()["metadata"]
        self.assertIn("intent", meta)
        self.assertEqual(meta["intent"], "sql")

    def test_metadata_contains_sql_query(self):
        state = shared.make_completed_state()

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query", json={"query": "How many alerts?"}
            )

        meta = resp.json()["metadata"]
        self.assertIn("sql_query", meta)
        self.assertIsNotNone(meta["sql_query"])

    def test_processing_time_ms_present(self):
        state = shared.make_completed_state(processing_time_ms=250)

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query", json={"query": "How many alerts?"}
            )

        self.assertEqual(resp.json()["processing_time_ms"], 250)

    def test_request_id_present(self):
        state = shared.make_completed_state()

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query", json={"query": "Count alerts"}
            )

        self.assertIn("request_id", resp.json())
        self.assertNotEqual(resp.json()["request_id"], "")


# ─────────────────────────────────────────────────────────────────────────────
# 2. include_trace flag
# ─────────────────────────────────────────────────────────────────────────────

class TestQueryEndpointTrace(_APIBase):
    """Verify include_trace controls whether trace appears in response."""

    def test_trace_absent_when_include_trace_false(self):
        state = shared.make_completed_state()

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query",
                json={"query": "How many alerts?", "include_trace": False},
            )

        self.assertEqual(resp.json()["trace"], [])
        logger.info("include_trace=False → trace=[] ✓")

    def test_trace_present_when_include_trace_true(self):
        state = shared.make_completed_state()

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query",
                json={"query": "How many alerts?", "include_trace": True},
            )

        trace = resp.json()["trace"]
        self.assertIsInstance(trace, list)
        self.assertGreater(len(trace), 0)
        logger.info("include_trace=True → %d trace entries ✓", len(trace))

    def test_trace_entries_are_strings(self):
        state = shared.make_completed_state()

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query",
                json={"query": "How many alerts?", "include_trace": True},
            )

        for entry in resp.json()["trace"]:
            self.assertIsInstance(entry, str)


# ─────────────────────────────────────────────────────────────────────────────
# 3. Validation & error handling
# ─────────────────────────────────────────────────────────────────────────────

class TestQueryEndpointValidation(_APIBase):
    """Request validation and error propagation."""

    def test_empty_query_returns_422(self):
        resp = self.client.post("/api/v1/query", json={"query": ""})
        self.assertEqual(resp.status_code, 422)
        logger.info("empty query → 422 ✓")

    def test_missing_query_field_returns_422(self):
        resp = self.client.post("/api/v1/query", json={})
        self.assertEqual(resp.status_code, 422)

    def test_orchestrator_exception_returns_500(self):
        orch = MagicMock()
        orch.run = AsyncMock(side_effect=RuntimeError("DB exploded"))

        with patch("api.routes.get_orchestrator", return_value=orch):
            resp = self.client.post(
                "/api/v1/query", json={"query": "How many alerts?"}
            )

        self.assertEqual(resp.status_code, 500)
        logger.warning("orchestrator exception → 500 ✓")

    def test_failed_state_returns_error_status(self):
        state = shared.make_completed_state()
        state["status"] = "failed"
        state["final_response"] = "Error processing request: DB timeout"

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query", json={"query": "How many alerts?"}
            )

        self.assertEqual(resp.status_code, 200)       # HTTP is fine
        self.assertEqual(resp.json()["status"], "error")  # domain status is error


# ─────────────────────────────────────────────────────────────────────────────
# 4. Routing integration — confirm intent correctly reflected in metadata
# ─────────────────────────────────────────────────────────────────────────────

class TestRoutingIntegration(_APIBase):
    """
    Exercises real RoutingAgent.classify() through the full API stack.
    Only the SQL-agent step is mocked (no DB calls).
    """

    def _make_sql_state_from_real_routing(self, query: str) -> dict:
        """
        Build a state that mirrors what happens after real routing + mocked SQL.
        We run the routing agent for real and inject a mock SQL result.
        """
        import asyncio
        from orchestration.state import create_state, add_trace
        from agents.routing_agent import classify_intent

        state = create_state(query)
        # asyncio.run() creates a fresh event loop; safe in Python 3.10 where
        # get_event_loop() no longer auto-creates one in a non-async context.
        state = asyncio.run(classify_intent(state))

        # Inject SQL result
        state["status"] = "completed"
        state["response_source"] = "sql"
        state["sql_query"] = "SELECT count() FROM default.tbl"
        state["sql_result"] = [{"count": 42}]
        state["sql_result_formatted"] = "There were 42 alerts."
        state["final_response"] = "There were 42 alerts."
        state["processing_time_ms"] = 80
        add_trace(state, "mock sql complete")
        return state

    def test_sql_query_classified_as_sql(self):
        state = self._make_sql_state_from_real_routing("How many fire alerts yesterday?")
        shared.log_state_trace(state, "test_sql_query_classified_as_sql")

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query",
                json={"query": "How many fire alerts yesterday?"},
            )

        meta = resp.json()["metadata"]
        self.assertEqual(meta["intent"], "sql")
        self.assertGreater(meta["intent_confidence"], 0.0)
        logger.info(
            "routing integration: intent=%s conf=%.2f reason=%s",
            meta["intent"], meta["intent_confidence"],
            meta.get("routing_reasoning", ""),
        )

    def test_count_query_routes_to_sql(self):
        state = self._make_sql_state_from_real_routing("What is the total vehicle count today?")
        shared.log_state_trace(state, "test_count_query_routes_to_sql")

        with patch("api.routes.get_orchestrator", return_value=_mock_orchestrator(state)):
            resp = self.client.post(
                "/api/v1/query",
                json={"query": "What is the total vehicle count today?"},
            )

        self.assertEqual(resp.json()["metadata"]["intent"], "sql")


# ─────────────────────────────────────────────────────────────────────────────
# 5. GET /api/v1/health
# ─────────────────────────────────────────────────────────────────────────────

class TestHealthEndpoint(_APIBase):
    """GET /api/v1/health — various component health combinations."""

    def _mock_health_deps(
        self,
        ch_ok: bool = True,
        llm_ok: bool = True,
        vlm_ok: bool = True,
    ):
        """Return patch context managers for all three health dependencies."""
        mock_ch = MagicMock()
        mock_ch.test_connection.return_value = ch_ok

        mock_llm = shared.make_mock_llm()
        mock_llm.health_check = AsyncMock(return_value=llm_ok)

        mock_vlm = MagicMock()
        mock_vlm.health_check = AsyncMock(return_value=vlm_ok)

        return (
            patch("api.routes.get_clickhouse", return_value=mock_ch),
            patch("api.routes.get_llm", return_value=mock_llm),
            patch("api.routes.get_vlm", return_value=mock_vlm),
        )

    def test_all_healthy_returns_healthy(self):
        p_ch, p_llm, p_vlm = self._mock_health_deps(True, True, True)
        with p_ch, p_llm, p_vlm:
            resp = self.client.get("/api/v1/health")

        self.assertEqual(resp.status_code, 200)
        self.assertEqual(resp.json()["status"], "healthy")
        logger.info("health: all OK → healthy ✓")

    def test_one_down_returns_degraded(self):
        p_ch, p_llm, p_vlm = self._mock_health_deps(True, True, False)
        with p_ch, p_llm, p_vlm:
            resp = self.client.get("/api/v1/health")

        self.assertEqual(resp.json()["status"], "degraded")
        logger.info("health: VLM down → degraded ✓")

    def test_all_down_returns_unhealthy(self):
        p_ch, p_llm, p_vlm = self._mock_health_deps(False, False, False)
        with p_ch, p_llm, p_vlm:
            resp = self.client.get("/api/v1/health")

        self.assertEqual(resp.json()["status"], "unhealthy")
        logger.info("health: all down → unhealthy ✓")

    def test_components_key_present(self):
        p_ch, p_llm, p_vlm = self._mock_health_deps()
        with p_ch, p_llm, p_vlm:
            resp = self.client.get("/api/v1/health")

        data = resp.json()
        self.assertIn("components", data)
        self.assertIn("clickhouse", data["components"])
        self.assertIn("llm", data["components"])
        self.assertIn("vlm", data["components"])

    def test_timestamp_present(self):
        p_ch, p_llm, p_vlm = self._mock_health_deps()
        with p_ch, p_llm, p_vlm:
            resp = self.client.get("/api/v1/health")

        self.assertIn("timestamp", resp.json())

    def test_clickhouse_error_reflected_in_components(self):
        p_ch, p_llm, p_vlm = self._mock_health_deps(ch_ok=False)
        with p_ch, p_llm, p_vlm:
            resp = self.client.get("/api/v1/health")

        comp = resp.json()["components"]["clickhouse"]
        self.assertEqual(comp["status"], "error")
        logger.info("health: ClickHouse down → component.status=error ✓")

    def test_llm_error_reflected_in_components(self):
        p_ch, p_llm, p_vlm = self._mock_health_deps(llm_ok=False)
        with p_ch, p_llm, p_vlm:
            resp = self.client.get("/api/v1/health")

        comp = resp.json()["components"]["llm"]
        self.assertEqual(comp["status"], "error")

    def test_exception_in_health_check_returns_error_status(self):
        mock_ch = MagicMock()
        mock_ch.test_connection.side_effect = Exception("DB unreachable")
        mock_llm = shared.make_mock_llm()
        mock_vlm = MagicMock()
        mock_vlm.health_check = AsyncMock(return_value=True)

        with patch("api.routes.get_clickhouse", return_value=mock_ch), \
             patch("api.routes.get_llm", return_value=mock_llm), \
             patch("api.routes.get_vlm", return_value=mock_vlm):
            resp = self.client.get("/api/v1/health")

        self.assertEqual(resp.status_code, 200)
        comp = resp.json()["components"]["clickhouse"]
        self.assertEqual(comp["status"], "error")
        logger.warning("health: ClickHouse exception → error status ✓")


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

