"""Shared helpers for Prometheus range queries.

These two pieces were called out by the spec's downstream-notes as needing a
home: the step-derivation function (SC-104) and the series normalization helper
(SC-102). Keeping them in their own module makes them unit-testable in isolation
and reusable by the chart and mean widget paths (and, later, the in-service data
path of the service-storage-harness change) without ``sources.py`` growing
unbounded.

``normalize_prometheus_matrix`` is a direct extraction of the metric-label →
readable-label rule that previously lived inside the Grafana datasource-proxy
path, retargeted at the native Prometheus ``/api/v1/query_range`` matrix shape so
users migrating a ``grafana/chart`` widget to ``prometheus/chart`` see identical
labels.
"""

from __future__ import annotations

from typing import Any

#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
WINDOW_PRESETS: dict[str, int] = {
    "1h": 3_600,
    "6h": 21_600,
    "24h": 86_400,
    "7d": 604_800,
}

#: Sentinel values Prometheus serialises for non-finite floats; map these to
#: ``None`` so the frontend renderer can skip them via ``connectNulls``.
_NON_NUMERIC = (None, "NaN", "+Inf", "-Inf")


def step_for_window(window_seconds: int, target_points: int = 200) -> int:
    """Derive a scrape ``step`` for a window that yields ~``target_points`` samples.

    Clamped to a minimum of 15 seconds so Prometheus does not reject
    sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
    the resulting point count to land in the 100–300 band; with
    ``target_points=200`` every preset yields 200 points.
    """
    return max(15, round(window_seconds / target_points))


def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
    ``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
    renderer consumes.

    Label rule (matches the removed Grafana path so labels are stable on
    migration):

    1. Drop ``__name__`` (and any other ``__``-prefixed) metric labels.
    2. If labels remain, join them as ``k=v k=v`` (sorted for determinism).
    3. Else fall back to ``"value"``.
    4. Dedup label collisions with a `` (n)`` suffix.
    """
    series: list[dict[str, Any]] = []
    seen: dict[str, int] = {}
    for entry in result:
        metric = entry.get("metric") or {}
        values = entry.get("values") or []
        parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")]
        label = " ".join(parts) if parts else "value"
        if label in seen:
            seen[label] += 1
            label = f"{label} ({seen[label]})"
        else:
            seen[label] = 0
        points: list[dict[str, Any]] = []
        for ts, raw in values:
            t = _safe_int(ts)
            if t is None:
                # Drop samples whose timestamp is unusable rather than raising.
                continue
            points.append({"t": t, "v": _safe_float(raw)})
        series.append({"label": label, "points": points})
    return series


def _safe_float(raw: Any) -> float | None:
    """Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
    if raw in _NON_NUMERIC:
        return None
    try:
        return float(raw)
    except (TypeError, ValueError):
        return None


def _safe_int(ts: Any) -> int | None:
    """Best-effort int conversion for a Prometheus timestamp."""
    try:
        return int(float(ts))
    except (TypeError, ValueError):
        return None
