"""Deteccion de outliers multivariante con Isolation Forest.

Detecta filas anomalas considerando TODAS las columnas a la vez (no columna a
columna): una fila puede ser normal en cada variable por separado y aun asi ser
un outlier por la combinacion de sus valores. Pura y determinista
(`random_state=0`).
"""

import numpy as np
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler


def isolation_forest_outliers(
    columns: dict,
    contamination: float = 0.05,
    max_report: int = 50,
) -> dict:
    """Detecta outliers multivariante con Isolation Forest.

    Args:
        columns: dict {nombre_columna: [valores numericos]}. Todas las listas se
            asumen alineadas por fila (misma longitud, la fila i de cada columna
            forma una observacion). Solo se usan columnas cuyos valores sean
            numericos; las demas se ignoran.
        contamination: proporcion esperada de outliers en [0, 0.5], pasada a
            IsolationForest. Default 0.05.
        max_report: numero maximo de filas anomalas a devolver en
            outlier_rows, las mas anomalas primero. Default 50.

    Returns:
        dict con:
            n_outliers: numero total de filas marcadas como outlier.
            outlier_pct: porcentaje de outliers sobre filas validas (0-100).
            outlier_rows: lista de {row_index, score} de los outliers, mas
                anomalo primero, truncada a max_report.
            threshold: umbral de decision del modelo (offset_). Una fila es
                outlier cuando su score (decision_function) es < threshold.
            n_rows_used: filas validas usadas (tras descartar filas con None).
            n_features: numero de columnas numericas usadas.

        IMPORTANTE: row_index es el indice contando SOLO las filas validas (las
        que no tenian ningun None en las columnas numericas usadas), empezando
        en 0 en orden de aparicion. No es el indice en las listas originales si
        se descarto alguna fila por contener None.

        Si hay menos de 2 columnas numericas o menos de 10 filas validas,
        devuelve {n_outliers: 0, note: "datos insuficientes"} sin petar.
    """
    # Selecciona solo columnas con todos los valores numericos (ints/floats,
    # bool no cuenta). None se permite a nivel de fila y se filtra despues.
    numeric_cols: dict[str, list] = {}
    for name, values in columns.items():
        if not isinstance(values, (list, tuple)):
            continue
        ok = True
        for v in values:
            if v is None:
                continue
            if isinstance(v, bool) or not isinstance(v, (int, float)):
                ok = False
                break
            if isinstance(v, float) and (np.isnan(v) or np.isinf(v)):
                ok = False
                break
        if ok:
            numeric_cols[name] = list(values)

    if len(numeric_cols) < 2:
        return {"n_outliers": 0, "note": "datos insuficientes"}

    col_names = list(numeric_cols.keys())
    n_rows_total = min(len(numeric_cols[c]) for c in col_names)

    # Construye matriz fila a fila, descartando filas con None en cualquier
    # columna usada. row_index = posicion entre las filas validas.
    rows: list[list[float]] = []
    for i in range(n_rows_total):
        row = [numeric_cols[c][i] for c in col_names]
        if any(v is None for v in row):
            continue
        rows.append([float(v) for v in row])

    if len(rows) < 10:
        return {"n_outliers": 0, "note": "datos insuficientes"}

    matrix = np.asarray(rows, dtype=float)
    n_rows_used = matrix.shape[0]
    n_features = matrix.shape[1]

    # Estandariza para que ninguna columna domine por escala.
    scaled = StandardScaler().fit_transform(matrix)

    model = IsolationForest(contamination=contamination, random_state=0)
    labels = model.fit_predict(scaled)  # -1 = outlier, 1 = inlier
    # decision_function: cuanto menor, mas anomalo. Outlier <=> score < 0
    # tras el ajuste de offset_ que aplica sklearn (score = raw - offset_).
    scores = model.decision_function(scaled)
    threshold = float(model.offset_)

    outlier_idx = [i for i, lab in enumerate(labels) if lab == -1]
    # Mas anomalo primero (score mas bajo primero).
    outlier_idx.sort(key=lambda i: scores[i])

    n_outliers = len(outlier_idx)
    outlier_rows = [
        {"row_index": int(i), "score": float(scores[i])}
        for i in outlier_idx[:max_report]
    ]

    return {
        "n_outliers": n_outliers,
        "outlier_pct": round(100.0 * n_outliers / n_rows_used, 4),
        "outlier_rows": outlier_rows,
        "threshold": threshold,
        "n_rows_used": n_rows_used,
        "n_features": n_features,
    }
