"""tests/test_observatory.py — Tests for GET /admin/observatory endpoint.

Verifies:
- 200 response with all expected keys
- Auth required (401 without key)
- Config values present in response
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock

import pytest

pytest.importorskip("fastapi", reason="fastapi not installed")
pytest.importorskip("httpx", reason="httpx not installed")

from fastapi.testclient import TestClient  # noqa: E402


# ---------------------------------------------------------------------------
# Minimal fake dataclasses mirroring real plugin contracts
# ---------------------------------------------------------------------------

@dataclass
class _FakeHealthReport:
    ok: bool = True
    storage: str = "ok"
    modules: Dict[str, str] = field(default_factory=lambda: {"storage": "ok"})
    errors: List[str] = field(default_factory=list)
    timestamp: str = "2025-01-01T00:00:00+00:00"


@dataclass
class _FakeCortexConfig:
    # Embeddings
    embedding_model: str = "voyage-code-3"
    embedding_dim: int = 1024
    provider_embedding_provider: str = "voyage"

    # Extraction
    extraction_model: str = "gemini-2.5-flash"
    extraction_enabled: bool = True
    extraction_prompt_version: str = "v1"

    # Reconciliation
    reconciliation_model: str = "gemini-2.5-flash"

    # Dialectic
    dialectic_model: Optional[str] = None

    # Rerank
    retrieval_rerank_enabled: bool = True
    retrieval_rerank_model: str = "rerank-2.5-lite"
    retrieval_rerank_top_k: int = 5

    # LLM
    provider_mode: str = "host"
    llm_provider_priority: List[str] = field(
        default_factory=lambda: ["google", "anthropic"]
    )
    provider_llm_model: str = "gemini-2.5-flash"

    # Storage
    owner_id: str = "default"


def _make_mock_plugin(*, storage_ok: bool = True) -> MagicMock:
    """Build a minimal MagicMock that looks like CortexPlugin."""
    plugin = MagicMock()
    plugin._init_errors = []
    plugin.health = AsyncMock(return_value=_FakeHealthReport(
        storage="ok" if storage_ok else "unavailable"
    ))
    plugin.config = _FakeCortexConfig()

    if storage_ok:
        storage = MagicMock()
        storage.ping = AsyncMock(return_value=None)
        storage.count_claims = AsyncMock(return_value=42)
        storage.count_sessions = AsyncMock(return_value=7)
        storage.get_cornerstones = AsyncMock(return_value=["cs1", "cs2", "cs3"])
        plugin.storage = storage
    else:
        plugin.storage = None

    return plugin


def _build_client(plugin: MagicMock, *, auth: bool = True) -> TestClient:
    """Build a TestClient for the admin router with optional auth."""
    from fastapi import FastAPI, HTTPException
    from cortex.api.routes.admin import router, _make_routes

    app = FastAPI()

    def _noop_verify(x: Any = None):
        return None

    async def _deny_verify(x: Any = None):
        raise HTTPException(status_code=401, detail="Unauthorized")

    verify_fn = _noop_verify if auth else _deny_verify

    _make_routes(verify_api_key=verify_fn)
    app.include_router(router)

    from cortex.api import deps as _deps
    _deps.plugin_holder["plugin"] = plugin

    return TestClient(app, raise_server_exceptions=False)


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

class TestObservatoryEndpoint:
    """Core tests for GET /admin/observatory."""

    @pytest.fixture
    def client(self):
        plugin = _make_mock_plugin(storage_ok=True)
        return _build_client(plugin)

    def test_returns_200(self, client):
        """Observatory endpoint must return HTTP 200."""
        resp = client.get("/api/v1/admin/observatory")
        assert resp.status_code == 200

    def test_top_level_keys_present(self, client):
        """Response must include node, version, timestamp, and config."""
        resp = client.get("/api/v1/admin/observatory")
        data = resp.json()
        for key in ("node", "version", "timestamp", "config"):
            assert key in data, f"Missing top-level key: {key!r}"

    def test_config_sections_present(self, client):
        """Config block must include all expected sections."""
        resp = client.get("/api/v1/admin/observatory")
        config = resp.json()["config"]
        for section in ("embeddings", "extraction", "reconciliation", "dialectic",
                        "rerank", "llm", "storage"):
            assert section in config, f"Missing config section: {section!r}"

    def test_embeddings_keys(self, client):
        """Embeddings section must have model, dim, provider."""
        resp = client.get("/api/v1/admin/observatory")
        emb = resp.json()["config"]["embeddings"]
        assert "model" in emb
        assert "dim" in emb
        assert "provider" in emb

    def test_extraction_keys(self, client):
        """Extraction section must have model, enabled, prompt_version."""
        resp = client.get("/api/v1/admin/observatory")
        ext = resp.json()["config"]["extraction"]
        assert "model" in ext
        assert "enabled" in ext
        assert "prompt_version" in ext

    def test_rerank_keys(self, client):
        """Rerank section must have enabled, model, top_k."""
        resp = client.get("/api/v1/admin/observatory")
        rerank = resp.json()["config"]["rerank"]
        assert "enabled" in rerank
        assert "model" in rerank
        assert "top_k" in rerank

    def test_llm_keys(self, client):
        """LLM section must have provider_mode, provider_priority, completion_model."""
        resp = client.get("/api/v1/admin/observatory")
        llm = resp.json()["config"]["llm"]
        assert "provider_mode" in llm
        assert "provider_priority" in llm
        assert "completion_model" in llm

    def test_storage_keys(self, client):
        """Storage section must have claims_count, sessions_count, cornerstones_count."""
        resp = client.get("/api/v1/admin/observatory")
        storage = resp.json()["config"]["storage"]
        assert "claims_count" in storage
        assert "sessions_count" in storage
        assert "cornerstones_count" in storage

    def test_storage_counts_populated(self, client):
        """Storage counts must reflect values from the storage adapter."""
        resp = client.get("/api/v1/admin/observatory")
        storage = resp.json()["config"]["storage"]
        assert storage["claims_count"] == 42
        assert storage["sessions_count"] == 7
        assert storage["cornerstones_count"] == 3

    def test_version_matches_package(self, client):
        """Version field must match cortex.__version__."""
        from cortex import __version__
        resp = client.get("/api/v1/admin/observatory")
        data = resp.json()
        assert data["version"] == __version__

    def test_dialectic_fallback(self, client):
        """When dialectic_model is None, falls back to extraction_model."""
        resp = client.get("/api/v1/admin/observatory")
        data = resp.json()
        dialectic = data["config"]["dialectic"]["model"]
        extraction = data["config"]["extraction"]["model"]
        # Our fake config has dialectic_model=None, so should fall back
        assert dialectic == extraction

    def test_timestamp_is_iso(self, client):
        """Timestamp must be a valid ISO 8601 string."""
        from datetime import datetime
        resp = client.get("/api/v1/admin/observatory")
        ts = resp.json()["timestamp"]
        assert isinstance(ts, str)
        # Should parse without error
        datetime.fromisoformat(ts.replace("Z", "+00:00"))


class TestObservatoryAuth:
    """Auth guard tests for GET /admin/observatory."""

    def test_returns_401_without_key(self):
        """Request without API key must return 401.

        Build a standalone app with a deny-all verify dependency to confirm
        the observatory route respects the injected auth dependency.
        """
        from fastapi import FastAPI, APIRouter, HTTPException

        # Fresh router — avoids polluting the shared module-level router
        fresh_router = APIRouter()

        async def _deny_verify(x: Any = None):
            raise HTTPException(status_code=401, detail="Unauthorized")

        from cortex.api.routes.admin.observatory import _register as _observatory

        _observatory(fresh_router, _deny_verify)

        app = FastAPI()
        app.include_router(fresh_router)

        plugin = _make_mock_plugin()
        from cortex.api import deps as _deps
        _deps.plugin_holder["plugin"] = plugin

        client = TestClient(app, raise_server_exceptions=False)
        resp = client.get("/api/v1/admin/observatory")
        assert resp.status_code == 401


class TestObservatoryStorageUnavailable:
    """Observatory must degrade gracefully when storage is down."""

    @pytest.fixture
    def client(self):
        plugin = _make_mock_plugin(storage_ok=False)
        return _build_client(plugin)

    def test_returns_200_when_storage_down(self, client):
        """Should still return 200 even if storage is unavailable."""
        resp = client.get("/api/v1/admin/observatory")
        assert resp.status_code == 200

    def test_storage_counts_zero_when_unavailable(self, client):
        """Storage counts must default to 0 when storage is unavailable."""
        resp = client.get("/api/v1/admin/observatory")
        storage = resp.json()["config"]["storage"]
        assert storage["claims_count"] == 0
        assert storage["sessions_count"] == 0
        assert storage["cornerstones_count"] == 0
