"""tests/test_observatory_aggregator.py — Tests for Config Observatory aggregation layer.

Tests:
- fetch_node_config: HTTP call, auth header, error handling
- aggregate_configs: concurrency, drift detection, empty/single-node edge cases
- _flatten_sections: flattening sectioned config payload
- _compute_drift: drift detection logic, redacted field exclusion, missing fields
- GET /api/v1/admin/fleet/config-drift: route integration test
"""
from __future__ import annotations

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

import pytest

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


from cortex.observatory.aggregator import (
    AggregatedStatus,
    ConfigDrift,
    NodeConfig,
    NodeResult,
    _compute_drift,
    _flatten_sections,
    aggregate_configs,
    fetch_node_config,
)


# ---------------------------------------------------------------------------
# Sample data helpers
# ---------------------------------------------------------------------------

def _make_config_status_response(
    node_name: str = "cortex-prod",
    embedding_model: str = "voyage-4-large",
    extraction_model: str = "gemini-2.5-flash",
    rerank_enabled: bool = True,
    version: str = "1.5.0",
    api_key_value: str = "***",
) -> dict:
    """Build a realistic config-status response dict."""
    return {
        "schema_version": "1.0",
        "node": node_name,
        "version": version,
        "timestamp": "2026-03-24T00:00:00+00:00",
        "runtime": {
            "python_version": "3.12.0",
            "python_impl": "CPython",
            "platform": "Linux",
            "machine": "x86_64",
        },
        "config": {
            "embeddings": [
                {"name": "embedding_model", "value": embedding_model, "default": "voyage-4-large", "type": "str", "env_var": "CORTEX_EMBEDDING_MODEL", "sensitive": False},
                {"name": "embedding_dim", "value": 1024, "default": 1024, "type": "int", "env_var": "CORTEX_EMBEDDING_DIM", "sensitive": False},
                {"name": "provider_embedding_api_key", "value": api_key_value, "default": "", "type": "str", "env_var": "VOYAGE_API_KEY", "sensitive": True},
            ],
            "extraction": [
                {"name": "extraction_model", "value": extraction_model, "default": "gemini-2.5-flash", "type": "str", "env_var": "CORTEX_EXTRACTION_MODEL", "sensitive": False},
                {"name": "extraction_enabled", "value": True, "default": True, "type": "bool", "env_var": "CORTEX_EXTRACTION_ENABLED", "sensitive": False},
            ],
            "rerank": [
                {"name": "retrieval_rerank_enabled", "value": rerank_enabled, "default": True, "type": "bool", "env_var": "CORTEX_RETRIEVAL_RERANK_ENABLED", "sensitive": False},
            ],
        },
        "_meta": {},
    }


def _make_node_config(name: str = "cortex-prod", url: str = "https://cortex.fly.dev") -> NodeConfig:
    return NodeConfig(url=url, name=name, api_key="sk-test-key")


def _make_ok_node_result(
    name: str = "cortex-prod",
    embedding_model: str = "voyage-4-large",
    extraction_model: str = "gemini-2.5-flash",
) -> NodeResult:
    raw = _make_config_status_response(name, embedding_model=embedding_model, extraction_model=extraction_model)
    flat = {
        "embedding_model": embedding_model,
        "embedding_dim": 1024,
        "provider_embedding_api_key": "***",
        "extraction_model": extraction_model,
        "extraction_enabled": True,
        "retrieval_rerank_enabled": True,
    }
    return NodeResult(
        name=name,
        url=f"https://{name}.example.com",
        status="ok",
        version="1.5.0",
        timestamp="2026-03-24T00:00:00+00:00",
        schema_version="1.0",
        flat_config=flat,
        raw=raw,
    )


# ---------------------------------------------------------------------------
# _flatten_sections
# ---------------------------------------------------------------------------

class TestFlattenSections:
    def test_flattens_multiple_sections(self):
        config_sections = {
            "embeddings": [
                {"name": "embedding_model", "value": "voyage-4-large"},
                {"name": "embedding_dim", "value": 1024},
            ],
            "extraction": [
                {"name": "extraction_model", "value": "gemini-2.5-flash"},
            ],
        }
        result = _flatten_sections(config_sections)
        assert result["embedding_model"] == ("embeddings", "voyage-4-large")
        assert result["embedding_dim"] == ("embeddings", 1024)
        assert result["extraction_model"] == ("extraction", "gemini-2.5-flash")

    def test_empty_sections_returns_empty(self):
        assert _flatten_sections({}) == {}

    def test_skips_non_list_values(self):
        config_sections = {
            "meta": "not a list",
            "embeddings": [{"name": "embedding_model", "value": "v4"}],
        }
        result = _flatten_sections(config_sections)
        assert "embedding_model" in result
        # meta key is skipped (not a list)
        assert len(result) == 1

    def test_skips_entries_without_name(self):
        config_sections = {
            "embeddings": [
                {"value": "voyage-4-large"},  # no "name" key
                {"name": "embedding_dim", "value": 1024},
            ],
        }
        result = _flatten_sections(config_sections)
        assert "embedding_dim" in result
        assert len(result) == 1


# ---------------------------------------------------------------------------
# _compute_drift
# ---------------------------------------------------------------------------

class TestComputeDrift:
    def test_no_drift_when_values_match(self):
        nodes = [
            _make_ok_node_result("node-a", embedding_model="voyage-4-large"),
            _make_ok_node_result("node-b", embedding_model="voyage-4-large"),
        ]
        drifts = _compute_drift(nodes)
        # No fields differ
        drift_fields = {d.field for d in drifts}
        assert "embedding_model" not in drift_fields

    def test_detects_embedding_model_drift(self):
        nodes = [
            _make_ok_node_result("node-a", embedding_model="voyage-4-large"),
            _make_ok_node_result("node-b", embedding_model="gemini-embedding-001"),
        ]
        drifts = _compute_drift(nodes)
        drift_fields = {d.field: d for d in drifts}
        assert "embedding_model" in drift_fields
        drift = drift_fields["embedding_model"]
        assert drift.values_by_node["node-a"] == "voyage-4-large"
        assert drift.values_by_node["node-b"] == "gemini-embedding-001"
        assert drift.section == "embeddings"

    def test_detects_extraction_model_drift(self):
        nodes = [
            _make_ok_node_result("node-a", extraction_model="gemini-2.5-flash"),
            _make_ok_node_result("node-b", extraction_model="claude-sonnet-4"),
        ]
        drifts = _compute_drift(nodes)
        drift_fields = {d.field for d in drifts}
        assert "extraction_model" in drift_fields

    def test_excludes_redacted_fields_from_drift(self):
        """provider_embedding_api_key is '***' on all nodes — must NOT be flagged."""
        nodes = [
            _make_ok_node_result("node-a"),
            _make_ok_node_result("node-b"),
        ]
        drifts = _compute_drift(nodes)
        drift_fields = {d.field for d in drifts}
        assert "provider_embedding_api_key" not in drift_fields

    def test_returns_empty_with_single_node(self):
        nodes = [_make_ok_node_result("node-a")]
        assert _compute_drift(nodes) == []

    def test_returns_empty_with_no_nodes(self):
        assert _compute_drift([]) == []

    def test_excludes_error_nodes_from_drift(self):
        ok = _make_ok_node_result("node-a", embedding_model="voyage-4-large")
        err = NodeResult(name="node-b", url="https://node-b.example.com", status="error", error="timeout")
        drifts = _compute_drift([ok, err])
        # Only one healthy node — no drift possible
        assert drifts == []

    def test_drift_with_missing_field_on_one_node(self):
        """A field present on node-a but absent on node-b should be flagged."""
        node_a = _make_ok_node_result("node-a")
        node_b_raw = _make_config_status_response("node-b")
        # Remove embedding_model from node-b's config
        node_b_raw["config"]["embeddings"] = [
            e for e in node_b_raw["config"]["embeddings"]
            if e["name"] != "embedding_model"
        ]
        node_b = NodeResult(
            name="node-b",
            url="https://node-b.example.com",
            status="ok",
            schema_version="1.0",  # match node-a to avoid version_mismatch
            flat_config={k: v for k, v in node_a.flat_config.items() if k != "embedding_model"},
            raw=node_b_raw,
        )
        drifts = _compute_drift([node_a, node_b])
        drift_fields = {d.field: d for d in drifts}
        assert "embedding_model" in drift_fields
        drift = drift_fields["embedding_model"]
        # node-b should show None for the missing field
        assert drift.values_by_node["node-b"] is None

    def test_drift_sorted_by_section_then_field(self):
        nodes = [
            _make_ok_node_result("node-a", embedding_model="voyage-4-large", extraction_model="gemini-2.5-flash"),
            _make_ok_node_result("node-b", embedding_model="gemini-embedding-001", extraction_model="claude-sonnet-4"),
        ]
        drifts = _compute_drift(nodes)
        # All should be sorted (section, field)
        keys = [(d.section, d.field) for d in drifts]
        assert keys == sorted(keys)


# ---------------------------------------------------------------------------
# fetch_node_config
# ---------------------------------------------------------------------------

class TestNodeConfigValidation:
    """B-CO5: NodeConfig must validate required fields at init."""

    def test_missing_url_raises(self):
        with pytest.raises(ValueError, match="url is required"):
            NodeConfig(url="", name="test", api_key="sk-test")

    def test_missing_api_key_raises(self):
        with pytest.raises(ValueError, match="api_key is required"):
            NodeConfig(url="https://example.com", name="test", api_key="")

    def test_missing_name_raises(self):
        with pytest.raises(ValueError, match="name is required"):
            NodeConfig(url="https://example.com", name="", api_key="sk-test")

    def test_whitespace_only_url_raises(self):
        with pytest.raises(ValueError, match="url is required"):
            NodeConfig(url="   ", name="test", api_key="sk-test")

    def test_whitespace_only_api_key_raises(self):
        with pytest.raises(ValueError, match="api_key is required"):
            NodeConfig(url="https://example.com", name="test", api_key="  ")

    def test_valid_config_succeeds(self):
        node = NodeConfig(url="https://example.com", name="test", api_key="sk-test")
        assert node.url == "https://example.com"
        assert node.name == "test"
        assert node.api_key == "sk-test"


class TestSchemaVersionDrift:
    """B-CO4: Nodes with different schema versions should emit version_mismatch drift."""

    def test_different_schema_versions_emit_version_mismatch(self):
        node_a = _make_ok_node_result("node-a")
        node_a.schema_version = "1.0"
        node_b = _make_ok_node_result("node-b", embedding_model="different-model")
        node_b.schema_version = "2.0"

        drifts = _compute_drift([node_a, node_b])
        assert len(drifts) == 1
        assert drifts[0].field == "schema_version"
        assert drifts[0].drift_type == "version_mismatch"
        assert drifts[0].values_by_node == {"node-a": "1.0", "node-b": "2.0"}

    def test_same_schema_versions_compare_normally(self):
        node_a = _make_ok_node_result("node-a", embedding_model="voyage-4-large")
        node_a.schema_version = "1.0"
        node_b = _make_ok_node_result("node-b", embedding_model="gemini-embedding-001")
        node_b.schema_version = "1.0"

        drifts = _compute_drift([node_a, node_b])
        drift_fields = {d.field for d in drifts}
        assert "embedding_model" in drift_fields
        # No version_mismatch drift
        assert all(d.drift_type == "value" for d in drifts)

    def test_none_schema_version_treated_as_unknown(self):
        node_a = _make_ok_node_result("node-a")
        node_a.schema_version = None
        node_b = _make_ok_node_result("node-b")
        node_b.schema_version = "1.0"

        drifts = _compute_drift([node_a, node_b])
        assert len(drifts) == 1
        assert drifts[0].drift_type == "version_mismatch"
        assert drifts[0].values_by_node["node-a"] == "unknown"

    def test_both_none_schema_versions_compare_normally(self):
        """Both None → both 'unknown' → same → normal comparison."""
        node_a = _make_ok_node_result("node-a", embedding_model="voyage-4-large")
        node_a.schema_version = None
        node_b = _make_ok_node_result("node-b", embedding_model="gemini-embedding-001")
        node_b.schema_version = None

        drifts = _compute_drift([node_a, node_b])
        drift_fields = {d.field for d in drifts}
        assert "embedding_model" in drift_fields


class TestFetchNodeConfig:
    @pytest.mark.asyncio
    async def test_makes_get_request_to_correct_url(self):
        expected_response = _make_config_status_response()

        mock_response = MagicMock()
        mock_response.json.return_value = expected_response
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.get = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch("httpx.AsyncClient", return_value=mock_client):
            result = await fetch_node_config(
                url="https://cortex.fly.dev",
                api_key="sk-test",
            )

        mock_client.get.assert_called_once_with(
            "https://cortex.fly.dev/api/v1/admin/fleet/config-status",
            headers={"Authorization": "Bearer sk-test"},
        )
        assert result == expected_response

    @pytest.mark.asyncio
    async def test_strips_trailing_slash_from_url(self):
        mock_response = MagicMock()
        mock_response.json.return_value = {}
        mock_response.raise_for_status = MagicMock()

        mock_client = AsyncMock()
        mock_client.get = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch("httpx.AsyncClient", return_value=mock_client):
            await fetch_node_config(url="https://cortex.fly.dev/", api_key="sk-test")

        call_url = mock_client.get.call_args[0][0]
        assert call_url == "https://cortex.fly.dev/api/v1/admin/fleet/config-status"

    @pytest.mark.asyncio
    async def test_raises_on_http_error(self):
        import httpx

        mock_response = MagicMock()
        mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
            "404", request=MagicMock(), response=MagicMock()
        )
        mock_client = AsyncMock()
        mock_client.get = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)

        with patch("httpx.AsyncClient", return_value=mock_client):
            with pytest.raises(Exception):
                await fetch_node_config(url="https://cortex.fly.dev", api_key="sk-test")


# ---------------------------------------------------------------------------
# aggregate_configs
# ---------------------------------------------------------------------------

class TestAggregateConfigs:
    def _mock_fetch(self, node_responses: dict):
        """Return an async mock for _fetch_node_result using the given {name: NodeResult} map."""
        async def _fake_fetch(node: NodeConfig, timeout_s: float) -> NodeResult:
            if node.name in node_responses:
                return node_responses[node.name]
            return NodeResult(
                name=node.name, url=node.url, status="error", error="not configured"
            )
        return _fake_fetch

    @pytest.mark.asyncio
    async def test_empty_nodes_returns_empty_status(self):
        status = await aggregate_configs([])
        assert status.total_nodes == 0
        assert status.healthy_nodes == 0
        assert status.drifted_fields == 0
        assert status.drifts == []
        assert status.nodes == []

    @pytest.mark.asyncio
    async def test_single_node_no_drift(self):
        node = _make_node_config("cortex-prod")
        result = _make_ok_node_result("cortex-prod")

        with patch(
            "cortex.observatory.aggregator._fetch_node_result",
            side_effect=self._mock_fetch({"cortex-prod": result}),
        ):
            status = await aggregate_configs([node])

        assert status.total_nodes == 1
        assert status.healthy_nodes == 1
        assert status.drifted_fields == 0

    @pytest.mark.asyncio
    async def test_two_nodes_with_drift(self):
        nodes = [
            _make_node_config("node-a", "https://node-a.example.com"),
            _make_node_config("node-b", "https://node-b.example.com"),
        ]
        node_results = {
            "node-a": _make_ok_node_result("node-a", embedding_model="voyage-4-large"),
            "node-b": _make_ok_node_result("node-b", embedding_model="gemini-embedding-001"),
        }

        with patch(
            "cortex.observatory.aggregator._fetch_node_result",
            side_effect=self._mock_fetch(node_results),
        ):
            status = await aggregate_configs(nodes)

        assert status.total_nodes == 2
        assert status.healthy_nodes == 2
        assert status.drifted_fields > 0
        drift_fields = {d.field for d in status.drifts}
        assert "embedding_model" in drift_fields

    @pytest.mark.asyncio
    async def test_error_node_included_in_report(self):
        nodes = [
            _make_node_config("node-a", "https://node-a.example.com"),
            _make_node_config("node-b", "https://node-b.example.com"),
        ]
        node_results = {
            "node-a": _make_ok_node_result("node-a"),
            "node-b": NodeResult(
                name="node-b",
                url="https://node-b.example.com",
                status="error",
                error="Connection refused",
            ),
        }

        with patch(
            "cortex.observatory.aggregator._fetch_node_result",
            side_effect=self._mock_fetch(node_results),
        ):
            status = await aggregate_configs(nodes)

        assert status.total_nodes == 2
        assert status.healthy_nodes == 1
        node_statuses = {r.name: r.status for r in status.nodes}
        assert node_statuses["node-b"] == "error"

    @pytest.mark.asyncio
    async def test_two_identical_nodes_no_drift(self):
        nodes = [
            _make_node_config("node-a"),
            _make_node_config("node-b"),
        ]
        node_results = {
            "node-a": _make_ok_node_result("node-a"),
            "node-b": _make_ok_node_result("node-b"),
        }

        with patch(
            "cortex.observatory.aggregator._fetch_node_result",
            side_effect=self._mock_fetch(node_results),
        ):
            status = await aggregate_configs(nodes)

        assert status.drifted_fields == 0

    @pytest.mark.asyncio
    async def test_generated_at_is_iso_timestamp(self):
        status = await aggregate_configs([])
        from datetime import datetime, timezone
        # Should not raise
        parsed = datetime.fromisoformat(status.generated_at)
        assert parsed.tzinfo is not None


# ---------------------------------------------------------------------------
# Route integration: GET /api/v1/admin/fleet/config-drift
# ---------------------------------------------------------------------------

@dataclass
class _FakeObsConfig:
    """Minimal fake CortexConfig for drift route tests."""
    observatory_nodes: List[str] = field(default_factory=list)
    observatory_fetch_timeout_s: float = 10.0


def _build_drift_client(observatory_nodes: List[str], *, auth: bool = True):
    """Build a TestClient wired to the config-drift route."""
    from fastapi import FastAPI, HTTPException, APIRouter
    from fastapi.testclient import TestClient
    from cortex.api.routes.admin.config_drift import _register as _drift_register
    from cortex.api import deps as _deps

    fresh_router = APIRouter()

    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

    plugin = MagicMock()
    plugin.config = _FakeObsConfig(observatory_nodes=observatory_nodes)

    _drift_register(fresh_router, verify_fn)

    app = FastAPI()
    app.include_router(fresh_router)
    _deps.plugin_holder["plugin"] = plugin

    return TestClient(app, raise_server_exceptions=False)


DRIFT_ENDPOINT = "/api/v1/admin/fleet/config-drift"


class TestConfigDriftEndpoint:
    def test_returns_200_with_no_nodes(self):
        client = _build_drift_client([])
        resp = client.get(DRIFT_ENDPOINT)
        assert resp.status_code == 200

    def test_empty_nodes_response_shape(self):
        client = _build_drift_client([])
        body = client.get(DRIFT_ENDPOINT).json()
        assert "generated_at" in body
        assert "summary" in body
        assert "nodes" in body
        assert "drifts" in body
        assert body["summary"]["total_nodes"] == 0
        assert body["summary"]["drifted_fields"] == 0
        assert body["summary"]["has_drift"] is False

    def test_requires_auth(self):
        client = _build_drift_client([], auth=False)
        resp = client.get(DRIFT_ENDPOINT)
        assert resp.status_code == 401

    def test_with_two_nodes_drift_detected(self):
        nodes_config = [
            json.dumps({"url": "https://node-a.example.com", "name": "node-a", "api_key": "sk-a"}),
            json.dumps({"url": "https://node-b.example.com", "name": "node-b", "api_key": "sk-b"}),
        ]
        client = _build_drift_client(nodes_config)

        node_results = {
            "node-a": _make_ok_node_result("node-a", embedding_model="voyage-4-large"),
            "node-b": _make_ok_node_result("node-b", embedding_model="gemini-embedding-001"),
        }

        async def _fake_aggregate(nodes, fetch_timeout_s=10.0):
            results = [node_results[n.name] for n in nodes if n.name in node_results]
            from cortex.observatory.aggregator import _compute_drift, AggregatedStatus
            from datetime import datetime, timezone
            drifts = _compute_drift(results)
            return AggregatedStatus(
                generated_at=datetime.now(timezone.utc).isoformat(),
                nodes=results,
                drifts=drifts,
                total_nodes=len(nodes),
                healthy_nodes=len(results),
                drifted_fields=len(drifts),
            )

        with patch("cortex.api.routes.admin.config_drift.aggregate_configs", side_effect=_fake_aggregate):
            body = client.get(DRIFT_ENDPOINT).json()

        assert body["summary"]["total_nodes"] == 2
        assert body["summary"]["healthy_nodes"] == 2
        assert body["summary"]["has_drift"] is True
        drift_fields = {d["field"] for d in body["drifts"]}
        assert "embedding_model" in drift_fields

    def test_malformed_node_config_returns_warning(self):
        nodes_config = [
            "not valid json",
            json.dumps({"url": "https://node-a.example.com", "name": "node-a", "api_key": "sk-a"}),
        ]
        client = _build_drift_client(nodes_config)

        async def _fake_aggregate(nodes, fetch_timeout_s=10.0):
            from datetime import datetime, timezone
            from cortex.observatory.aggregator import AggregatedStatus
            return AggregatedStatus(
                generated_at=datetime.now(timezone.utc).isoformat(),
                nodes=[],
                drifts=[],
                total_nodes=len(nodes),
                healthy_nodes=0,
                drifted_fields=0,
            )

        with patch("cortex.api.routes.admin.config_drift.aggregate_configs", side_effect=_fake_aggregate):
            body = client.get(DRIFT_ENDPOINT).json()

        assert "warnings" in body
        assert len(body["warnings"]) == 1

    def test_missing_keys_in_node_config_returns_warning(self):
        nodes_config = [
            json.dumps({"url": "https://node-a.example.com"}),  # missing name + api_key
        ]
        client = _build_drift_client(nodes_config)

        async def _fake_aggregate(nodes, fetch_timeout_s=10.0):
            from datetime import datetime, timezone
            from cortex.observatory.aggregator import AggregatedStatus
            return AggregatedStatus(
                generated_at=datetime.now(timezone.utc).isoformat(),
                nodes=[],
                drifts=[],
                total_nodes=0,
                healthy_nodes=0,
                drifted_fields=0,
            )

        with patch("cortex.api.routes.admin.config_drift.aggregate_configs", side_effect=_fake_aggregate):
            body = client.get(DRIFT_ENDPOINT).json()

        assert "warnings" in body

    def test_drift_response_has_meta(self):
        client = _build_drift_client([])
        body = client.get(DRIFT_ENDPOINT).json()
        assert "_meta" in body

    def test_node_result_excludes_flat_config(self):
        """flat_config should NOT be in the drift response (too large)."""
        nodes_config = [
            json.dumps({"url": "https://node-a.example.com", "name": "node-a", "api_key": "sk-a"}),
        ]
        client = _build_drift_client(nodes_config)

        async def _fake_aggregate(nodes, fetch_timeout_s=10.0):
            from datetime import datetime, timezone
            from cortex.observatory.aggregator import AggregatedStatus
            results = [_make_ok_node_result("node-a")]
            return AggregatedStatus(
                generated_at=datetime.now(timezone.utc).isoformat(),
                nodes=results,
                drifts=[],
                total_nodes=1,
                healthy_nodes=1,
                drifted_fields=0,
            )

        with patch("cortex.api.routes.admin.config_drift.aggregate_configs", side_effect=_fake_aggregate):
            body = client.get(DRIFT_ENDPOINT).json()

        # nodes in response should NOT have flat_config or raw
        for node in body["nodes"]:
            assert "flat_config" not in node
            assert "raw" not in node
