"""Evidence-grade finding normalization.

Goal: make every finding carry a consistent, audit-friendly explanation payload without
forcing every detector to be rewritten at once.

Contract (added/normalized on every finding):
- reason_codes: list[str]
- evidence: dict

This module intentionally stays lightweight and dependency-free.
"""

from __future__ import annotations

import json
from typing import Any, Dict, Iterable, List, Optional


def _split_semicolon(value: Optional[str]) -> List[str]:
    if not value:
        return []
    return [part.strip() for part in str(value).split(";") if part.strip()]


def _parse_cw_error_codes(metrics_error: Optional[str]) -> List[str]:
    """Parse cw_avg error strings like 'cw_error:AWS/EC2:CPUUtilization:throttle'."""
    codes: List[str] = []
    for err in _split_semicolon(metrics_error):
        # allow single errors too
        if not err.startswith("cw_error:"):
            continue
        parts = err.split(":")
        if len(parts) < 4:
            continue
        cls = parts[-1].strip().lower()
        if cls == "throttle":
            codes.append("cw_throttled")
        elif cls == "not_authorized":
            codes.append("cw_not_authorized")
        elif cls == "no_data":
            codes.append("cw_no_data")
        elif cls:
            codes.append("cw_error_other")
    return codes


def _has_measured_metrics(finding: Dict[str, Any]) -> bool:
    # Common keys used by CW-backed detectors.
    for key in ("cpu_avg", "net_in_avg", "net_out_avg"):
        if finding.get(key) is not None:
            return True
    # Any *_avg numeric-ish value counts as measured.
    for key, value in finding.items():
        if key.endswith("_avg") and value is not None:
            return True
    return False


def _minimal_inventory_only(evidence: Dict[str, Any]) -> bool:
    if set(evidence.keys()) != {"inventory"}:
        return False
    inv = evidence.get("inventory")
    if not isinstance(inv, dict) or not inv:
        return True
    allowed = {"resource_type", "resource_id", "region"}
    return set(inv.keys()) <= allowed


def infer_reason_codes(finding: Dict[str, Any]) -> List[str]:
    # Start with detector-provided reason codes (if any), then add inferred ones.
    existing = finding.get("reason_codes")
    reason_codes: List[str] = list(existing) if isinstance(existing, list) else []

    metric_status = finding.get("metric_status")
    metrics_error = finding.get("metrics_error")

    if metric_status == "skipped_budget":
        reason_codes.append("cw_budget_exhausted")

    reason_codes.extend(_parse_cw_error_codes(metrics_error))

    if _has_measured_metrics(finding):
        reason_codes.append("cw_metrics_measured")

    if finding.get("estimation_method"):
        reason_codes.append("cost_estimated")

    if finding.get("estimated_monthly_cost_usd") is None:
        reason_codes.append("cost_unknown")

    if not reason_codes:
        reason_codes.append("heuristic")

    # De-duplicate while preserving order.
    seen = set()
    out: List[str] = []
    for code in reason_codes:
        if code not in seen:
            seen.add(code)
            out.append(code)
    return out


def build_evidence(
    finding: Dict[str, Any],
    *,
    use_cloudwatch: bool,
    lookback_days: int,
    cw_provenance: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    # Start with detector-provided evidence (if any), then add normalized sections.
    base = finding.get("evidence")
    evidence: Dict[str, Any] = dict(base) if isinstance(base, dict) else {}

    # CloudWatch evidence (only if CW is involved or relevant fields exist)
    has_cw_signal = bool(
        finding.get("metric_status")
        or finding.get("metrics_error")
        or _has_measured_metrics(finding)
    )
    if has_cw_signal:
        cw_errors = _split_semicolon(finding.get("metrics_error"))
        evidence["cloudwatch"] = {
            "enabled": bool(use_cloudwatch),
            "lookback_days": int(lookback_days),
            "metric_status": finding.get("metric_status"),
            "errors": cw_errors,
        }

        cw_metrics = finding.get("cw_metrics")
        if isinstance(cw_metrics, list) and cw_metrics:
            evidence["cloudwatch"]["metrics"] = cw_metrics
            try:
                ok_points = sum(
                    int(m.get("datapoints") or 0)
                    for m in cw_metrics
                    if isinstance(m, dict) and m.get("status") == "ok"
                )
            except Exception:
                ok_points = None
            if ok_points is not None:
                evidence["cloudwatch"]["datapoints_total"] = ok_points
        if isinstance(cw_provenance, dict):
            evidence["cloudwatch"]["budget"] = {
                "attempted": cw_provenance.get("cloudwatch_queries_attempted"),
                "used": cw_provenance.get("cloudwatch_queries_used"),
                "remaining": cw_provenance.get("cloudwatch_queries_remaining"),
            }

    # Estimation evidence
    if finding.get("estimation_method") or finding.get("assumptions"):
        evidence["estimation"] = {
            "method": finding.get("estimation_method"),
            "assumptions": finding.get("assumptions") or [],
            "adjusted_monthly_cost_usd": finding.get("adjusted_monthly_cost_usd"),
        }

    if "inventory" not in evidence:
        inv: Dict[str, Any] = {}
        if finding.get("resource_type"):
            inv["resource_type"] = finding.get("resource_type")
        if finding.get("id") or finding.get("resource_id") or finding.get("resource"):
            inv["resource_id"] = (
                finding.get("id")
                or finding.get("resource_id")
                or finding.get("resource")
            )
        if finding.get("region"):
            inv["region"] = finding.get("region")
        if inv:
            evidence["inventory"] = inv

    return evidence


def normalize_finding(
    finding: Dict[str, Any],
    *,
    use_cloudwatch: bool,
    lookback_days: int,
    cw_provenance: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    # Copy to avoid mutating detector output in-place.
    out = dict(finding)

    out["reason_codes"] = infer_reason_codes(out)
    out["evidence"] = build_evidence(
        out,
        use_cloudwatch=use_cloudwatch,
        lookback_days=lookback_days,
        cw_provenance=cw_provenance,
    )

    evidence = out.get("evidence")
    if isinstance(evidence, dict) and (
        not evidence or _minimal_inventory_only(evidence)
    ):
        reason_codes = out.get("reason_codes")
        if not isinstance(reason_codes, list):
            reason_codes = []
        if "evidence_limited" not in reason_codes:
            reason_codes.append("evidence_limited")
        confidence = out.get("confidence")
        if isinstance(confidence, (int, float)):
            out["confidence"] = min(float(confidence), 0.5)
        out["reason_codes"] = reason_codes

    return out


def normalize_findings(
    findings: Iterable[Dict[str, Any]],
    *,
    use_cloudwatch: bool,
    lookback_days: int,
    cw_provenance: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
    return [
        normalize_finding(
            f,
            use_cloudwatch=use_cloudwatch,
            lookback_days=lookback_days,
            cw_provenance=cw_provenance,
        )
        for f in findings
    ]


def json_safe(value: Any) -> str:
    """Convert nested values to a stable JSON string for CSV/reporting."""
    try:
        return json.dumps(value, sort_keys=True)
    except Exception:
        return str(value)
