"""Score de calidad de datos (0-100) para un ColumnProfile del grupo eda.

Funcion pura: dado el perfil de una columna producido por el grupo de
capacidad `eda` (p.ej. summarize_table_duckdb / profile_table), calcula un
score agregado de calidad junto a su desglose por dimension y dos listas
legibles separadas: `issues` (defectos de calidad reales que SI bajan el
score) y `observations` (señales analiticas que NO bajan el score). No
realiza I/O ni muta el input.

Modelo (DAMA-DMBOK / ISO 8000), ver report 2046:

- Solo entran en el score las dimensiones medibles automaticamente desde el
  perfil, sin fuente externa de verdad: completeness y validity por columna.
- Renormalizacion por aplicabilidad: si una dimension no es medible en la
  columna (texto libre sin semantica -> validity no aplica; columna 100% nula
  -> validity no medible), se excluye y los pesos se renormalizan sobre las
  aplicables. Una columna ni se premia ni se castiga por algo no medible.
- Sin doble conteo: la falta de datos cuenta solo en completeness (se elimino
  el antiguo castigo extra de `mostly_null` sobre validity).
- Los OUTLIERS NO bajan la calidad. Un valor extremo puede ser real y
  correcto; detectar atipicos es analisis de la distribucion, no un juicio de
  coreccion. Outliers, columnas constantes e identificadores de alta
  cardinalidad pasan a `observations`, nunca a `issues`.
"""


# Pesos base de las dimensiones de columna (se renormalizan por aplicabilidad).
_W_COMPLETENESS = 0.6
_W_VALIDITY = 0.4

# Tipos inferidos cuyo almacen garantiza la conformidad de tipo (validity=1.0)
# cuando NO vienen de una promocion de texto (en cuyo caso manda validity_rate).
_NATIVE_TYPED = ("numeric", "integer", "float", "datetime", "date", "boolean", "bool")


def column_quality_score(col: dict) -> dict:
    """Calcula un score de calidad de datos 0-100 para un ColumnProfile.

    El score combina solo dimensiones de calidad medibles desde el perfil, con
    renormalizacion por aplicabilidad:

      - completeness (peso base 0.6, siempre aplica): proporcion de valores
        presentes = 1 - null_pct. En texto, las celdas vacias (`empty_count`)
        tambien cuentan como faltantes.
      - validity (peso base 0.4, cuando hay un criterio de validacion real):
        fraccion de valores no nulos conformes a su tipo/semantica. Tipo nativo
        numerico/fecha/bool = 1.0; texto promovido a numero/fecha = parse rate
        (`validity_rate`); texto con `semantic_type` regexable = `match_rate`;
        texto libre o columna 100% nula = NO aplicable (renormaliza a solo
        completeness).

    Los outliers, columnas constantes, identificadores y asimetria fuerte NO
    bajan el score: se devuelven en `observations`.

    Args:
        col: ColumnProfile dict del grupo eda. Se leen las claves de forma
            defensiva con .get(...) y se tolera que muchas vengan en None.
            Claves relevantes: null_pct (0-1), n_rows, empty_count,
            inferred_type, semantic_type, validity_rate (0-1, lo expone
            profile_table al promocionar texto a numero/fecha), match_rate
            (0-1), unique_pct (0-1), flags (list[str], reconoce
            "constant"/"possible_id"/"high_cardinality"), numeric
            ({outlier_pct: 0-100, skew, ...}|None).

    Returns:
        dict con:
          score        (float 0-100, redondeado a 1 decimal),
          completeness (float 0-1),
          validity     (float 0-1 | None si no aplicable),
          dimensions   ({completeness, validity}),
          applicable   (list[str] de dimensiones que entraron en el score),
          issues       (list[str]) SOLO defectos de calidad (nulos, vacios,
                       valores no conformes a su tipo/semantica),
          observations (list[str]) señales analiticas que NO bajan el score
                       (outliers, columna constante, posible id, asimetria).
    """
    if not isinstance(col, dict):
        col = {}

    flags = col.get("flags") or []
    if not isinstance(flags, (list, tuple)):
        flags = []
    flags = set(flags)

    issues: list[str] = []
    observations: list[str] = []

    inferred_type = col.get("inferred_type") or ""
    semantic_type = col.get("semantic_type") or ""

    # --- completeness -------------------------------------------------
    # Falta de datos = nulos + (en texto) celdas vacias. Es el unico sitio
    # donde la falta de datos cuenta: nunca se duplica en validity.
    null_pct = _clamp(_num(col.get("null_pct"), 0.0), 0.0, 1.0)
    completeness = 1.0 - null_pct
    if null_pct > 0:
        issues.append(f"{_pct(null_pct)} nulos")

    empty_frac = 0.0
    n_rows = col.get("n_rows")
    empty_count = col.get("empty_count")
    if (
        isinstance(n_rows, (int, float)) and not isinstance(n_rows, bool) and n_rows > 0
        and isinstance(empty_count, (int, float)) and not isinstance(empty_count, bool)
        and empty_count > 0
    ):
        empty_frac = _clamp(float(empty_count) / float(n_rows), 0.0, 1.0)
        completeness = _clamp(completeness - empty_frac, 0.0, 1.0)
        issues.append(f"{_pct(empty_frac)} vacíos")

    # --- validity (con renormalizacion por aplicabilidad) -------------
    # None = no medible -> se excluye del score (no penaliza ni premia).
    validity = None
    if completeness <= 0.0:
        # Columna 100% faltante: no hay valores no nulos sobre los que medir
        # conformidad. validity no aplica -> el score sale solo de completeness
        # (= 0). Es el peor defecto de calidad posible.
        validity = None
    else:
        validity_rate = col.get("validity_rate")
        match_rate = col.get("match_rate")
        if validity_rate is not None:
            # Texto promovido a numero/fecha: parse rate real de la muestra.
            v = _num(validity_rate, None)
            if v is not None:
                validity = _clamp(v, 0.0, 1.0)
                if validity < 1.0:
                    kind = (
                        "número" if inferred_type == "numeric"
                        else "fecha" if inferred_type == "datetime"
                        else inferred_type or "su tipo"
                    )
                    issues.append(
                        f"{_pct(1.0 - validity)} no parsea al tipo {kind}"
                    )
        elif inferred_type in _NATIVE_TYPED:
            # Tipo nativo garantizado por el almacen: no hay valores que no
            # parseen. validity = 1.0 (no se confunde con tener outliers).
            validity = 1.0
        elif semantic_type and match_rate is not None:
            v = _num(match_rate, None)
            if v is not None:
                validity = _clamp(v, 0.0, 1.0)
                if validity < 1.0:
                    issues.append(
                        f"{_pct(1.0 - validity)} no casa con el "
                        f"formato «{semantic_type}»"
                    )
        else:
            # Texto libre / categorica sin semantica: no hay criterio honesto
            # de validez. No aplica.
            validity = None

    # --- observations (NO bajan el score) -----------------------------
    numeric = col.get("numeric")
    if isinstance(numeric, dict):
        # outlier_pct viene en escala 0-100 desde describe_numeric (z-score>3).
        outlier_pct = _num(numeric.get("outlier_pct"), None)
        if outlier_pct is not None and outlier_pct >= 0.05:
            observations.append(
                f"{_pct(outlier_pct / 100.0)} de valores atípicos (z-score>3): "
                "revisar si son errores u observaciones legítimas"
            )
        skew = _num(numeric.get("skew"), None)
        if skew is not None and abs(skew) >= 1.0:
            observations.append(
                f"asimetría fuerte (skew={round(skew, 2)}): considerar "
                "re-expresión antes de modelar"
            )

    if "constant" in flags:
        observations.append(
            "columna constante: aporta poca información para el análisis"
        )

    unique_pct = _num(col.get("unique_pct"), None)
    is_id = (
        "possible_id" in flags
        or "high_cardinality" in flags
        or (
            inferred_type in ("text", "categorical")
            and unique_pct is not None
            and _clamp(unique_pct, 0.0, 1.0) > 0.9
        )
    )
    if is_id:
        observations.append(
            "valores casi únicos: posible identificador (no es un defecto de calidad)"
        )

    # --- score agregado con renormalizacion ---------------------------
    applicable = ["completeness"]
    num = _W_COMPLETENESS * completeness
    den = _W_COMPLETENESS
    if validity is not None:
        applicable.append("validity")
        num += _W_VALIDITY * validity
        den += _W_VALIDITY
    score = round(100.0 * num / den, 1) if den > 0 else 0.0

    return {
        "score": score,
        "completeness": completeness,
        "validity": validity,
        "dimensions": {"completeness": completeness, "validity": validity},
        "applicable": applicable,
        "issues": issues,
        "observations": observations,
    }


def _pct(frac: float) -> str:
    """Formatea una fraccion 0-1 como porcentaje honesto: «N%» si >=1%, «0.N%»
    por debajo (para no mostrar «0%» cuando hay un defecto real pequeño)."""
    p = frac * 100.0
    if p >= 1.0:
        return f"{round(p)}%"
    return f"{p:.1f}%"


def _num(x, default):
    """Convierte x a float; devuelve `default` si es None o no parseable."""
    if x is None:
        return default
    if isinstance(x, bool):
        return default
    try:
        return float(x)
    except (TypeError, ValueError):
        return default


def _clamp(x: float, lo: float, hi: float) -> float:
    """Recorta x al rango [lo, hi]."""
    if x < lo:
        return lo
    if x > hi:
        return hi
    return x
