import statsmodels.api as sm
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.tree import DecisionTreeClassifier
from sklearn.tree import plot_tree
from sklearn.metrics import accuracy_score
from sklearn.tree import DecisionTreeRegressor
from sklearn.metrics import mean_squared_error
from sklearn.svm import SVR
from sklearn.model_selection import GridSearchCV
from keras.models import Sequential
from keras.layers import Dense, Dropout

from scipy import stats
from statsmodels.stats.outliers_influence import variance_inflation_factor
from sklearn.metrics import roc_curve, auc, precision_recall_curve

## =========================================================
## VIF: Variance Inflation Factor
## Sirve para detectar multicolinealidad entre variables X
## =========================================================
def vif(X):
    X = sm.add_constant(X)

    vif_data = pd.DataFrame()
    vif_data["variable"] = X.columns
    vif_data["VIF"] = [
        variance_inflation_factor(X.values, i)
        for i in range(X.shape[1])
    ]

    return vif_data


## =========================================================
## OLS: Ordinary Least Squares
## Ajusta un modelo de regresión lineal con statsmodels
## =========================================================
def ols(y, X):
    X = sm.add_constant(X)
    modelo = sm.OLS(y, X).fit()
    return modelo


## =========================================================
## Box-Cox Transform
## Evalúa y transforma una variable y para mejorar normalidad
## y homocedasticidad en modelos lineales
## =========================================================
def boxcox_transform(y, return_transformed=True, verbose=True):
    
    ## Validación: Box-Cox solo funciona con valores positivos
    if np.any(y <= 0):
        raise ValueError("Box-Cox requiere que todos los valores de y sean positivos.")
    
    ## Transformación
    y_transformed, lambda_opt = stats.boxcox(y)
    
    ## Mensajes de interpretación
    if verbose:
        print(f"Lambda óptimo: {lambda_opt:.4f}")
        
        if abs(lambda_opt - 1) < 0.15:
            print("Recomendación: no transformar la variable.")
        elif abs(lambda_opt) < 0.15:
            print("Recomendación: usar log(y).")
        elif abs(lambda_opt - 0.5) < 0.15:
            print("Recomendación: usar sqrt(y).")
        elif lambda_opt < 0:
            print("Recomendación: transformación fuerte (inversa o Box-Cox).")
        else:
            print(f"Recomendación: usar Box-Cox con lambda = {lambda_opt:.4f}.")
    
    ## Retorno
    if return_transformed:
        return y_transformed, lambda_opt
    else:
        return lambda_opt


## =========================================================
## ROC Curve
## Grafica la curva ROC de un modelo de clasificación binaria
## y devuelve el valor AUC
## =========================================================
def plot_roc(model, X_test, y_test, scaler=None):
    
    ## Escalar si se proporciona scaler
    if scaler is not None:
        X_test = scaler.transform(X_test)
    
    ## Validación del modelo
    if not hasattr(model, "predict_proba"):
        raise ValueError("El modelo no tiene el método predict_proba().")
    
    ## Probabilidades de la clase positiva
    y_prob = model.predict_proba(X_test)[:, 1]
    
    ## Cálculo ROC y AUC
    fpr, tpr, _ = roc_curve(y_test, y_prob)
    roc_auc = auc(fpr, tpr)
    
    ## Gráfico
    plt.figure()
    plt.plot(fpr, tpr, label=f"AUC = {roc_auc:.2f}")
    plt.plot([0, 1], [0, 1], '--', label="Random")
    plt.xlabel("False Positive Rate")
    plt.ylabel("True Positive Rate")
    plt.title("ROC Curve")
    plt.legend()
    plt.grid()
    plt.show()
    plt.close()
    
    return roc_auc


## =========================================================
## Multiple ROC Curves
## Compara varios modelos de clasificación binaria en una sola
## gráfica ROC y devuelve un diccionario con los AUC
## =========================================================
def plot_multiple_roc(models, X_test, y_test, scaler=None):
    
    plt.figure()
    auc_scores = {}
    
    ## Escalar una sola vez si aplica
    X = scaler.transform(X_test) if scaler else X_test
    
    for name, model in models.items():
        if not hasattr(model, "predict_proba"):
            raise ValueError(f"El modelo '{name}' no tiene el método predict_proba().")
        
        y_prob = model.predict_proba(X)[:, 1]
        
        fpr, tpr, _ = roc_curve(y_test, y_prob)
        roc_auc = auc(fpr, tpr)
        
        auc_scores[name] = roc_auc
        
        plt.plot(fpr, tpr, label=f"{name} (AUC = {roc_auc:.2f})")
    
    plt.plot([0, 1], [0, 1], '--', label="Random")
    plt.xlabel("False Positive Rate")
    plt.ylabel("True Positive Rate")
    plt.title("ROC Curve Comparison")
    plt.legend()
    plt.grid()
    plt.show()
    plt.close()
    
    return auc_scores


## =========================================================
## Precision-Recall Curve
## Grafica la curva Precision-Recall de un modelo de
## clasificación binaria y devuelve el PR AUC
## =========================================================
def plot_precision_recall(model, X_test, y_test, scaler=None):
    
    ## Escalar si aplica
    X = scaler.transform(X_test) if scaler else X_test
    
    ## Validación del modelo
    if not hasattr(model, "predict_proba"):
        raise ValueError("El modelo no tiene el método predict_proba().")
    
    ## Probabilidades de la clase positiva
    y_prob = model.predict_proba(X)[:, 1]
    
    ## Cálculo de precision, recall y PR AUC
    precision, recall, _ = precision_recall_curve(y_test, y_prob)
    pr_auc = auc(recall, precision)
    
    ## Gráfico
    plt.figure()
    plt.plot(recall, precision, label=f"PR AUC = {pr_auc:.2f}")
    plt.xlabel("Recall")
    plt.ylabel("Precision")
    plt.title("Precision-Recall Curve")
    plt.legend()
    plt.grid()
    plt.show()
    plt.close()
    
    return pr_auc

## =========================================================
## Logistic Coefficients
## Extrae y ordena los coeficientes de un modelo de regresión
## logística para interpretar la influencia de cada variable
## sobre la probabilidad de pertenecer a la clase positiva
## =========================================================
def get_logistic_coefficients(model, X, top_n=None, sort=True):
    """
    Extrae los coeficientes de un modelo de regresión logística.

    Parameters:
    - model: modelo entrenado (sklearn LogisticRegression)
    - X: dataframe de features (para nombres de columnas)
    - top_n: número de variables a mostrar (opcional)
    - sort: ordenar por coeficiente de mayor a menor

    Returns:
    - DataFrame con coeficientes
    """

    if not hasattr(model, "coef_"):
        raise ValueError("El modelo no tiene coeficientes (coef_).")

    coef = pd.DataFrame({
        "Feature": X.columns,
        "Coef": model.coef_[0]
    })

    if sort:
        coef = coef.sort_values(by="Coef", ascending=False)

    if top_n is not None:
        coef = coef.head(top_n)

    return coef

## =========================================================
## Decision Tree Depth Test
## Prueba varios valores de max_depth en un árbol de decisión
## y devuelve un DataFrame con el accuracy en test
## =========================================================

def test_tree_depth(X_train, X_test, y_train, y_test, depths=range(1, 11), random_state=42):
    
    resultados = []
    
    for d in depths:
        model = DecisionTreeClassifier(max_depth=d, random_state=random_state)
        model.fit(X_train, y_train)
        y_pred = model.predict(X_test)
        
        resultados.append({
            "depth": d,
            "accuracy": accuracy_score(y_test, y_pred)
        })
    
    return pd.DataFrame(resultados)

## =========================================================
## Plot Decision Tree
## Grafica un árbol de decisión de forma clara y reutilizable
## =========================================================
def plot_decision_tree(
    model,
    feature_names,
    class_names=None,
    figsize=(22, 12),
    fontsize=10,
    title="Decision Tree",
    filled=True,
    rounded=True,
    precision=2
):
    
    plt.figure(figsize=figsize)
    
    plot_tree(
        model,
        feature_names=feature_names,
        class_names=class_names,
        filled=filled,
        rounded=rounded,
        fontsize=fontsize,
        precision=precision
    )
    
    plt.title(title, fontsize=16)
    plt.tight_layout()
    plt.show()

## =========================================================
## test_tree_depth_regression
## Calcula el numero de nodos que debe tener el arbol con menor rmse
## =========================================================

def test_tree_depth_regression(X_train, X_test, y_train, y_test, depths=range(1, 11)):
    
    resultados = []
    
    for d in depths:
        model = DecisionTreeRegressor(max_depth=d)
        model.fit(X_train, y_train)
        
        y_pred = model.predict(X_test)
        
        rmse = np.sqrt(mean_squared_error(y_test, y_pred))
        
        resultados.append({
            "depth": d,
            "rmse": rmse
        })
    
    return pd.DataFrame(resultados)

## =========================================================
## random forest
## me dice cuales fueron los features importantes dentro bosque
## =========================================================

def feature_importance_df(model, X):
    
    if hasattr(model, "feature_importances_"):
        importances = model.feature_importances_
    
    elif hasattr(model, "coef_"):
        importances = np.abs(model.coef_).flatten()
    
    else:
        raise ValueError("Modelo no soportado")
    
    df = pd.DataFrame({
        'Feature': X.columns,
        'Importance': importances
    }).sort_values(by='Importance', ascending=False)
    
    return df

## =========================================================
## SVM 
## Funcion que permite encontrar el mejor parametro para C y Gamma
## =========================================================

def find_best_svr_params(
    X_train,
    y_train,
    c_values=None,
    gamma_values=None,
    cv=5,
    scoring='r2',
    kernel='rbf',
    verbose=0
):
    """
    Busca los mejores hiperparámetros para un modelo SVR usando GridSearchCV.

    Parámetros
    ----------
    X_train : array-like
        Variables de entrenamiento (ya escaladas si corresponde).
    y_train : array-like
        Target de entrenamiento.
    c_values : list, optional
        Lista de valores para C.
    gamma_values : list, optional
        Lista de valores para gamma.
    cv : int, default=5
        Número de folds para validación cruzada.
    scoring : str, default='r2'
        Métrica de evaluación para GridSearchCV.
    kernel : str, default='rbf'
        Kernel a usar en SVR.
    verbose : int, default=0
        Nivel de detalle de GridSearchCV.

    Retorna
    -------
    dict
        Diccionario con:
        - best_params
        - best_score
        - best_estimator
        - grid_search
    """

    if c_values is None:
        c_values = [0.1, 1, 10, 100]

    if gamma_values is None:
        gamma_values = ['scale', 0.01, 0.1, 1]

    param_grid = {
        'C': c_values,
        'gamma': gamma_values,
        'kernel': [kernel]
    }

    grid = GridSearchCV(
        estimator=SVR(),
        param_grid=param_grid,
        cv=cv,
        scoring=scoring,
        n_jobs=-1,
        verbose=verbose
    )

    grid.fit(X_train, y_train)

    return {
        'best_params': grid.best_params_,
        'best_score': grid.best_score_,
        'best_estimator': grid.best_estimator_,
        'grid_search': grid
    }

## =========================================================
## Red Neuronal Densa - Clasificación Binaria
## Función genérica para construir modelos Keras reutilizables
## Compatible con SciKeras y GridSearchCV
## =========================================================

def build_dense_binary_model(
    input_dim,
    hidden_layers=(12, 8),
    activations="relu",
    output_activation="sigmoid",
    optimizer="adam",
    loss="binary_crossentropy",
    metrics=("accuracy",),
    dropout_rate=None,
):
    """
    Generic dense neural network builder for binary classification.

    Parameters
    ----------
    input_dim : int
        Number of input features.
    hidden_layers : tuple
        Units per hidden layer. Example: (64, 32, 16)
    activations : str or tuple
        Activation for hidden layers. If str, same activation is used for all.
    output_activation : str
        Final layer activation.
    optimizer : str
        Optimizer name.
    loss : str
        Loss function.
    metrics : tuple
        Metrics to monitor.
    dropout_rate : float or None
        Optional dropout applied after each hidden layer.
    """

    model = Sequential()

    if isinstance(activations, str):
        activations = [activations] * len(hidden_layers)

    for i, (units, act) in enumerate(zip(hidden_layers, activations)):
        if i == 0:
            model.add(Dense(units, input_dim=input_dim, activation=act))
        else:
            model.add(Dense(units, activation=act))

        if dropout_rate is not None:
            model.add(Dropout(dropout_rate))

    model.add(Dense(1, activation=output_activation))

    model.compile(
        optimizer=optimizer,
        loss=loss,
        metrics=list(metrics),
    )

    return model