"""cortex/api/routes/admin/config_drift.py — Config Observatory: fleet config drift report.

Implements GET /api/v1/admin/fleet/config-drift, which queries all registered
evaOS nodes' config-status endpoints concurrently and returns a unified drift
report highlighting fields that differ across nodes.

Node list is sourced from ``CortexConfig.observatory_nodes`` — a list of
JSON-encoded dicts with keys: url, name, api_key.  Nodes that are unreachable
are included in the report with ``status="error"`` rather than causing a 500.

Sensitive fields (value == "***") are excluded from drift comparison.

This route is part of the Config Observatory feature (#1074).
The per-node config schema comes from GET /admin/fleet/config-status (#1075).
"""

from __future__ import annotations

import json
import logging
from typing import Any, List

from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse

from cortex.api.deps import PREFIX, get_plugin
from cortex.observatory.aggregator import (
    AggregatedStatus,
    ConfigDrift,
    NodeConfig,
    NodeResult,
    aggregate_configs,
)

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Serialisation helpers
# ---------------------------------------------------------------------------

def _node_result_to_dict(result: NodeResult) -> dict:
    """Convert a NodeResult to a JSON-serialisable dict."""
    d: dict = {
        "name": result.name,
        "url": result.url,
        "status": result.status,
    }
    if result.error is not None:
        d["error"] = result.error
    if result.version is not None:
        d["version"] = result.version
    if result.timestamp is not None:
        d["timestamp"] = result.timestamp
    if result.schema_version is not None:
        d["schema_version"] = result.schema_version
    # flat_config is large — omit from the drift route to keep response focused.
    # Callers that need full configs should call /admin/fleet/config-status on each node.
    return d


def _drift_to_dict(drift: ConfigDrift) -> dict:
    """Convert a ConfigDrift to a JSON-serialisable dict."""
    return {
        "field": drift.field,
        "section": drift.section,
        "values_by_node": drift.values_by_node,
    }


def _status_to_response(status: AggregatedStatus) -> dict:
    """Build the full JSON response body from an AggregatedStatus."""
    return {
        "generated_at": status.generated_at,
        "summary": {
            "total_nodes": status.total_nodes,
            "healthy_nodes": status.healthy_nodes,
            "drifted_fields": status.drifted_fields,
            "has_drift": status.drifted_fields > 0,
        },
        "nodes": [_node_result_to_dict(r) for r in status.nodes],
        "drifts": [_drift_to_dict(d) for d in status.drifts],
        "_meta": {
            "description": (
                "drifts[] lists each config field whose value differs across healthy nodes. "
                "Sensitive fields (value='***') are excluded from drift comparison. "
                "Nodes with status='error' or status='timeout' are excluded from drift detection "
                "but shown in the nodes[] list for visibility."
            ),
            "drift_field_schema": {
                "field": "string — config field name",
                "section": "string — logical config section",
                "values_by_node": "dict[node_name, value] — current value per node",
            },
        },
    }


def _parse_observatory_nodes(raw_entries: List[str]) -> tuple[List[NodeConfig], List[str]]:
    """Parse ``observatory_nodes`` config entries into NodeConfig objects.

    Each entry is expected to be a JSON string with keys: url, name, api_key.

    Returns:
        (valid_nodes, parse_errors) — a list of valid NodeConfig objects and
        a list of error strings for malformed entries.
    """
    nodes: List[NodeConfig] = []
    errors: List[str] = []

    for i, entry in enumerate(raw_entries):
        try:
            obj = json.loads(entry) if isinstance(entry, str) else entry
        except json.JSONDecodeError as exc:
            errors.append(f"observatory_nodes[{i}]: invalid JSON — {exc}")
            continue

        if not isinstance(obj, dict):
            errors.append(f"observatory_nodes[{i}]: expected JSON object, got {type(obj).__name__}")
            continue

        missing = [k for k in ("url", "name", "api_key") if not obj.get(k)]
        if missing:
            errors.append(
                f"observatory_nodes[{i}]: missing required keys: {', '.join(missing)}"
            )
            continue

        nodes.append(NodeConfig(
            url=obj["url"],
            name=obj["name"],
            api_key=obj["api_key"],
        ))

    return nodes, errors


# ---------------------------------------------------------------------------
# Route registration
# ---------------------------------------------------------------------------

def _register(router: APIRouter, verify_api_key) -> None:
    """Register config-drift routes on the given router."""

    @router.get(
        f"{PREFIX}/admin/fleet/config-drift",
        summary="Config Observatory — fleet config drift report",
        tags=["admin"],
        operation_id="get_fleet_config_drift",
    )
    async def get_fleet_config_drift(
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> JSONResponse:
        """Query all registered evaOS nodes and return a unified config drift report.

        Reads ``observatory_nodes`` from the running ``CortexConfig`` to determine
        which nodes to query.  Each node is expected to expose
        ``GET /api/v1/admin/fleet/config-status`` (implemented in #1075).

        Nodes are fetched concurrently with a per-node timeout of
        ``observatory_fetch_timeout_s`` seconds (default 10s).  Unreachable or
        slow nodes are included in the ``nodes[]`` list with ``status="error"``
        or ``status="timeout"`` and are excluded from drift detection.

        **Drift detection:** A field is flagged as drifted when its runtime value
        differs across two or more healthy nodes.  Sensitive fields (``"***"``)
        are excluded from comparison.

        **Empty node list:** If no nodes are configured, returns a report with
        zero drifts and a warning in the response body.  This is not an error —
        it just means drift detection is not yet configured.

        **Authentication:** Requires the same API key as all other admin routes.
        """
        cfg = plugin.config
        raw_entries: List[str] = getattr(cfg, "observatory_nodes", []) or []
        fetch_timeout: float = getattr(cfg, "observatory_fetch_timeout_s", 10.0)

        nodes, parse_errors = _parse_observatory_nodes(raw_entries)

        if parse_errors:
            logger.warning(
                "Config Observatory: %d node config(s) failed to parse: %s",
                len(parse_errors),
                parse_errors,
            )

        status = await aggregate_configs(nodes, fetch_timeout_s=fetch_timeout)
        body = _status_to_response(status)

        # Surface parse errors in the response for operator visibility
        if parse_errors:
            body["warnings"] = parse_errors

        http_status = 200
        return JSONResponse(content=body, status_code=http_status)
