"""
agentic_score.py

Calculates the Agentic Readiness Score of a contact center from
a JSON file with aggregated KPIs (same structure as results.json).

Designed as a class to integrate easily into pipelines.

Features:
- Tolerant to missing data: if a dimension cannot be calculated
  (due to missing KPIs), it is marked as `computed = False` and not
  included in the global score calculation.
- Typical pipeline call:
    from agentic_score import AgenticScorer
    scorer = AgenticScorer()
    result = scorer.run_on_folder("/path/to/folder")

The folder must contain a `results.json` input file.
The module will generate an `agentic_readiness.json` in the same folder.
"""

from __future__ import annotations

import json
import math
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Union

Number = Union[int, float]


# =========================
# Helpers
# =========================

def _is_nan(x: Any) -> bool:
    """Returns True if x is NaN, None or the string 'NaN'."""
    try:
        if x is None:
            return True
        if isinstance(x, str) and x.lower() == "nan":
            return True
        return math.isnan(float(x))
    except (TypeError, ValueError):
        return False


def _safe_mean(values: Sequence[Optional[Number]]) -> Optional[float]:
    nums: List[float] = []
    for v in values:
        if v is None:
            continue
        if _is_nan(v):
            continue
        nums.append(float(v))
    if not nums:
        return None
    return sum(nums) / len(nums)


def _get_nested(d: Dict[str, Any], *keys: str, default: Any = None) -> Any:
    """Safe access to nested dictionaries."""
    cur: Any = d
    for k in keys:
        if not isinstance(cur, dict) or k not in cur:
            return default
        cur = cur[k]
    return cur


def _clamp(value: float, lo: float = 0.0, hi: float = 10.0) -> float:
    return max(lo, min(hi, value))


def _normalize_numeric_sequence(field: Any) -> Optional[List[Number]]:
    """
    Normalizes a field representing a numeric sequence.

    Supports:
    - Old pipeline format: [10, 20, 30]
    - New pipeline format: {"labels": [...], "values": [10, 20, 30]}

    Returns:
    - list of numbers, if there is valid numeric data
    - None, if the field does not have an interpretable numeric sequence
    """
    if field is None:
        return None

    # New format: {"labels": [...], "values": [...]}
    if isinstance(field, dict) and "values" in field:
        seq = field.get("values")
    else:
        seq = field

    if not isinstance(seq, Sequence):
        return None

    out: List[Number] = []
    for v in seq:
        if isinstance(v, (int, float)):
            out.append(v)
        else:
            # Try soft conversion in case it's a numeric string
            try:
                out.append(float(v))
            except (TypeError, ValueError):
                continue

    return out or None


# =========================
# Scoring functions
# =========================

def score_repetitividad(volume_by_skill: Optional[List[Number]]) -> Dict[str, Any]:
    """
    Repeatability based on average volume per skill.

    Rule (designed per process/skill):
      - 10 if volume > 80
      - 5 if 40–80
      - 0 if < 40

    If there is no data (empty or non-numeric list), the dimension
    is marked as not calculated (computed = False).
    """
    if not volume_by_skill:
        return {
            "score": None,
            "computed": False,
            "reason": "no_volume_data",
            "details": {
                "avg_volume_per_skill": None,
                "volume_by_skill": volume_by_skill,
            },
        }

    avg_volume = _safe_mean(volume_by_skill)
    if avg_volume is None:
        return {
            "score": None,
            "computed": False,
            "reason": "volume_not_numeric",
            "details": {
                "avg_volume_per_skill": None,
                "volume_by_skill": volume_by_skill,
            },
        }

    if avg_volume > 80:
        score = 10.0
        reason = "high_volume"
    elif avg_volume >= 40:
        score = 5.0
        reason = "medium_volume"
    else:
        score = 0.0
        reason = "low_volume"

    return {
        "score": score,
        "computed": True,
        "reason": reason,
        "details": {
            "avg_volume_per_skill": avg_volume,
            "volume_by_skill": volume_by_skill,
            "thresholds": {
                "high": 80,
                "medium": 40,
            },
        },
    }


def score_predictibilidad(aht_ratio: Any,
                          escalation_rate: Any) -> Dict[str, Any]:
    """
    Predictability based on:
      - AHT variability: ratio P90/P50
      - Escalation rate (%)

    Rule:
      - 10 if ratio < 1.5 and escalation < 10%
      - 5 if ratio 1.5–2.0 or escalation 10–20%
      - 0 if ratio > 2.0 and escalation > 20%
      - 3 fallback if data parciales

    If there is no ratio nor escalation, the dimension is not calculated.
    """
    if aht_ratio is None and escalation_rate is None:
        return {
            "score": None,
            "computed": False,
            "reason": "no_data",
            "details": {
                "aht_p90_p50_ratio": None,
                "escalation_rate_pct": None,
            },
        }

    # Normalize ratio
    if aht_ratio is None or _is_nan(aht_ratio):
        ratio: Optional[float] = None
    else:
        ratio = float(aht_ratio)

    # Normalize escalation
    if escalation_rate is None or _is_nan(escalation_rate):
        esc: Optional[float] = None
    else:
        esc = float(escalation_rate)

    if ratio is None and esc is None:
        return {
            "score": None,
            "computed": False,
            "reason": "no_data",
            "details": {
                "aht_p90_p50_ratio": None,
                "escalation_rate_pct": None,
            },
        }

    score: float
    reason: str

    if ratio is not None and esc is not None:
        if ratio < 1.5 and esc < 10.0:
            score = 10.0
            reason = "high_predictability"
        elif (1.5 <= ratio <= 2.0) or (10.0 <= esc <= 20.0):
            score = 5.0
            reason = "medium_predictability"
        elif ratio > 2.0 and esc > 20.0:
            score = 0.0
            reason = "low_predictability"
        else:
            score = 3.0
            reason = "intermediate_case"
    else:
        # Partial data: penalize but do not set to 0
        score = 3.0
        reason = "partial_data"

    return {
        "score": score,
        "computed": True,
        "reason": reason,
        "details": {
            "aht_p90_p50_ratio": ratio,
            "escalation_rate_pct": esc,
            "rules": {
                "high": {"max_ratio": 1.5, "max_esc_pct": 10},
                "medium": {"ratio_range": [1.5, 2.0], "esc_range_pct": [10, 20]},
                "low": {"min_ratio": 2.0, "min_esc_pct": 20},
            },
        },
    }


def score_estructuracion(channel_distribution_pct: Any) -> Dict[str, Any]:
    """
    Data structuring using channel proxy.

    We assume the channel with the highest % is text (en proyectos reales se puede
    parametrizar esta asignación).

    Rule:
      - 10 if text > 60%
      - 5 si 30–60%
      - 0 si < 30%

    If there is no datas of channels, the dimension is not calculated.
    """
    if not channel_distribution_pct:
        return {
            "score": None,
            "computed": False,
            "reason": "no_channel_data",
            "details": {
                "estimated_text_share_pct": None,
                "channel_distribution_pct": channel_distribution_pct,
            },
        }

    try:
        values: List[float] = []
        for x in channel_distribution_pct:
            if _is_nan(x):
                continue
            values.append(float(x))
        if not values:
            raise ValueError("sin valores numéricos")
        max_share = max(values)
    except Exception:
        return {
            "score": None,
            "computed": False,
            "reason": "channels_not_numeric",
            "details": {
                "estimated_text_share_pct": None,
                "channel_distribution_pct": channel_distribution_pct,
            },
        }

    if max_share > 60.0:
        score = 10.0
        reason = "high_text_proportion"
    elif max_share >= 30.0:
        score = 5.0
        reason = "medium_text_proportion"
    else:
        score = 0.0
        reason = "low_text_proportion"

    return {
        "score": score,
        "computed": True,
        "reason": reason,
        "details": {
            "estimated_text_share_pct": max_share,
            "channel_distribution_pct": channel_distribution_pct,
            "thresholds_pct": {
                "high": 60,
                "medium": 30,
            },
        },
    }


def score_complejidad(aht_ratio: Any,
                      escalation_rate: Any) -> Dict[str, Any]:
    """
    Inverse complexity of the process (0–10).

    1) Base: linear inverse de la variabilidad AHT (ratio P90/P50):
       - ratio = 1.0 -> 10
       - ratio = 1.5 -> ~7.5
       - ratio = 2.0 -> 5
       - ratio = 2.5 -> 2.5
       - ratio >= 3.0 -> 0

       formula_base = (3 - ratio) / (3 - 1) * 10, acotado a [0,10]

    2) Escalation adjustment:
       - restamos (escalation_rate / 5) puntos.

    Nota: higher score = process more "simple / automatizable".

    If there is no ratio nor escalation, the dimension is not calculated.
    """
    if aht_ratio is None or _is_nan(aht_ratio):
        ratio: Optional[float] = None
    else:
        ratio = float(aht_ratio)

    if escalation_rate is None or _is_nan(escalation_rate):
        esc: Optional[float] = None
    else:
        esc = float(escalation_rate)

    if ratio is None and esc is None:
        return {
            "score": None,
            "computed": False,
            "reason": "no_data",
            "details": {
                "aht_p90_p50_ratio": None,
                "escalation_rate_pct": None,
            },
        }

    # Base for variability
    if ratio is None:
        base = 5.0  # neutral fallback
        base_reason = "no_ratio_using_neutral_value"
    else:
        base_raw = (3.0 - ratio) / (3.0 - 1.0) * 10.0
        base = _clamp(base_raw)
        base_reason = "calculated_from_ratio"

    # Escalation adjustment
    if esc is None:
        adj = 0.0
        adj_reason = "no_escalation_no_adjustment"
    else:
        adj = - (esc / 5.0)  # every 5 escalation points subtract 1
        adj_reason = "escalation_adjustment"

    final_score = _clamp(base + adj)

    return {
        "score": final_score,
        "computed": True,
        "reason": "inverse_complexity",
        "details": {
            "aht_p90_p50_ratio": ratio,
            "escalation_rate_pct": esc,
            "base_score": base,
            "base_reason": base_reason,
            "adjustment": adj,
            "adjustment_reason": adj_reason,
        },
    }


def score_estabilidad(peak_offpeak_ratio: Any) -> Dict[str, Any]:
    """
    Process stability based on peak/off-peak ratio.

    Rule:
      - 10 if ratio < 3
      - 7 si 3–5
      - 3 si 5–7
      - 0 si > 7

    If there is no data of ratio, the dimension is not calculated.
    """
    if peak_offpeak_ratio is None or _is_nan(peak_offpeak_ratio):
        return {
            "score": None,
            "computed": False,
            "reason": "no_peak_offpeak_data",
            "details": {
                "peak_offpeak_ratio": None,
            },
        }

    r = float(peak_offpeak_ratio)
    if r < 3.0:
        score = 10.0
        reason = "very_stable"
    elif r < 5.0:
        score = 7.0
        reason = "moderately_stable"
    elif r < 7.0:
        score = 3.0
        reason = "pronounced_peak"
    else:
        score = 0.0
        reason = "very_unstable"

    return {
        "score": score,
        "computed": True,
        "reason": reason,
        "details": {
            "peak_offpeak_ratio": r,
            "thresholds": {
                "very_stable": 3.0,
                "stable": 5.0,
                "unstable": 7.0,
            },
        },
    }


def score_roi(annual_savings: Any) -> Dict[str, Any]:
    """
    Annual potential ROI.

    Rule:
      - 10 if savings > 100k €/year
      - 5 si 10k–100k €/year
      - 0 si < 10k €/year

    If there is no data of savings, the dimension is not calculated.
    """
    if annual_savings is None or _is_nan(annual_savings):
        return {
            "score": None,
            "computed": False,
            "reason": "no_savings_data",
            "details": {
                "annual_savings_eur": None,
            },
        }

    savings = float(annual_savings)
    if savings > 100_000:
        score = 10.0
        reason = "high_roi"
    elif savings >= 10_000:
        score = 5.0
        reason = "medium_roi"
    else:
        score = 0.0
        reason = "low_roi"

    return {
        "score": score,
        "computed": True,
        "reason": reason,
        "details": {
            "annual_savings_eur": savings,
            "thresholds_eur": {
                "high": 100_000,
                "medium": 10_000,
            },
        },
    }


def classify_agentic_score(score: Optional[float]) -> Dict[str, Any]:
    """
    Final classification (aligned with frontend):
      - ≥6: COPILOT 🤖 (Ready for Copilot)
      - 4–5.99: OPTIMIZE 🔧 (Optimizar Primero)
      - <4: HUMAN 👤 (Requiere Gestión Humana)

    If score is None (no dimension available), returns NO_DATA.
    """
    if score is None:
        return {
            "label": "NO_DATA",
            "emoji": "❓",
            "description": (
                "Could not calculate the Agentic Readiness Score because "
                "none of the dimensions had sufficient data."
            ),
        }

    if score >= 6.0:
        label = "COPILOT"
        emoji = "🤖"
        description = (
            "Ready for Copilot. Processes with sufficient predictability and simplicity "
            "for AI assistance (real-time suggestions, autocomplete)."
        )
    elif score >= 4.0:
        label = "OPTIMIZE"
        emoji = "🔧"
        description = (
            "Optimize first. Standardize processes and reduce variability "
            "before implementing AI assistance."
        )
    else:
        label = "HUMAN"
        emoji = "👤"
        description = (
            "Requires human management. Complex or variable processes that "
            "need human intervention before considering automation."
        )

    return {
        "label": label,
        "emoji": emoji,
        "description": description,
    }


# =========================
# Clase principal
# =========================

class AgenticScorer:
    """
    Clase para calcular el Agentic Readiness Score a partir de resultados
    agregados (results.json) y dejar la salida en agentic_readiness.json
    en la misma carpeta.
    """

    def __init__(
        self,
        input_filename: str = "results.json",
        output_filename: str = "agentic_readiness.json",
    ) -> None:
        self.input_filename = input_filename
        self.output_filename = output_filename

        self.base_weights: Dict[str, float] = {
            "repetitividad": 0.25,
            "predictibilidad": 0.20,
            "estructuracion": 0.15,
            "complejidad": 0.15,
            "estabilidad": 0.10,
            "roi": 0.15,
        }

    # --------- IO helpers ---------

    def load_results(self, folder_path: Union[str, Path]) -> Dict[str, Any]:
        folder = Path(folder_path)
        input_path = folder / self.input_filename
        if not input_path.exists():
            raise FileNotFoundError(
                f"No se ha encontrado el archivo de entrada '{self.input_filename}' "
                f"en la carpeta: {folder}"
            )
        with input_path.open("r", encoding="utf-8") as f:
            return json.load(f)

    def save_agentic_readiness(self, folder_path: Union[str, Path], result: Dict[str, Any]) -> Path:
        folder = Path(folder_path)
        output_path = folder / self.output_filename
        with output_path.open("w", encoding="utf-8") as f:
            json.dump(result, f, ensure_ascii=False, indent=2)
        return output_path

    # --------- Core computation ---------

    def compute_from_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Calculates the Agentic Readiness Score from a data dict.

        Tolerant to missing data: renormalizes weights using only
        dimensions with `computed = True`.

        Pipeline compatibility:
        - Supports both the old format:
            "volume_by_skill": [10, 20, 30]
        - and the new:
            "volume_by_skill": {"labels": [...], "values": [10, 20, 30]}
        """
        volumetry = data.get("volumetry", {})
        op = data.get("operational_performance", {})
        econ = data.get("economy_costs", {})

        # Normalize here the possible formats for the type checker
        volume_by_skill = _normalize_numeric_sequence(
            volumetry.get("volume_by_skill")
        )
        channel_distribution_pct = _normalize_numeric_sequence(
            volumetry.get("channel_distribution_pct")
        )
        peak_offpeak_ratio = volumetry.get("peak_offpeak_ratio")

        aht_ratio = _get_nested(op, "aht_distribution", "p90_p50_ratio")
        escalation_rate = op.get("escalation_rate")

        annual_savings = _get_nested(econ, "potential_savings", "annual_savings")

        # --- Calculamos sub-scores (cada uno decide si está 'computed' o no) ---
        repet = score_repetitividad(volume_by_skill)
        pred = score_predictibilidad(aht_ratio, escalation_rate)
        estr = score_estructuracion(channel_distribution_pct)
        comp = score_complejidad(aht_ratio, escalation_rate)
        estab = score_estabilidad(peak_offpeak_ratio)
        roi = score_roi(annual_savings)

        sub_scores = {
            "repetitividad": repet,
            "predictibilidad": pred,
            "estructuracion": estr,
            "complejidad": comp,
            "estabilidad": estab,
            "roi": roi,
        }

        # --- Weight renormalization only with available dimensions ---
        effective_weights: Dict[str, float] = {}
        for name, base_w in self.base_weights.items():
            dim = sub_scores.get(name, {})
            if dim.get("computed"):
                effective_weights[name] = base_w

        total_effective_weight = sum(effective_weights.values())
        if total_effective_weight > 0:
            normalized_weights = {
                name: w / total_effective_weight for name, w in effective_weights.items()
            }
        else:
            normalized_weights = {}

        # --- Final score ---
        if not normalized_weights:
            final_score: Optional[float] = None
        else:
            acc = 0.0
            for name, dim in sub_scores.items():
                if not dim.get("computed"):
                    continue
                w = normalized_weights.get(name, 0.0)
                acc += (dim.get("score") or 0.0) * w
            final_score = round(acc, 2)

        classification = classify_agentic_score(final_score)

        result = {
            "agentic_readiness": {
                "version": "1.0",
                "final_score": final_score,
                "classification": classification,
                "weights": {
                    "base_weights": self.base_weights,
                    "normalized_weights": normalized_weights,
                },
                "sub_scores": sub_scores,
                "metadata": {
                    "source_module": "agentic_score.py",
                    "notes": (
                        "Simplified model based on aggregated KPIs. "
                        "Renormalizes weights when dimensions are missing."
                    ),
                },
            }
        }

        return result

    def compute_and_return(self, data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Permite calcular el Agentic Readiness directamente desde
        un objeto Python (dict), sin necesidad de carpetas ni archivos.
        """
        return self.compute_from_data(data)

    def run_on_folder(self, folder_path: Union[str, Path]) -> Dict[str, Any]:
        """
        Typical pipeline entry point:
        - Reads <folder>/results.json
        - Calculates Agentic Readiness
        - Writes <folder>/agentic_readiness.json
        - Returns the dict with the result
        """
        data = self.load_results(folder_path)
        result = self.compute_from_data(data)
        self.save_agentic_readiness(folder_path, result)
        return result


# =========================
# CLI opcional
# =========================

def main(argv: List[str]) -> None:
    if len(argv) < 2:
        print(
            "Uso: python agentic_score.py <carpeta_resultados>\n"
            "La carpeta debe contener un 'results.json'. Se generará un "
            "'agentic_readiness.json' en la misma carpeta.",
            file=sys.stderr,
        )
        sys.exit(1)

    folder = argv[1]
    scorer = AgenticScorer()

    try:
        result = scorer.run_on_folder(folder)
    except Exception as e:
        print(f"Error al procesar la carpeta '{folder}': {e}", file=sys.stderr)
        sys.exit(1)

    # Por comodidad, también mostramos el score final por consola
    ar = result.get("agentic_readiness", {})
    print(json.dumps(result, ensure_ascii=False, indent=2))
    final_score = ar.get("final_score")
    classification = ar.get("classification", {})
    label = classification.get("label")
    emoji = classification.get("emoji")
    if final_score is not None and label:
        print(f"\nAgentic Readiness Score: {final_score} {emoji} ({label})")


if __name__ == "__main__":
    main(sys.argv)
