"""cortex/observatory/aggregator.py — Config Observatory aggregation layer.

Queries multiple evaOS Cortex nodes via their ``GET /api/v1/admin/fleet/config-status``
endpoints and produces a unified drift report highlighting config differences.

Design:
- Each node is described by a ``NodeConfig`` (url, name, api_key).
- ``fetch_node_config`` calls one node — returns the raw JSON dict or an error dict.
- ``aggregate_configs`` queries all nodes concurrently, flattens the sectioned
  config-status schema into (field_name → value) maps, then compares across nodes.
- A ``ConfigDrift`` is emitted for every field whose runtime value differs across nodes.
- Fields whose values are redacted (``"***"``) are excluded from drift comparison
  because we cannot tell whether they differ.
- The ``AggregatedStatus`` includes both the drift list and per-node snapshots.

The ``observatory_nodes`` list is expected to live in ``CortexConfig`` (added
separately) or be passed in directly by the caller.  This module has no
dependency on the config dataclass — it accepts a plain list of ``NodeConfig``
objects so it can be used in tests and CLI tooling without a running plugin.

Timeout: each node fetch is independently capped at ``fetch_timeout_s`` (default 10s).
Errors: a node that fails to respond is included in the report with ``status="error"``.
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional


# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------

@dataclass
class NodeConfig:
    """Connection info for one evaOS Cortex node.

    Attributes:
        url:     Base URL of the node, e.g. ``https://cortex.fly.dev`` (no trailing slash).
        name:    Human-readable node identifier, e.g. ``"cortex-prod"``.
        api_key: API key used for the ``Authorization`` header.

    Raises:
        ValueError: If ``url`` or ``api_key`` is empty/missing.
    """
    url: str
    name: str
    api_key: str

    def __post_init__(self):
        if not self.url or not self.url.strip():
            raise ValueError(
                f"NodeConfig '{self.name}': url is required but was empty or missing"
            )
        if not self.api_key or not self.api_key.strip():
            raise ValueError(
                f"NodeConfig '{self.name}': api_key is required but was empty or missing"
            )
        if not self.name or not self.name.strip():
            raise ValueError(
                "NodeConfig: name is required but was empty or missing"
            )


@dataclass
class ConfigDrift:
    """A single config field that differs across nodes.

    Attributes:
        field:          The field name, e.g. ``"embedding_model"``.
        section:        Logical section the field belongs to, e.g. ``"embeddings"``.
        values_by_node: Map of node name → runtime value.  Nodes that did not
                        return this field have a value of ``None``.
        drift_type:     ``"value"`` for normal value differences, ``"version_mismatch"``
                        when nodes run different schema versions (comparison unreliable).
    """
    field: str
    section: str
    values_by_node: Dict[str, Any]
    drift_type: str = "value"


@dataclass
class NodeResult:
    """Result of fetching one node's config-status.

    Attributes:
        name:         Node name (from ``NodeConfig``).
        url:          Node base URL.
        status:       ``"ok"`` | ``"error"`` | ``"timeout"``.
        error:        Error message when status != "ok".
        version:      Cortex version string from the node response.
        timestamp:    ISO-8601 timestamp from the node response.
        schema_version: Config schema version from the node response.
        flat_config:  Flat ``{field_name: value}`` dict built from the sectioned response.
        raw:          Original response dict (for debugging; may be None on error).
    """
    name: str
    url: str
    status: str  # "ok" | "error" | "timeout"
    error: Optional[str] = None
    version: Optional[str] = None
    timestamp: Optional[str] = None
    schema_version: Optional[str] = None
    flat_config: Dict[str, Any] = field(default_factory=dict)
    raw: Optional[Dict[str, Any]] = None


@dataclass
class AggregatedStatus:
    """Unified config status across all queried nodes.

    Attributes:
        generated_at:    ISO-8601 timestamp when this report was generated.
        nodes:           Per-node results (one per ``NodeConfig`` queried).
        drifts:          Fields that differ across nodes (excluding redacted fields).
        total_nodes:     Total nodes queried.
        healthy_nodes:   Nodes that responded successfully.
        drifted_fields:  Count of fields with detected drift.
    """
    generated_at: str
    nodes: List[NodeResult]
    drifts: List[ConfigDrift]
    total_nodes: int
    healthy_nodes: int
    drifted_fields: int


# ---------------------------------------------------------------------------
# Fetch helpers
# ---------------------------------------------------------------------------

_REDACTED = "***"
_CONFIG_STATUS_PATH = "/api/v1/admin/fleet/config-status"


def _flatten_sections(config_sections: Dict[str, Any]) -> Dict[str, tuple[str, Any]]:
    """Flatten sectioned config-status ``config`` payload to ``{field_name: (section, value)}``.

    The config-status response has the shape::

        {
            "embeddings": [
                {"name": "embedding_model", "value": "voyage-4-large", ...},
                ...
            ],
            ...
        }

    Returns a dict mapping field name → (section_name, value).
    """
    result: Dict[str, tuple[str, Any]] = {}
    for section_name, fields in config_sections.items():
        if not isinstance(fields, list):
            continue
        for entry in fields:
            if not isinstance(entry, dict):
                continue
            name = entry.get("name")
            value = entry.get("value")
            if name is not None:
                result[name] = (section_name, value)
    return result


async def fetch_node_config(
    url: str,
    api_key: str,
    timeout_s: float = 10.0,
) -> Dict[str, Any]:
    """Fetch a single node's config-status payload.

    Makes an HTTP GET to ``{url}/api/v1/admin/fleet/config-status`` with the
    provided API key and returns the parsed JSON dict.

    Args:
        url:       Node base URL (no trailing slash).
        api_key:   API key for the ``Authorization: Bearer <key>`` header.
        timeout_s: Per-request timeout in seconds.

    Returns:
        Parsed JSON response dict on success.

    Raises:
        Exception: On network error, timeout, or non-2xx HTTP status.
    """
    try:
        import httpx
    except ImportError as exc:
        raise ImportError(
            "httpx is required for Config Observatory — install with: pip install httpx"
        ) from exc

    endpoint = f"{url.rstrip('/')}{_CONFIG_STATUS_PATH}"
    headers = {"Authorization": f"Bearer {api_key}"}

    async with httpx.AsyncClient(timeout=timeout_s) as client:
        response = await client.get(endpoint, headers=headers)
        response.raise_for_status()
        return response.json()


async def _fetch_node_result(node: NodeConfig, timeout_s: float) -> NodeResult:
    """Fetch one node and return a ``NodeResult`` (never raises)."""
    try:
        raw = await asyncio.wait_for(
            fetch_node_config(node.url, node.api_key, timeout_s=timeout_s),
            timeout=timeout_s + 1.0,  # outer guard slightly larger than inner
        )
    except asyncio.TimeoutError:
        return NodeResult(
            name=node.name,
            url=node.url,
            status="timeout",
            error=f"Request timed out after {timeout_s}s",
        )
    except Exception as exc:  # noqa: BLE001
        return NodeResult(
            name=node.name,
            url=node.url,
            status="error",
            error=str(exc),
        )

    # Extract metadata
    config_sections = raw.get("config", {})
    flat_fields = _flatten_sections(config_sections)
    flat_config = {name: value for name, (_, value) in flat_fields.items()}

    return NodeResult(
        name=node.name,
        url=node.url,
        status="ok",
        version=raw.get("version"),
        timestamp=raw.get("timestamp"),
        schema_version=raw.get("schema_version"),
        flat_config=flat_config,
        raw=raw,
    )


# ---------------------------------------------------------------------------
# Aggregation
# ---------------------------------------------------------------------------

def _compute_drift(nodes: List[NodeResult]) -> List[ConfigDrift]:
    """Compare configs across healthy nodes; return fields that differ.

    Redacted values (``"***"``) are excluded from comparison because we cannot
    determine whether they are actually equal.  A field present in some nodes
    but absent in others IS flagged as drift (the missing nodes contribute
    ``None`` to ``values_by_node``).

    When nodes report different ``schema_version`` values, field-level comparison
    is unreliable (fields may have been added/removed/renamed between versions).
    In that case a single ``"version_mismatch"`` drift entry is emitted instead
    of per-field diffs.
    """
    healthy = [n for n in nodes if n.status == "ok"]
    if len(healthy) < 2:
        # Need at least two nodes to detect drift
        return []

    # Schema version check: if nodes disagree on schema_version, emit a
    # single version_mismatch drift instead of comparing individual fields.
    schema_versions = {n.name: (n.schema_version or "unknown") for n in healthy}
    unique_versions = set(schema_versions.values())
    if len(unique_versions) > 1:
        return [ConfigDrift(
            field="schema_version",
            section="_meta",
            values_by_node=schema_versions,
            drift_type="version_mismatch",
        )]

    # Collect all field names across all nodes
    all_fields: Dict[str, str] = {}  # field_name → section
    for result in healthy:
        raw_config = result.raw.get("config", {}) if result.raw else {}
        for section_name, fields in raw_config.items():
            if not isinstance(fields, list):
                continue
            for entry in fields:
                if isinstance(entry, dict) and "name" in entry:
                    all_fields.setdefault(entry["name"], section_name)

    drifts: List[ConfigDrift] = []

    for field_name, section in all_fields.items():
        values_by_node: Dict[str, Any] = {}
        has_non_redacted = False

        for result in healthy:
            value = result.flat_config.get(field_name)
            values_by_node[result.name] = value
            if value != _REDACTED:
                has_non_redacted = True

        if not has_non_redacted:
            # All values are redacted — cannot compare
            continue

        # Filter to non-redacted values for uniqueness check
        comparable_values = {
            node: val
            for node, val in values_by_node.items()
            if val != _REDACTED
        }
        unique_values = set()
        for v in comparable_values.values():
            # Lists/dicts are not hashable; convert to string for comparison
            try:
                unique_values.add(v)
            except TypeError:
                unique_values.add(str(v))

        if len(unique_values) > 1:
            drifts.append(ConfigDrift(
                field=field_name,
                section=section,
                values_by_node=values_by_node,
            ))

    # Sort drift by section then field for deterministic output
    drifts.sort(key=lambda d: (d.section, d.field))
    return drifts


async def aggregate_configs(
    nodes: List[NodeConfig],
    fetch_timeout_s: float = 10.0,
) -> AggregatedStatus:
    """Query all nodes concurrently and compute a unified drift report.

    Args:
        nodes:           List of nodes to query.
        fetch_timeout_s: Per-node HTTP request timeout in seconds.

    Returns:
        ``AggregatedStatus`` with per-node results and drift list.
    """
    if not nodes:
        return AggregatedStatus(
            generated_at=datetime.now(timezone.utc).isoformat(),
            nodes=[],
            drifts=[],
            total_nodes=0,
            healthy_nodes=0,
            drifted_fields=0,
        )

    # Fetch all nodes concurrently
    results: List[NodeResult] = await asyncio.gather(
        *[_fetch_node_result(node, fetch_timeout_s) for node in nodes]
    )

    healthy_nodes = sum(1 for r in results if r.status == "ok")
    drifts = _compute_drift(results)

    return AggregatedStatus(
        generated_at=datetime.now(timezone.utc).isoformat(),
        nodes=results,
        drifts=drifts,
        total_nodes=len(nodes),
        healthy_nodes=healthy_nodes,
        drifted_fields=len(drifts),
    )
