import seaborn as sb
import numpy as np
import shap

from pandas import DataFrame, Series, merge, concat
from matplotlib import pyplot as plt
from sklearn.inspection import permutation_importance
from sklearn.model_selection import learning_curve

from sklearn.pipeline import Pipeline
from sklearn.linear_model import (
    LinearRegression,
    Ridge,
    Lasso,
    ElasticNet,
    SGDClassifier,
    SGDRegressor,
    LogisticRegression,
)
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import RandomForestRegressor
from xgboost import XGBRegressor
from lightgbm import LGBMRegressor
from catboost import CatBoostRegressor

# 성능 평가 지표 모듈
from sklearn.metrics import (
    # 예측 지표
    r2_score,
    mean_absolute_error,
    mean_squared_error,
    mean_squared_log_error,
    mean_absolute_percentage_error,
    # 분류 지표
    log_loss,
    confusion_matrix,
    accuracy_score,
    precision_score,
    recall_score,
    f1_score,
    roc_auc_score,
    roc_curve,
)

from .hs_plot import create_figure, finalize_plot, barplot, lineplot, config


# --------------------------------------------------------
# 회귀 성능 평가 지표 함수
# --------------------------------------------------------
def reg_scores(
    estimator, x_test: DataFrame, y_test: DataFrame | np.ndarray
) -> DataFrame:
    """
    회귀 성능 평가 지표 함수
    수업에서 사용된 hs_get_scores 함수와 동일.

    Args:
        estimator: 학습된 사이킷런 회귀 모델
        x_test: 테스트용 설명변수 데이터 (DataFrame)
        y_test: 실제 목표변수 값 (DataFrame 또는 ndarray)

    Returns:
        DataFrame: 회귀 성능 평가 지표 (R2, MAE, MSE, RMSE, MAPE, MPE)
    """
    if hasattr(estimator, "named_steps"):
        classname = estimator.named_steps["model"].__class__.__name__
    else:
        classname = estimator.__class__.__name__

    y_pred = estimator.predict(x_test)

    score_df = DataFrame(
        {
            "결정계수(R2)": r2_score(y_test, y_pred),
            "평균절대오차(MAE)": mean_absolute_error(y_test, y_pred),
            "평균제곱오차(MSE)": mean_squared_error(y_test, y_pred),
            "평균오차(RMSE)": np.sqrt(mean_squared_error(y_test, y_pred)),
            "평균제곱로그오차(MSLE)": mean_squared_log_error(y_test, y_pred),
            "평균로그오차(RMSLE)": np.sqrt(mean_squared_log_error(y_test, y_pred)),
            "평균 절대 백분오차 비율(MAPE)": mean_absolute_percentage_error(
                y_test, y_pred
            ),
            "평균 비율 오차(MPE)": np.mean((y_test - y_pred) / y_test * 100),
        },
        index=[classname],
    )

    return score_df


# --------------------------------------------------------
# 이진 분류 성능 평가 지표 함수
# --------------------------------------------------------
def cls_bin_scores(
    estimator,
    x_test: DataFrame,
    y_test: DataFrame | Series | np.ndarray,
    threshold: float = 0.5,
    pos_label=None,
    plot: bool = True,
    plot_width: int | float = 640,
) -> DataFrame:
    """
    이진 분류 성능 평가 지표 함수.

    Args:
        estimator: 학습된 사이킷런 이진 분류 모델
        x_test: 테스트용 설명변수 데이터 (DataFrame)
        y_test: 실제 목표변수 값 (DataFrame, Series 또는 ndarray)
        threshold: 양성 클래스 예측 확률 임계값 (기본값: 0.5)
        pos_label: 양성 클래스 레이블 (기본값: None, sklearn 기본 규칙 따름)
        plot: ROC 곡선 그래프 출력 여부 (기본값: True)
        plot_width: ROC 곡선 플롯 너비 (기본값: 640
    Returns:
        DataFrame: 이진 분류 성능 평가 지표 (정확도, 정밀도, 재현율, 위양성율, 특이성, F1 Score, AUC)
    """
    # ---------------------------------
    # 1️⃣ 입력 안정성 검사
    # ---------------------------------
    y_test = np.asarray(y_test).ravel()

    if len(np.unique(y_test)) != 2:
        raise ValueError("이 함수는 이항분류 전용임.")

    if not hasattr(estimator, "predict_proba"):
        raise ValueError("predict_proba 지원 모델만 사용 가능.")

    if not (0 <= threshold <= 1):
        raise ValueError("threshold는 0~1 사이 값이어야 함.")

    classes = list(estimator.classes_)

    if pos_label is None:
        pos_label = classes[1]  # sklearn 기본 규칙

    if pos_label not in classes:
        raise ValueError("pos_label이 클래스에 존재하지 않음.")

    pos_index = classes.index(pos_label)
    neg_label = [c for c in classes if c != pos_label][0]

    # 위,양성 확률
    y_pred_proba = estimator.predict_proba(x_test)
    # 1로 분류될 확률
    y_pred_proba_1 = y_pred_proba[:, pos_index]
    # 예측값
    #y_pred = estimator.predict(x_test)
    y_pred = np.where(y_pred_proba_1 >= threshold, pos_label, neg_label)

    # 혼동행렬
    neg_label = [c for c in classes if c != pos_label][0]
    cm = confusion_matrix(y_test, y_pred, labels=[neg_label, pos_label])

    if cm.shape != (2, 2):
        raise ValueError("혼동행렬이 2x2가 아님.")

    ((TN, FP), (FN, TP)) = cm

    # 클래스 이름
    if hasattr(estimator, "named_steps"):
        classname = estimator.named_steps["model"].__class__.__name__
    else:
        classname = estimator.__class__.__name__

    # auc score
    auc = roc_auc_score(y_test, y_pred_proba_1)

    score_df = DataFrame(
        {
            "정확도(Accuracy)": [accuracy_score(y_test, y_pred)],
            "정밀도(Precision)": [precision_score(y_test, y_pred, pos_label=pos_label, zero_division=0)],
            "재현율(Recall,tpr)": [recall_score(y_test, y_pred, pos_label=pos_label, zero_division=0)],
            "위양성율(Fallout,fpr)": [FP / (TN + FP) if (TN + FP) > 0 else np.nan],
            "특이성(TNR)": [1 - (FP / (TN + FP)) if (TN + FP) > 0 else np.nan],
            "F1 Score": [f1_score(y_test, y_pred, pos_label=pos_label, zero_division=0)],
            "AUC": [auc],
        },
        index=[f"{classname} (thr={threshold:.2f})"]
    )

    # -----------------------------
    # ROC 곡선 그리기
    # -----------------------------
    if plot:
        roc_fpr, roc_tpr, thresholds = roc_curve(y_test, y_pred_proba_1, pos_label=pos_label)

        def __callback(ax):
            sb.lineplot(x=[0, 1], y=[0, 1], color="red", linestyle=":", alpha=0.5)
            plt.fill_between(x=roc_fpr, y1=roc_tpr, alpha=0.1)  # type: ignore
            ax.set_xlabel(
                "위양성율 (False Positive Rate)", fontsize=config.label_font_size
            )
            ax.set_ylabel(
                "재현율 (True Positive Rate)", fontsize=config.label_font_size
            )
            ax.set_xlim(0, 1)
            ax.set_ylim(0, 1)

        lineplot(
            df=None,
            xname=roc_fpr,
            yname=roc_tpr,
            width=plot_width,           # type: ignore
            height=plot_width * 0.9,    # type: ignore
            title=f"ROC Curve (AUC={auc:.4f})",
            callback=__callback,
        )

    return score_df

# --------------------------------------------------------
# 이진 분류 성능 평가 지표 함수
# --------------------------------------------------------
def cls_multi_scores(
    estimator,
    x_test: DataFrame,
    y_test: DataFrame | Series | np.ndarray,
    pos_label=None,
    plot: bool = True,
    plot_width: int | float = 640,
) -> DataFrame:
    pass


# --------------------------------------------------------
# 학습곡선기반 과적합 판별 함수
# --------------------------------------------------------
def learning_cv(
    estimator,
    x,
    y,
    scoring=None,
    cv=5,
    train_sizes=np.linspace(0.1, 1.0, 10),
    n_jobs=-1,
) -> DataFrame:
    """학습곡선 기반 과적합 판별 함수.
    수업에서 사용된 hs_learning_cv 함수와 동일.

    Args:
        estimator: 사이킷런 Estimator (파이프라인 권장)
        x: 설명변수 (DataFrame 또는 ndarray)
        y: 목표변수 (Series 또는 ndarray)
        scoring: 평가 지표
            - 회귀 기본값: neg_root_mean_squared_error
            - 분류 기본값: roc_auc
        cv: 교차검증 폴드 수 (기본값: 5)
        train_sizes: 학습곡선 학습 데이터 비율
        n_jobs: 병렬 처리 개수 (기본값: -1, 모든 CPU 사용)

    Returns:
        DataFrame: 과적합 판별 결과 표
    """

    # -------------------------------------------------
    # 내부 유틸
    # -------------------------------------------------
    def _safe_mean(arr):
        return float(np.mean(arr))

    def _safe_std(arr):
        return float(np.std(arr))

    def _get_metric_name(scoring_value) -> str:
        if isinstance(scoring_value, str):
            return scoring_value.upper()
        if callable(scoring_value):
            return getattr(scoring_value, "__name__", "CUSTOM_SCORE").upper()
        return "SCORE"

    def _get_metric_family(scoring_value) -> str:
        """분류 지표군 판별"""
        if not isinstance(scoring_value, str):
            return "generic"

        s = scoring_value.lower()

        # ranking / probability discrimination
        if s in [
            "roc_auc",
            "roc_auc_ovr",
            "roc_auc_ovo",
            "average_precision",
        ]:
            return "ranking"

        # balanced / harmonic / imbalance-aware
        if s in [
            "f1",
            "f1_macro",
            "f1_micro",
            "f1_weighted",
            "balanced_accuracy",
            "jaccard",
            "jaccard_macro",
            "jaccard_micro",
            "jaccard_weighted",
        ]:
            return "balanced"

        # recall family
        if s in [
            "recall",
            "recall_macro",
            "recall_micro",
            "recall_weighted",
        ]:
            return "recall"

        # precision family
        if s in [
            "precision",
            "precision_macro",
            "precision_micro",
            "precision_weighted",
        ]:
            return "precision"

        # plain accuracy
        if s in ["accuracy"]:
            return "accuracy"

        return "generic"

    def _judge_classification_metric(
        final_train: float,
        final_cv: float,
        final_std: float,
        scoring_value,
    ) -> tuple[str, float, float, str]:
        """분류 지표군별 과적합 판정"""
        family = _get_metric_family(scoring_value)
        gap = final_train - final_cv
        cv_variability = final_std

        if family == "ranking":
            if final_train < 0.70 and final_cv < 0.70:
                status = "⚠️ 과소적합"
            elif gap >= 0.10 and final_train >= 0.80:
                status = "⚠️ 과대적합"
            elif gap <= 0.05 and final_cv >= 0.75 and final_std <= 0.03:
                status = "✅ 일반화 양호"
            elif final_std > 0.05:
                status = "⚠️ 데이터 부족"
            else:
                status = "⚠️ 판단유보"

        elif family == "balanced":
            if final_train < 0.65 and final_cv < 0.65:
                status = "⚠️ 과소적합"
            elif gap >= 0.10 and final_train >= 0.75:
                status = "⚠️ 과대적합"
            elif gap <= 0.05 and final_cv >= 0.70 and final_std <= 0.04:
                status = "✅ 일반화 양호"
            elif final_std > 0.06:
                status = "⚠️ 데이터 부족"
            else:
                status = "⚠️ 판단유보"

        elif family == "recall":
            if final_train < 0.60 and final_cv < 0.60:
                status = "⚠️ 과소적합"
            elif gap >= 0.12 and final_train >= 0.80:
                status = "⚠️ 과대적합"
            elif gap <= 0.07 and final_cv >= 0.70 and final_std <= 0.05:
                status = "✅ 일반화 양호"
            elif final_std > 0.08:
                status = "⚠️ 데이터 부족"
            else:
                status = "⚠️ 판단유보"

        elif family == "precision":
            if final_train < 0.60 and final_cv < 0.60:
                status = "⚠️ 과소적합"
            elif gap >= 0.12 and final_train >= 0.80:
                status = "⚠️ 과대적합"
            elif gap <= 0.07 and final_cv >= 0.70 and final_std <= 0.05:
                status = "✅ 일반화 양호"
            elif final_std > 0.08:
                status = "⚠️ 데이터 부족"
            else:
                status = "⚠️ 판단유보"

        elif family == "accuracy":
            if final_train < 0.65 and final_cv < 0.65:
                status = "⚠️ 과소적합"
            elif gap >= 0.08 and final_train >= 0.80:
                status = "⚠️ 과대적합"
            elif gap <= 0.03 and final_cv >= 0.75 and final_std <= 0.03:
                status = "✅ 일반화 양호"
            elif final_std > 0.05:
                status = "⚠️ 데이터 부족"
            else:
                status = "⚠️ 판단유보"

        else:
            if gap >= 0.10:
                status = "⚠️ 과대적합 의심"
            elif gap <= 0.05 and final_std <= 0.05:
                status = "✅ 일반화 양호"
            elif final_std > 0.08:
                status = "⚠️ 데이터 부족"
            else:
                status = "⚠️ 판단유보"

        return status, gap, cv_variability, family

    # -------------------------------------------------
    # [NEW] 문제 유형 자동 판별
    # -------------------------------------------------
    is_classification = (
        hasattr(estimator, "_estimator_type")
        and estimator._estimator_type == "classifier"
    )

    # -------------------------------------------------
    # [NEW] scoring 자동 설정
    # -------------------------------------------------
    if scoring is None:
        scoring = "roc_auc" if is_classification else "neg_root_mean_squared_error"

    # -------------------------------------------------
    # learning curve 계산
    # -------------------------------------------------
    train_sizes, train_scores, cv_scores = learning_curve(  # type: ignore
        estimator=estimator,
        X=x,
        y=y,
        train_sizes=train_sizes,
        cv=cv,
        scoring=scoring,
        n_jobs=n_jobs,
        shuffle=False,
        random_state=52,
    )

    # -------------------------------------------------
    # 모델명 추출 (Pipeline 대응)
    # -------------------------------------------------
    if hasattr(estimator, "named_steps"):
        classname = estimator.named_steps["model"].__class__.__name__
    else:
        classname = estimator.__class__.__name__

    # =================================================
    # 회귀 전용 처리 (기존 코드)
    # =================================================
    if not is_classification:

        # neg RMSE → RMSE
        train_rmse = -train_scores
        cv_rmse = -cv_scores

        # 평균 / 표준편차
        train_mean = train_rmse.mean(axis=1)
        cv_mean = cv_rmse.mean(axis=1)
        cv_std = cv_rmse.std(axis=1)

        # 마지막 지점 기준 정량 판정
        final_train = float(train_mean[-1])
        final_cv = float(cv_mean[-1])
        final_std = float(cv_std[-1])

        gap_ratio = final_train / final_cv if final_cv != 0 else np.nan
        var_ratio = final_std / final_cv if final_cv != 0 else np.nan

        # -----------------
        # 과소적합 기준선
        # -----------------
        y_mean = float(np.mean(y_arr))
        rmse_naive = float(np.sqrt(np.mean((y_arr - y_mean) ** 2)))

        std_y = float(np.std(y_arr))

        min_r2 = 0.10
        rmse_r2 = float(np.sqrt((1 - min_r2) * np.var(y_arr)))

        some_threshold = min(rmse_naive, std_y, rmse_r2)

        # -----------------
        # 판정 로직
        # -----------------
        if len(train_mean) >= 2:
            train_slope = float(train_mean[-1] - train_mean[-2])
            cv_slope = float(cv_mean[-1] - cv_mean[-2])
        else:
            train_slope = 0.0
            cv_slope = 0.0

        if gap_ratio >= 0.95 and final_cv > some_threshold:
            status = "⚠️ 과소적합"
        elif gap_ratio <= 0.80 and train_slope > 0 and cv_slope < 0:
            status = "⚠️ 데이터 추가시 일반화 기대"
        elif gap_ratio <= 0.80:
            status = "⚠️ 과대적합"
        elif gap_ratio <= 0.95 and var_ratio <= 0.10:
            status = "✅ 일반화 양호"
        elif var_ratio > 0.15:
            status = "⚠️ 데이터 부족"
        else:
            status = "⚠️ 판단유보"

        metric_name = "RMSE"

        result_df = DataFrame(
            {
                f"Train {metric_name}": [final_train],
                f"CV {metric_name} 평균": [final_cv],
                f"CV {metric_name} 표준편차": [final_std],
                "Train/CV 비율": [gap_ratio],
                "CV 변동성 비율": [var_ratio],
                "판정 결과": [status],
            },
            index=[classname],
        )

    # =================================================
    # 분류 전용 처리
    # =================================================
    else:
        # [NEW] 분류는 그대로 사용 (AUC, Accuracy 등)
        train_metric = train_scores
        cv_metric = cv_scores

        train_mean = train_metric.mean(axis=1)
        cv_mean = cv_metric.mean(axis=1)
        cv_std = cv_metric.std(axis=1)

        # 최근 3개 지점 평균 사용
        tail_n = min(3, len(train_mean))

        final_train = _safe_mean(train_mean[-tail_n:])
        final_cv = _safe_mean(cv_mean[-tail_n:])
        final_std = _safe_mean(cv_std[-tail_n:])

        status, generalization_gap, cv_variability, metric_family = (
            _judge_classification_metric(
                final_train=final_train,
                final_cv=final_cv,
                final_std=final_std,
                scoring_value=scoring,
            )
        )

        # [NEW] 분류용 비율 정의 (차이 기반)
        gap_ratio = final_train - final_cv
        var_ratio = final_std

        metric_name = _get_metric_name(scoring)

        result_df = DataFrame(
            {
                f"Train {metric_name}": [final_train],
                f"CV {metric_name} 평균": [final_cv],
                f"CV {metric_name} 표준편차": [final_std],
                "일반화 격차(Train-CV)": [generalization_gap],
                "CV 변동성": [cv_variability],
                "지표군": [metric_family],
                "판정 결과": [status],
            },
            index=[classname],
        )

    # -----------------
    # 학습곡선 시각화
    # -----------------
    def __callback(ax):
        sb.lineplot(
            x=train_sizes,
            y=cv_mean,
            marker="o",
            linewidth=config.line_width,
            markeredgecolor="#ffffff",
            label=f"CV {metric_name}",
        )

        ax.set_xlabel(
            "학습 데이터 비율", fontsize=config.label_font_size
        )  # type : ignore
        ax.set_ylabel(metric_name, fontsize=config.label_font_size)  # type : ignore

    lineplot(
        df=None,
        xname=train_sizes,
        yname=train_mean,
        marker="o",
        markeredgecolor="#ffffff",
        label=f"Train {metric_name}",
        title=f"{classname} 학습곡선 (Learning Curve)",
        callback=__callback,
    )

    return result_df


# --------------------------------------------------------
# 회귀 성능 평가 + 과적합 판별 통합 함수
# --------------------------------------------------------
def score_cv(
    estimator,
    x_test: DataFrame,
    y_test: DataFrame | np.ndarray,
    x_origin: DataFrame,
    y_origin: DataFrame | np.ndarray,
    scoring="neg_root_mean_squared_error",
    cv=5,
    train_sizes=np.linspace(0.1, 1.0, 10),
    n_jobs=-1,
) -> DataFrame:
    """
    회귀 성능 평가 지표 함수.
    수업에서 사용된 hs_get_score_cv 함수와 동일.

    Args:
        estimator: 학습된 사이킷런 회귀 모델
        x_test: 테스트용 설명변수 데이터 (DataFrame)
        y_test: 실제 목표변수 값 (DataFrame 또는 ndarray)
        x_origin: 학습곡선용 전체 설명변수 데이터 (DataFrame, learning_curve=True일 때 필요)
        y_origin: 학습곡선용 전체 목표변수 값 (DataFrame 또는 ndarray, learning_curve=True일 때 필요)
        scoring: 학습곡선 평가 지표 (기본값: neg_root_mean_squared_error)
        cv: 학습곡선 교차검증 폴드 수 (기본값: 5)
        train_sizes: 학습곡선 학습 데이터 비율 (기본값: np.linspace(0.1, 1.0, 10))
        n_jobs: 학습곡선 병렬 처리 개수 (기본값: -1, 모든 CPU 사용)

    Returns:
        DataFrame: 회귀 성능 평가 지표 + 과적합 판정 여부
    """

    if type(estimator) != list:
        estimator = [estimator]

    result_df = DataFrame()

    for est in estimator:
        score_df = reg_scores(est, x_test, y_test)
        cv_df = learning_cv(
            est,
            x_origin,
            y_origin,
            scoring=scoring,
            cv=cv,
            train_sizes=train_sizes,
            n_jobs=n_jobs,
        )

        result_df = concat(
            [result_df, merge(score_df, cv_df, left_index=True, right_index=True)]
        )

    return result_df


# --------------------------------------------------------
# 특징 중요도 분석 함수
# --------------------------------------------------------
def feature_importance(
    model,
    x_train: DataFrame,
    y_train: DataFrame | np.ndarray | Series,
    threshold: float = 0.9,
    plot: bool = True,
    width: int = config.width,
) -> DataFrame:
    """
    특징 중요도 분석 함수.
    수업에서 사용된 hs_feature_importance 함수와 동일.

    Args:
        model: 학습된 사이킷런 회귀 모델
        x_train: 학습용 설명변수 데이터 (DataFrame)
        y_train: 학습용 목표변수 값 (DataFrame, Series 또는 ndarray)
        threshold: 누적 중요도 기준 (기본값: 0.9)
        plot: 중요도 시각화 여부 (기본값: True)
        width: 플롯 너비 (기본값: config.width)

    Returns:
        DataFrame: 특징 중요도 결과 표
    """
    if isinstance(model, XGBRegressor):  # type: ignore
        booster = model.get_booster()  # type: ignore
        imp = booster.get_score(importance_type="gain")
        imp_sr = Series(imp)
        imp_df = DataFrame(imp_sr, columns=["importance"])
    elif isinstance(model, LGBMRegressor):
        booster = model.booster_
        imp = booster.feature_importance(importance_type="gain")
        imp_df = DataFrame({"importance": imp}, index=model.feature_name_)
    elif isinstance(model, CatBoostRegressor):
        imp = model.get_feature_importance(type="FeatureImportance")
        imp_df = DataFrame({"importance": imp}, index=model.feature_names_)
    else:
        imp_df = permutation_importance(
            estimator=model,
            X=x_train,
            y=y_train,
            scoring="r2",
            n_repeats=30,
            random_state=42,
            n_jobs=-1,
        )

        # 결과 정리
        imp_df = DataFrame({"importance": imp_df.importances_mean}, index=x_train.columns)  # type: ignore

    # ----------------------------------
    # 중요도 비율 + 누적 중요도 계산
    # ----------------------------------
    imp_df["ratio"] = imp_df["importance"] / imp_df["importance"].sum()
    imp_df.sort_values("ratio", ascending=False, inplace=True)
    imp_df["cumsum"] = imp_df["ratio"].cumsum()

    # ----------------------------------
    # 시각화
    # ----------------------------------
    if plot:
        df = imp_df.sort_values(by="ratio", ascending=False)

        # 90% 처음 도달하는 인덱스 (0-based)
        cut_idx = np.argmax(imp_df["cumsum"].values >= threshold)  # type: ignore

        # x축은 rank 기준이므로 +1
        cut_rank = (int(cut_idx) + 1) - 0.5

        def __callback(ax):
            # 값 라벨 추가
            for i, v in enumerate(imp_df["importance"]):
                ax.text(
                    v + 0.005,  # 막대 끝에서 약간 오른쪽
                    i,  # y 위치
                    # 표시 형식
                    f"{v:.1f} ({imp_df.iloc[i]['cumsum']*100:.1f}%)",
                    va="center",
                    fontsize=config.text_font_size,
                )

            ax.set_xlabel("importance(cumsum)", fontsize=config.label_font_size)
            ax.set_xlim(0, imp_df["importance"].max() * 1.2)
            ax.set_ylabel(None)

            # 90% 도달 지점 수직선 (핵심)
            plt.axhline(y=cut_rank, linestyle=":", color="red", alpha=0.8)

        barplot(
            df,
            xname="importance",
            yname=df.index,
            width=width,
            height=len(df) * 60,
            title=f"Feature Importance",  # type: ignore
            callback=__callback,
        )

    return imp_df


# --------------------------------------------------------
# SHAP 값 기반 특징 중요도 분석 함수
# --------------------------------------------------------
def shap_analysis(
    model,
    x: DataFrame,
    plot: bool = True,
    width: int = config.width,
) -> tuple[DataFrame, np.ndarray]:
    """
    SHAP 값 기반 특징 중요도 분석 함수.
    수업에서 사용된 hs_shap_analysis 함수와 동일.

    Args:
        model: 학습된 사이킷런 회귀 모델
        x: 설명변수 데이터 (DataFrame)
        plot: SHAP 요약 플롯 시각화 여부 (기본값: True
        width: 플롯 너비 (기본값: config.width)

    Returns:
        tuple: 특징 중요도 요약 DataFrame 및 SHAP 값 배열
    """
    # =================================================
    # SHAP 유형 판별
    # =================================================
    # Pipeline 라인 객체와 그렇지 않은 경우 모두 처리
    if isinstance(model, Pipeline):
        estimator = model.named_steps["model"]
        is_pipeline = True
    else:
        estimator = model
        is_pipeline = False

    # 선형 계열인지 아닌지 판별
    is_linear_model = isinstance(
        estimator,
        (
            LinearRegression,
            Ridge,
            Lasso,
            ElasticNet,
            SGDRegressor,
            LogisticRegression,
            SGDClassifier,
        ),
    )

    # 전달된 독립변수 복사
    x_df = x.copy()
    columns = x.columns.tolist()
    indexs = x.index.tolist()

    # 1. SHAP Explainer
    if is_linear_model:
        # pipeline인 경우 파이프라인의 마지막 step을 제외하고 순환하면서 데이터 변환 적용
        if is_pipeline:
            for name, step in list(model.named_steps.items()):
                if name == "model":
                    continue

                x_df = step.transform(x_df)  # type: ignore

        masker = shap.maskers.Independent(x_df)
        explainer = shap.LinearExplainer(estimator, masker=masker)
    else:
        explainer = shap.TreeExplainer(estimator)

    # 2. SHAP 값 계산: shape = [n_samples, n_features]
    shap_values = explainer.shap_values(x_df)

    # 3. DataFrame 변환
    shap_df = DataFrame(
        shap_values,
        columns=columns,
        index=indexs,
    )

    # 4. 요약 통계
    summary_df = DataFrame(
        {
            "feature": shap_df.columns,
            "mean_abs_shap": shap_df.abs().mean().values,
            "mean_shap": shap_df.mean().values,
            "std_shap": shap_df.std().values,
        }
    )

    # 5. 영향 방향 (보수적 표현)
    summary_df["direction"] = np.where(
        summary_df["mean_shap"] > 0,
        "양(+) 경향",
        np.where(summary_df["mean_shap"] < 0, "음(-) 경향", "혼합/미약"),
    )

    # 6. 변동성 지표
    summary_df["cv"] = summary_df["std_shap"] / (summary_df["mean_abs_shap"] + 1e-9)

    summary_df["variability"] = np.where(
        summary_df["cv"] < 1,
        "stable",  # 변동성 낮음 - 평균 대비 일관적 영향 의미
        "variable",  # 변동성 큼 - 상황 의존적 영향 의미
    )

    # 7. 중요도 기준 정렬
    summary_df = summary_df.sort_values("mean_abs_shap", ascending=False).reset_index(
        drop=True
    )

    # 8. 중요 변수 표시 (누적 80%)
    total_importance = summary_df["mean_abs_shap"].sum()
    summary_df["importance_ratio"] = summary_df["mean_abs_shap"] / total_importance
    summary_df["importance_cumsum"] = summary_df["importance_ratio"].cumsum()

    summary_df["is_important"] = np.where(
        summary_df["importance_cumsum"] <= 0.80,
        "core",  # 누적 80% 내 중요 변수 - 모델 핵심 결정 요인 의미 명확
        "secondary",  # 누적 80% 초과 변수 - 보조적/상황적 영향 요인 의미
    )

    # 9. 시각화
    if plot:
        shap.summary_plot(shap_values, x, show=False)

        fig = plt.gcf()
        fig.set_size_inches(width / 100, len(summary_df) * 60 / 100)
        fig.set_dpi(config.dpi)
        ax = fig.get_axes()[0]

        plt.xlabel("SHAP value", fontsize=config.label_font_size)
        plt.xticks(fontsize=config.font_size)
        plt.yticks(fontsize=config.font_size)

        finalize_plot(ax, outparams=True, title="SHAP Summary Plot")

    return summary_df, shap_values


# --------------------------------------------------------
# SHAP 값 기반 특징 의존성 분석 함수
# --------------------------------------------------------


def shap_dependence_analysis(
    summary_df: DataFrame,
    shap_values: np.ndarray,
    x: DataFrame,
    include_secondary: bool = False,
    width: int = config.width,
    height: int = config.height,
):
    """
    SHAP 값 기반 특징 의존성 분석 함수
    수업에서 사용된 hs_shap_dependence_analysis 함수와 동일.

    Args:
        summary_df: shap_analysis 함수의 요약 DataFrame
        shap_values: shap_analysis 함수의 SHAP 값 배열
        x: 설명변수 데이터 (DataFrame)
        include_secondary: 상호작용 변수에 보조 변수 포함 여부 (기본값: False)
        width: 플롯 너비 (기본값: config.width)
        height: 플롯 높이 (기본값: config.height)

    Returns:
        list: 생성된 특징 상호작용 쌍 목록
    """

    # 1. 주 대상 변수 (Core + Variable)
    main_features = summary_df[
        (summary_df["is_important"] == "core")
        & (summary_df["variability"] == "variable")
    ]["feature"].tolist()

    # 2. 상호작용 후보 변수
    interaction_features = summary_df[summary_df["is_important"] == "core"][
        "feature"
    ].tolist()

    if include_secondary and len(interaction_features) < 2:
        interaction_features.extend(
            summary_df[summary_df["is_important"] == "secondary"]["feature"].tolist()
        )

    # 3. 변수 쌍 생성 (자기 자신 제외)
    pairs = []

    for f in main_features:
        for inter in interaction_features:
            # 자기 자신과의 조합은 제외
            if f != inter:
                pairs.append((f, inter))

    # paris를 역순으로 순회하며 중복 제거
    seen = set()
    for i in range(len(pairs) - 1, -1, -1):
        key = frozenset(pairs[i])
        if key in seen:
            del pairs[i]
        else:
            seen.add(key)

    # 중요도 순 정렬 (주 변수 기준)
    importance_rank = {}

    for i, row in summary_df.iterrows():
        importance_rank[row["feature"]] = i

    pairs = sorted(pairs, key=lambda k: importance_rank.get(k[0], 999))

    # 4. dependence plot 일괄 생성
    for feature_name, interaction_name in pairs:
        shap.dependence_plot(
            feature_name,
            shap_values,
            x,
            interaction_index=interaction_name,
            show=False,
        )

        # SHAP figure 직접 제어
        fig = plt.gcf()
        fig.set_size_inches(width / 100, height / 100)
        fig.set_dpi(config.dpi)
        ax = fig.get_axes()[0]

        plt.xlabel(feature_name, fontsize=config.label_font_size)
        plt.ylabel(f"SHAP value for {feature_name}", fontsize=config.label_font_size)
        plt.xticks(fontsize=config.font_size)
        plt.yticks(fontsize=config.font_size)

        finalize_plot(
            ax,
            outparams=True,
            title=f"SHAP Dependence Plot: {feature_name} vs {interaction_name}",
        )

    return pairs
