"""Sugiere la re-expresión (escalera de potencias de Tukey) que más simetriza una columna.

Funcion pura y determinista: no hace I/O, no ejecuta la transformación, no muta el
input. Solo razona por reglas sobre un bloque de estadísticos de una columna numérica
(el sub-bloque ``numeric`` de un ColumnProfile del grupo ``eda``: ``describe_numeric``)
y devuelve la transformación de la "escalera de potencias" de Tukey que se espera que
reduzca mejor la asimetría, junto a su razón legible y alternativas ordenadas.

Trasfondo (Tukey, *EDA* 1977, cap. 3-4 "re-expression"): la escalera de potencias
ordena las transformaciones por su exponente ``p``::

    ... x^3   x^2   x   sqrt(x)   log(x)   -1/sqrt(x)   -1/x ...
        p=3   p=2  p=1   p=0.5     p=0       p=-0.5     p=-1

Bajar por la escalera (``p`` menor) comprime la cola derecha → corrige asimetría
POSITIVA. Subir por la escalera (``p`` mayor) corrige asimetría NEGATIVA. El log
(``p=0``) es el escalón más usado para colas derechas largas, pero exige datos
estrictamente positivos. Con ceros se usa ``log1p`` (= ``log(1+x)``); con negativos
o ceros, la generalización moderna es ``Yeo-Johnson`` (y ``Box-Cox`` para datos
estrictamente positivos), que estiman el exponente óptimo a partir de los datos.

Esta función NO ejecuta la transformación: decide cuál sugerir. Es el caller quien la
aplica (p.ej. con ``numpy``/``scipy``/``sklearn``) si decide seguir la recomendación.
"""

from __future__ import annotations

# Umbrales sobre |skew| (convención habitual en EDA):
#   |skew| < 0.5  -> aproximadamente simétrica, no hace falta re-expresar.
#   0.5 <= |skew| < 1.0 -> asimetría moderada.
#   |skew| >= 1.0 -> asimetría fuerte (cola larga).
_SYMMETRIC_THRESHOLD = 0.5
_STRONG_THRESHOLD = 1.0

# Exponente conceptual de la escalera de Tukey por transformación (didáctico).
_LADDER_POWER = {
    "cube": 3.0,
    "square": 2.0,
    "none": 1.0,
    "sqrt": 0.5,
    "log": 0.0,
    "log1p": 0.0,
    "reciprocal": -1.0,
    "box-cox": None,        # data-driven (lambda estimado)
    "yeo-johnson": None,    # data-driven (lambda estimado)
}


def _to_float(v):
    """Parsea a float; None si es None/bool/no parseable (NaN incluido)."""
    if v is None or isinstance(v, bool):
        return None
    try:
        f = float(v)
    except (TypeError, ValueError):
        return None
    if f != f:  # NaN
        return None
    return f


def _alt(name: str, reason: str) -> dict:
    """Construye una entrada de alternativa con su exponente de la escalera."""
    return {"transform": name, "ladder_power": _LADDER_POWER.get(name), "reason": reason}


def suggest_reexpression(stats: dict) -> dict:
    """Sugiere la transformación de la escalera de potencias de Tukey que más simetriza.

    Razona por reglas (no ejecuta la transformación) a partir de un bloque de
    estadísticos de una columna numérica. Acepta tanto el sub-bloque ``numeric`` de
    un ColumnProfile (claves ``skew``, ``min``, ``kurtosis``, ``zero_pct``,
    ``negative_pct``...) como el ColumnProfile completo (en cuyo caso usa su clave
    ``numeric``). La decisión combina la magnitud y el signo de ``skew`` con el
    dominio de los datos (si hay ceros y/o negativos), porque ``log``/``Box-Cox``
    solo admiten valores estrictamente positivos.

    Reglas:
        - ``|skew| < 0.5``                  -> ``none`` (ya es ~simétrica).
        - ``skew`` positivo (cola derecha):
            - hay negativos                 -> ``yeo-johnson``.
            - hay ceros (sin negativos)     -> ``log1p`` (fuerte) / ``sqrt`` (moderado).
            - estrictamente positivos       -> ``log`` (fuerte) / ``sqrt`` (moderado).
        - ``skew`` negativo (cola izquierda):
            - hay negativos o ceros         -> ``yeo-johnson``.
            - estrictamente positivos       -> ``cube`` (fuerte) / ``square`` (moderado).
        - dominio desconocido (sin ``min``/``zero_pct``/``negative_pct``) y
          ``skew`` apreciable -> ``yeo-johnson`` (opción segura que admite cualquier
          dominio) más una nota.

    Es pura, determinista y no lanza excepciones: entradas vacías o sin ``skew``
    devuelven ``recommended = None`` y una ``note`` explicativa.

    Args:
        stats: dict con los estadísticos de la columna. Espera al menos ``skew``.
            Usa además ``min``, ``zero_pct`` y ``negative_pct`` (cuando estén) para
            determinar el dominio. Si recibe un ColumnProfile completo, lee su
            sub-bloque ``numeric``.

    Returns:
        dict con:
            - ``recommended``: nombre de la transformación sugerida (``"none"``,
              ``"log"``, ``"log1p"``, ``"sqrt"``, ``"square"``, ``"cube"``,
              ``"reciprocal"``, ``"box-cox"``, ``"yeo-johnson"``) o ``None`` si no
              se puede decidir (falta ``skew``).
            - ``ladder_power``: exponente conceptual de la escalera de Tukey de la
              transformación recomendada (``1.0`` raw, ``0.5`` sqrt, ``0.0`` log,
              ``None`` para las data-driven), o ``None`` si no hay recomendación.
            - ``reason``: explicación legible de por qué se sugiere.
            - ``alternatives``: lista ordenada de otras transformaciones razonables,
              cada una ``{"transform", "ladder_power", "reason"}``.
            - ``skew``: el skew usado en la decisión (float) o ``None``.
            - ``note``: cadena vacía en el caso normal; mensaje cuando la entrada es
              incompleta (sin ``skew``, dominio desconocido, etc.).
    """
    if not isinstance(stats, dict) or not stats:
        return {
            "recommended": None,
            "ladder_power": None,
            "reason": "",
            "alternatives": [],
            "skew": None,
            "note": "stats vacío o no es un dict: nada que sugerir",
        }

    # Aceptar un ColumnProfile completo: bajar a su sub-bloque numeric.
    if "skew" not in stats and isinstance(stats.get("numeric"), dict):
        stats = stats["numeric"]

    skew = _to_float(stats.get("skew"))
    if skew is None:
        return {
            "recommended": None,
            "ladder_power": None,
            "reason": "",
            "alternatives": [],
            "skew": None,
            "note": "skew ausente o no numérico: no se puede sugerir re-expresión",
        }

    minimum = _to_float(stats.get("min"))
    zero_pct = _to_float(stats.get("zero_pct"))
    negative_pct = _to_float(stats.get("negative_pct"))

    # Determinar el dominio de los datos a partir de lo disponible.
    domain_known = (
        minimum is not None or zero_pct is not None or negative_pct is not None
    )
    has_negative = (negative_pct is not None and negative_pct > 0) or (
        minimum is not None and minimum < 0
    )
    has_zero = (zero_pct is not None and zero_pct > 0) or (
        minimum is not None and minimum == 0
    )
    strictly_positive = domain_known and not has_negative and not has_zero

    abs_skew = abs(skew)
    strong = abs_skew >= _STRONG_THRESHOLD
    magnitude = "fuerte" if strong else "moderada"
    side = "cola derecha (asimetría positiva)" if skew > 0 else "cola izquierda (asimetría negativa)"
    note = ""

    # 1. Aproximadamente simétrica -> no re-expresar.
    if abs_skew < _SYMMETRIC_THRESHOLD:
        return {
            "recommended": "none",
            "ladder_power": _LADDER_POWER["none"],
            "reason": (
                f"skew = {skew:.3g} (|skew| < {_SYMMETRIC_THRESHOLD}): la columna ya es "
                "aproximadamente simétrica, no necesita re-expresión"
            ),
            "alternatives": [],
            "skew": skew,
            "note": "",
        }

    alternatives: list = []

    # 2. Asimetría positiva (cola derecha): bajar por la escalera de Tukey.
    if skew > 0:
        if has_negative:
            recommended = "yeo-johnson"
            reason = (
                f"skew = {skew:.3g} ({side}, {magnitude}) y hay valores negativos: "
                "Yeo-Johnson estima el exponente óptimo y admite negativos y ceros "
                "(log/Box-Cox no)"
            )
        elif has_zero:
            recommended = "log1p" if strong else "sqrt"
            reason = (
                f"skew = {skew:.3g} ({side}, {magnitude}) con ceros presentes: "
                + ("log1p = log(1+x) comprime la cola sin romper en x=0"
                   if strong else
                   "sqrt simetriza una cola moderada y admite el cero")
            )
            alternatives.append(_alt(
                "yeo-johnson",
                "estima el exponente óptimo y admite ceros; alternativa data-driven",
            ))
            alternatives.append(_alt(
                "sqrt" if strong else "log1p",
                "otro escalón cercano de la escalera para ceros",
            ))
        elif strictly_positive:
            recommended = "log" if strong else "sqrt"
            reason = (
                f"skew = {skew:.3g} ({side}, {magnitude}) y todos los valores > 0: "
                + ("log comprime con fuerza la cola derecha larga (escalón p=0)"
                   if strong else
                   "sqrt corrige una cola derecha moderada (escalón p=0.5)")
            )
            alternatives.append(_alt(
                "box-cox",
                "estima el exponente óptimo sobre datos estrictamente positivos",
            ))
            alternatives.append(_alt(
                "sqrt" if strong else "log",
                "escalón vecino de la escalera de Tukey",
            ))
        else:
            recommended = "yeo-johnson"
            note = "dominio desconocido (sin min/zero_pct/negative_pct): se sugiere la opción segura"
            reason = (
                f"skew = {skew:.3g} ({side}, {magnitude}) pero no se conoce el dominio: "
                "Yeo-Johnson funciona con cualquier rango (positivos, ceros, negativos)"
            )

    # 3. Asimetría negativa (cola izquierda): subir por la escalera de Tukey.
    else:
        if has_negative or has_zero:
            recommended = "yeo-johnson"
            reason = (
                f"skew = {skew:.3g} ({side}, {magnitude}) con ceros/negativos: "
                "Yeo-Johnson sube por la escalera y admite cualquier dominio"
            )
        elif strictly_positive:
            recommended = "cube" if strong else "square"
            reason = (
                f"skew = {skew:.3g} ({side}, {magnitude}) y todos los valores > 0: "
                + ("x^3 alarga la cola izquierda corta (escalón p=3)"
                   if strong else
                   "x^2 corrige una cola izquierda moderada (escalón p=2)")
            )
            alternatives.append(_alt(
                "box-cox",
                "estima un exponente > 1 óptimo sobre datos positivos",
            ))
            alternatives.append(_alt(
                "square" if strong else "cube",
                "escalón vecino hacia arriba de la escalera de Tukey",
            ))
        else:
            recommended = "yeo-johnson"
            note = "dominio desconocido (sin min/zero_pct/negative_pct): se sugiere la opción segura"
            reason = (
                f"skew = {skew:.3g} ({side}, {magnitude}) pero no se conoce el dominio: "
                "Yeo-Johnson funciona con cualquier rango"
            )

    return {
        "recommended": recommended,
        "ladder_power": _LADDER_POWER.get(recommended),
        "reason": reason,
        "alternatives": alternatives,
        "skew": skew,
        "note": note,
    }
