# Notebook Title: Outlier Detection & Handling
# Author: Advanced Analytics Team
# Date: 2025-11-13
# Purpose: Framework deteksi, penanganan, dan evaluasi outlier (CS, TS, Panel)

from __future__ import annotations

# Standard Libraries
from abc import ABC, abstractmethod
from typing import Dict, List, Union

# Data & Stats
import numpy as np
import pandas as pd
from scipy import stats

# Visualization
import matplotlib.pyplot as plt
import seaborn as sns


# ===================================================================
# Bagian 1: Strategi Deteksi Outlier
# ===================================================================


class BaseDetectionStrategy(ABC):
    """Kelas dasar abstrak untuk semua strategi deteksi outlier."""

    @abstractmethod
    def fit(self, series: pd.Series):
        """Hitung dan kembalikan batas bawah dan atas dari data."""
        raise NotImplementedError


class IQRStrategy(BaseDetectionStrategy):
    """Deteksi outlier menggunakan Interquartile Range (IQR)."""

    def __init__(self, factor: float = 1.5):
        """Inisialisasi strategi dengan faktor pengali IQR."""
        self.factor = factor

    def fit(self, series: pd.Series):
        """Hitung batas outlier berdasarkan Q1, Q3, dan faktor IQR."""
        q1 = series.quantile(0.25)
        q3 = series.quantile(0.75)
        iqr = q3 - q1
        return q1 - (self.factor * iqr), q3 + (self.factor * iqr)


class ZScoreStrategy(BaseDetectionStrategy):
    """Deteksi outlier menggunakan Z-Score."""

    def __init__(self, factor: float = 3.0):
        """Inisialisasi dengan ambang batas standar deviasi."""
        self.factor = factor

    def fit(self, series: pd.Series):
        """Hitung batas outlier berdasarkan mean dan standar deviasi."""
        mean = series.mean()
        std = series.std()
        return mean - (self.factor * std), mean + (self.factor * std)


class RollingIQRStrategy(BaseDetectionStrategy):
    """Deteksi outlier time series menggunakan Rolling IQR."""

    def __init__(self, window: int = 20, factor: float = 1.5):
        """Inisialisasi dengan window dan faktor pengali IQR."""
        self.window = window
        self.factor = factor

    def fit(self, series: pd.Series):
        """Hitung batas outlier dinamis (rolling) per titik data."""
        q1 = series.rolling(window=self.window, min_periods=1).quantile(0.25)
        q3 = series.rolling(window=self.window, min_periods=1).quantile(0.75)
        iqr = q3 - q1
        return q1 - (self.factor * iqr), q3 + (self.factor * iqr)


# ===================================================================
# Bagian 2: Strategi Penanganan Outlier
# ===================================================================


class BaseHandlingStrategy(ABC):
    """Kelas dasar abstrak untuk strategi penanganan outlier."""

    @abstractmethod
    def handle(self, series: pd.Series, lower_bound, upper_bound):
        """Tangani outlier dan kembalikan series yang sudah dibersihkan."""
        raise NotImplementedError


class CappingStrategy(BaseHandlingStrategy):
    """Penanganan outlier dengan mengganti nilai di luar batas (capping)."""

    def handle(self, series: pd.Series, lower_bound, upper_bound):
        """Clip nilai di bawah/atas batas ke nilai batasnya."""
        return series.clip(lower_bound, upper_bound)


class ImputationStrategy(BaseHandlingStrategy):
    """Penanganan outlier dengan mengganti nilai memakai median/mean."""

    def __init__(self, strategy: str = "median"):
        """Inisialisasi dengan pilihan 'median' atau 'mean'."""
        if strategy not in ["median", "mean"]:
            raise ValueError("Strategy harus 'median' atau 'mean'")
        self.strategy = strategy

    def handle(self, series: pd.Series, lower_bound, upper_bound):
        """Ganti outlier dengan median/mean dari data non-outlier."""
        non_outliers = series[(series >= lower_bound) & (series <= upper_bound)]
        impute_value = (
            non_outliers.median()
            if self.strategy == "median"
            else non_outliers.mean()
        )
        series_handled = series.copy()
        outliers_mask = (series < lower_bound) | (series > upper_bound)
        series_handled[outliers_mask] = impute_value
        return series_handled


class LOCFStrategyCS(BaseHandlingStrategy):
    """Penanganan outlier dengan LOCF (Last Observation Carried Forward)."""

    def __init__(self, groupby=None):
        """Opsional groupby untuk panel, None untuk seri tunggal."""
        self.groupby = groupby

    def handle(
        self,
        series: pd.Series,
        lower_bound,
        upper_bound,
        df: pd.DataFrame | None = None,
    ):
        """Tandai outlier sebagai NaN lalu isi dengan forward-fill."""
        s = series.copy()
        s[(series < lower_bound) | (series > upper_bound)] = None

        if (
            self.groupby is not None
            and df is not None
            and self.groupby in df.columns
        ):
            s = (
                df.assign(_tmp=s)
                .groupby(self.groupby, group_keys=False)["_tmp"]
                .ffill()
            )
        else:
            s = s.ffill()

        return s


# Backward-compatible alias (gaya lama)
LOCFStrategy_CS = LOCFStrategyCS


# ===================================================================
# Bagian 3: Orkestrator Utama Outlier Handling
# ===================================================================


class BaseOutlierProcessor(ABC):
    """Kelas dasar abstrak untuk semua jenis prosesor outlier."""

    def __init__(
        self,
        detection_strategy: BaseDetectionStrategy,
        handling_strategy: BaseHandlingStrategy,
    ):
        """Inisialisasi dengan strategi deteksi dan penanganan."""
        self.detection_strategy = detection_strategy
        self.handling_strategy = handling_strategy
        self._bounds: Dict = {}
        self.numeric_cols_: List[str] | None = None
        self.fitted_: bool = False

    @abstractmethod
    def fit(self, X: pd.DataFrame, y=None):
        """Pelajari batas outlier dari data."""
        raise NotImplementedError

    @abstractmethod
    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Transform data berdasarkan batas yang sudah dipelajari."""
        raise NotImplementedError

    def fit_transform(self, X: pd.DataFrame, y=None) -> pd.DataFrame:
        """Jalankan fit lalu transform pada data yang sama."""
        return self.fit(X, y).transform(X)


class CrossSectionalProcessor(BaseOutlierProcessor):
    """Prosesor untuk menangani data cross-section."""

    def fit(self, X: pd.DataFrame, y=None):
        """Pelajari batas outlier untuk tiap kolom numerik."""
        self.numeric_cols_ = X.select_dtypes(include=np.number).columns.tolist()
        for col in self.numeric_cols_:
            self._bounds[col] = self.detection_strategy.fit(X[col])
        self.fitted_ = True
        return self

    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Transform tiap kolom numerik menggunakan batas yang dipelajari."""
        if not self.fitted_:
            raise RuntimeError("Transform called before fit.")
        X_transformed = X.copy()
        for col in self.numeric_cols_:
            lower, upper = self._bounds[col]
            X_transformed[col] = self.handling_strategy.handle(
                X_transformed[col],
                lower,
                upper,
            )
        return X_transformed


class TimeSeriesProcessor(BaseOutlierProcessor):
    """Prosesor khusus data time series dengan kolom tanggal."""

    def __init__(
        self,
        detection_strategy: BaseDetectionStrategy,
        handling_strategy: BaseHandlingStrategy,
        date_col: str,
    ):
        """Inisialisasi dengan strategi dan nama kolom tanggal."""
        super().__init__(detection_strategy, handling_strategy)
        self.date_col = date_col

    def _prepare_dataframe(self, X: pd.DataFrame) -> pd.DataFrame:
        """Konversi kolom tanggal jadi index dan sort berdasarkan waktu."""
        df = X.copy()
        if self.date_col not in df.columns:
            raise ValueError(
                f"Kolom tanggal '{self.date_col}' tidak ditemukan di DataFrame.",
            )
        df = df.dropna(subset=[self.date_col])
        return df.set_index(self.date_col).sort_index()

    def fit(self, X: pd.DataFrame, y=None):
        """Pelajari batas outlier setelah data disiapkan sebagai time series."""
        df_prepared = self._prepare_dataframe(X)
        self.numeric_cols_ = (
            df_prepared.select_dtypes(include=np.number).columns.tolist()
        )
        for col in self.numeric_cols_:
            self._bounds[col] = self.detection_strategy.fit(df_prepared[col])
        self.fitted_ = True
        return self

    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Transform data time series berdasarkan batas yang dipelajari."""
        if not self.fitted_:
            raise RuntimeError("Transform called before fit.")
        df_prepared = self._prepare_dataframe(X)
        X_transformed = df_prepared.copy()
        for col in self.numeric_cols_:
            lower, upper = self._bounds[col]
            X_transformed[col] = self.handling_strategy.handle(
                X_transformed[col],
                lower,
                upper,
            )
        return X_transformed.reset_index()


class PanelProcessor(BaseOutlierProcessor):
    """Prosesor untuk data panel dengan group_by dan kolom tanggal."""

    def __init__(
        self,
        detection_strategy: BaseDetectionStrategy,
        handling_strategy: BaseHandlingStrategy,
        group_by_col: Union[str, List[str]],
        date_col: str,
    ):
        """Inisialisasi dengan strategi, kolom grup, dan kolom tanggal."""
        super().__init__(detection_strategy, handling_strategy)
        self.group_by_col = group_by_col
        self.date_col = date_col

    def fit(self, X: pd.DataFrame, y=None):
        """Pelajari batas outlier per grup, diurutkan berdasarkan waktu."""
        self.numeric_cols_ = X.select_dtypes(include=np.number).columns.tolist()
        cols_to_group = (
            [self.group_by_col]
            if isinstance(self.group_by_col, str)
            else self.group_by_col
        )
        X_sorted = X.sort_values(by=cols_to_group + [self.date_col])
        for name, group in X_sorted.groupby(cols_to_group):
            for col in self.numeric_cols_:
                self._bounds[(name, col)] = self.detection_strategy.fit(group[col])
        self.fitted_ = True
        return self

    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Transform tiap grup berdasarkan batas outlier yang relevan."""
        if not self.fitted_:
            raise RuntimeError("Transform called before fit.")
        cols_to_group = (
            [self.group_by_col]
            if isinstance(self.group_by_col, str)
            else self.group_by_col
        )
        handled_groups: List[pd.DataFrame] = []
        for group_name, group_df in X.groupby(cols_to_group):
            handled_group = group_df.copy()
            for col in self.numeric_cols_:
                key = (group_name, col)
                if key in self._bounds:
                    lower, upper = self._bounds[key]
                    handled_group[col] = self.handling_strategy.handle(
                        handled_group[col],
                        lower,
                        upper,
                    )
            handled_groups.append(handled_group)
        return pd.concat(handled_groups).sort_index()


# ===================================================================
# Bagian 4: Strategi-strategi Evaluasi
# ===================================================================


class BaseEvaluationStrategy(ABC):
    """Kelas dasar abstrak untuk strategi evaluasi."""

    @abstractmethod
    def evaluate(
        self,
        df_before: pd.DataFrame,
        df_after: pd.DataFrame,
        *args,
        **kwargs,
    ):
        """Jalankan logika evaluasi dan kembalikan hasilnya."""
        raise NotImplementedError


class BoxplotStrategy(BaseEvaluationStrategy):
    """Evaluasi visual dengan perbandingan boxplot sebelum & sesudah."""

    def evaluate(
        self,
        df_before: pd.DataFrame,
        df_after: pd.DataFrame,
        df_after2: pd.DataFrame | None = None,
        df_after3: pd.DataFrame | None = None,
        treatement_amount: int = 1,
    ):
        """Buat satu subplot per variabel untuk membandingkan distribusi."""
        numeric_cols = df_before.select_dtypes(include=np.number).columns
        if not numeric_cols.any():
            return None

        n_cols = len(numeric_cols)
        subplot_cols = min(n_cols, 4)
        subplot_rows = (n_cols - 1) // subplot_cols + 1

        fig, axes = plt.subplots(
            subplot_rows,
            subplot_cols,
            figsize=(subplot_cols * 5, subplot_rows * 4.5),
            squeeze=False,
        )
        fig.suptitle(
            "Perbandingan Box Plot Sebelum & Sesudah Penanganan Outlier",
            fontsize=16,
        )

        axes = axes.flatten()

        for i, col in enumerate(numeric_cols):
            ax = axes[i]

            data_before = pd.DataFrame(
                {"value": df_before[col], "Status": "Sebelum"},
            )
            data_after = pd.DataFrame(
                {"value": df_after[col], "Status": "Sesudah"},
            )

            combined_data_list = [data_before, data_after]

            if int(treatement_amount) >= 2 and df_after2 is not None:
                data_after2 = pd.DataFrame(
                    {"value": df_after2[col], "Status": "Treatment 2"},
                )
                combined_data_list.append(data_after2)

            if int(treatement_amount) == 3 and df_after3 is not None:
                data_after3 = pd.DataFrame(
                    {"value": df_after3[col], "Status": "Treatment 3"},
                )
                combined_data_list.append(data_after3)

            combined_data = pd.concat(combined_data_list, ignore_index=True)

            palette = {
                "Sebelum": "#1f77b4",
                "Sesudah": "lightgreen",
                "Treatment 2": "#DD0303",
                "Treatment 3": "#134686",
            }

            sns.boxplot(
                x="Status",
                y="value",
                data=combined_data,
                ax=ax,
                palette=palette,
            )

            ax.set_title(f"Variabel: {col}")
            ax.set_xlabel("")
            ax.set_ylabel("Nilai")

        for j in range(i + 1, len(axes)):
            fig.delaxes(axes[j])

        plt.tight_layout(rect=[0, 0, 1, 0.96])
        return fig


class StatisticalSummaryStrategy(BaseEvaluationStrategy):
    """Evaluasi via ringkasan statistik deskriptif."""

    def evaluate(
        self,
        df_before: pd.DataFrame,
        df_after: pd.DataFrame,
    ):
        """Bandingkan statistik deskriptif sebelum dan sesudah."""
        def safe_describe(df: pd.DataFrame) -> pd.DataFrame:
            """Describe aman hanya untuk kolom numerik."""
            numeric_df = df.select_dtypes(include=np.number)
            if numeric_df.empty:
                return pd.DataFrame()
            return (
                numeric_df.describe()
                .transpose()
                .reset_index()
                .rename(columns={"index": "Variable"})
            )

        summary_before = safe_describe(df_before)
        summary_after = safe_describe(df_after)
        return summary_before, summary_after


class CrossSectionalTestStrategy(BaseEvaluationStrategy):
    """Evaluasi cross-section menggunakan uji statistik per kolom."""

    def __init__(self, test: "BaseStatTestStrategy"):
        """Inisialisasi dengan objek uji statistik yang dipilih."""
        self.test = test

    def evaluate(
        self,
        df_before: pd.DataFrame,
        df_after: pd.DataFrame,
    ) -> pd.DataFrame:
        """Jalankan uji statistik pada tiap kolom numerik."""
        numeric_cols = df_before.select_dtypes(include=np.number).columns
        results: Dict[str, float] = {}
        for col in numeric_cols:
            results[col] = self.test.run_test(df_before[col], df_after[col])
        return pd.DataFrame.from_dict(
            results,
            orient="index",
            columns=[self.test.name],
        )


class TimeSeriesTestStrategy(BaseEvaluationStrategy):
    """Evaluasi time series menggunakan uji statistik berpasangan."""

    def __init__(self, test: "BaseStatTestStrategy"):
        """Inisialisasi dengan objek uji statistik yang dipilih."""
        self.test = test

    def evaluate(
        self,
        df_before: pd.DataFrame,
        df_after: pd.DataFrame,
    ) -> pd.DataFrame:
        """Jalankan uji statistik time series pada tiap kolom."""
        numeric_cols = df_before.select_dtypes(include=np.number).columns
        results: Dict[str, float] = {}
        for col in numeric_cols:
            results[col] = self.test.run_test(df_before[col], df_after[col])
        return pd.DataFrame.from_dict(
            results,
            orient="index",
            columns=[self.test.name],
        )


class PanelTestStrategy(BaseEvaluationStrategy):
    """Evaluasi panel per grup menggunakan uji statistik."""

    def __init__(self, group_col: str, test: "BaseStatTestStrategy"):
        """Inisialisasi dengan kolom grup dan objek uji statistik."""
        self.group_col = group_col
        self.test = test

    def evaluate(
        self,
        df_before: pd.DataFrame,
        df_after: pd.DataFrame,
    ) -> pd.DataFrame:
        """Jalankan uji statistik per grup dan per kolom numerik."""
        results: Dict[str, Dict[str, float]] = {}
        if (
            self.group_col not in df_before.columns
            or self.group_col not in df_after.columns
        ):
            raise ValueError(
                f"Kolom grup '{self.group_col}' tidak ditemukan di salah satu DataFrame.",
            )

        for name, group_b in df_before.groupby(self.group_col):
            try:
                group_a = df_after.groupby(self.group_col).get_group(name)
                for col in group_b.select_dtypes(include=np.number).columns:
                    if col not in results:
                        results[col] = {}
                    results[col][name] = self.test.run_test(
                        group_b[col],
                        group_a[col],
                    )
            except KeyError:
                # print(
                #     f"Peringatan: Grup '{name}' tidak ditemukan di data 'setelah'. Dilewati.",
                # )
                pass
        return pd.DataFrame.from_dict(results)


class BaseStatTestStrategy(ABC):
    """Kelas dasar abstrak untuk membungkus fungsi uji statistik."""

    @property
    @abstractmethod
    def name(self) -> str:
        """Nama uji statistik (untuk kolom hasil)."""
        raise NotImplementedError

    @abstractmethod
    def run_test(
        self,
        data_before: pd.Series,
        data_after: pd.Series,
    ) -> float:
        """Jalankan uji statistik dan kembalikan p-value."""
        raise NotImplementedError


class KolmogorovSmirnovTest(BaseStatTestStrategy):
    """Bungkus uji Kolmogorov-Smirnov dua sampel."""

    @property
    def name(self) -> str:
        """Nama kolom: 'KS_p_value'."""
        return "KS_p_value"

    def run_test(
        self,
        data_before: pd.Series,
        data_after: pd.Series,
    ) -> float:
        """Jalankan uji KS dua sampel dan kembalikan p-value."""
        if data_before.empty or data_after.empty:
            return np.nan
        return stats.ks_2samp(
            data_before.dropna(),
            data_after.dropna(),
        )[1]


class LeveneTest(BaseStatTestStrategy):
    """Bungkus uji Levene untuk varian dua sampel."""

    @property
    def name(self) -> str:
        """Nama kolom: 'Levene_p_value_variance'."""
        return "Levene_p_value_variance"

    def run_test(
        self,
        data_before: pd.Series,
        data_after: pd.Series,
    ) -> float:
        """Jalankan uji Levene dan kembalikan p-value."""
        if data_before.empty or data_after.empty:
            return np.nan
        return stats.levene(
            data_before.dropna(),
            data_after.dropna(),
        )[1]


class TTestIndStrategy(BaseStatTestStrategy):
    """Uji-t dua sampel independen (Cross-Section)."""

    @property
    def name(self) -> str:
        """Nama kolom: 'T-Test_Ind_p_value_mean'."""
        return "T-Test_Ind_p_value_mean"

    def run_test(
        self,
        data_before: pd.Series,
        data_after: pd.Series,
    ) -> float:
        """Bandingkan rata-rata dua sampel independen."""
        if data_before.dropna().empty or data_after.dropna().empty:
            return np.nan
        return stats.ttest_ind(
            data_before.dropna(),
            data_after.dropna(),
            equal_var=False,
        )[1]


class MannWhitneyUTestStrategy(BaseStatTestStrategy):
    """Uji Mann-Whitney U (non-parametrik untuk Cross-Section)."""

    @property
    def name(self) -> str:
        """Nama kolom: 'MannWhitneyU_p_value_median'."""
        return "MannWhitneyU_p_value_median"

    def run_test(
        self,
        data_before: pd.Series,
        data_after: pd.Series,
    ) -> float:
        """Bandingkan distribusi/median dua sampel."""
        if data_before.dropna().empty or data_after.dropna().empty:
            return np.nan
        try:
            return stats.mannwhitneyu(
                data_before.dropna(),
                data_after.dropna(),
            )[1]
        except ValueError:
            return 1.0


class TTestPairedStrategy(BaseStatTestStrategy):
    """Uji-t sampel berpasangan (Time Series)."""

    @property
    def name(self) -> str:
        """Nama kolom: 'T-Test_Paired_p_value_mean'."""
        return "T-Test_Paired_p_value_mean"

    def run_test(
        self,
        data_before: pd.Series,
        data_after: pd.Series,
    ) -> float:
        """Bandingkan rata-rata dua seri berpasangan."""
        b_clean = data_before.dropna()
        a_clean = data_after.dropna()
        common_index = b_clean.index.intersection(a_clean.index)
        b_aligned = b_clean[common_index]
        a_aligned = a_clean[common_index]
        if b_aligned.empty:
            return np.nan
        return stats.ttest_rel(b_aligned, a_aligned)[1]


class WilcoxonSignedRankTestStrategy(BaseStatTestStrategy):
    """Uji Wilcoxon, alternatif non-parametrik Uji-t berpasangan."""

    @property
    def name(self) -> str:
        """Nama kolom: 'Wilcoxon_p_value_median'."""
        return "Wilcoxon_p_value_median"

    def run_test(
        self,
        data_before: pd.Series,
        data_after: pd.Series,
    ) -> float:
        """Bandingkan median dua seri berpasangan."""
        b_clean = data_before.dropna()
        a_clean = data_after.dropna()
        common_index = b_clean.index.intersection(a_clean.index)
        b_aligned = b_clean[common_index]
        a_aligned = a_clean[common_index]

        if b_aligned.empty:
            return np.nan

        diff = b_aligned.values - a_aligned.values
        if np.all(diff == 0):
            return 1.0

        with np.testing.suppress_warnings() as sup:
            sup.filter(RuntimeWarning)
            return stats.wilcoxon(diff, zero_method="pratt")[1]


# ===================================================================
# Bagian 5: Orkestrator Evaluasi
# ===================================================================


class OutlierEvaluator:
    """Orkestrator yang menjalankan berbagai strategi evaluasi."""

    def __init__(
        self,
        df_before: pd.DataFrame,
        df_after: pd.DataFrame,
    ):
        """Inisialisasi dengan DataFrame sebelum dan sesudah treatment."""
        self.df_before = df_before
        self.df_after = df_after

    def run(
        self,
        strategies: List[BaseEvaluationStrategy],
    ) -> Dict[str, pd.DataFrame]:
        """Jalankan semua strategi evaluasi dan kumpulkan hasilnya."""
        results: Dict[str, pd.DataFrame] = {}
        # print("--- Menjalankan Proses Evaluasi ---")
        for strategy in strategies:
            test_attr = getattr(strategy, "test", None)
            display_name = f" ({test_attr.name})" if test_attr else ""
            # print(
            #     f"Mengeksekusi strategi: {strategy.__class__.__name__}{display_name}",
            # )
            try:
                result = strategy.evaluate(self.df_before, self.df_after)
                if isinstance(result, pd.DataFrame):
                    key_name = (
                        f"{strategy.__class__.__name__}_"
                        f"{test_attr.name if test_attr else ''}"
                    )
                    results[key_name] = result
            except Exception as exc:
                pass
                # print(
                #     f"Error saat menjalankan {strategy.__class__.__name__}: {exc}",
                # )
        return results


def format_p_value(
    p_value: float,
    sig_text: str,
    insig_text: str,
    alpha: float = 0.05,
) -> str:
    """Format p-value menjadi teks interpretatif sederhana."""
    if pd.isna(p_value):
        return "N/A"
    p_str = f"{p_value:.3f}" if p_value >= 0.001 else "<0.001"
    if p_value < alpha:
        return f"{sig_text} ({p_str})"
    return f"{insig_text} ({p_str})"


def create_summary_table(
    results_dict: Dict[str, pd.DataFrame],
    test_map: Dict[str, Dict[str, str]],
    alpha: float = 0.05,
) -> pd.DataFrame:
    """Gabungkan dan format hasil beberapa uji statistik jadi satu tabel."""
    all_formatted_dfs: List[pd.DataFrame] = []
    all_indices = set()

    for test_key in test_map:
        full_key = next(
            (r_key for r_key in results_dict if test_key in r_key),
            None,
        )
        if full_key:
            all_indices.update(results_dict[full_key].index)

    if not all_indices:
        return pd.DataFrame(
            {"Info": ["Tidak ada hasil tes yang ditemukan."]},
        )

    final_index = sorted(list(all_indices))

    for test_key, info in test_map.items():
        full_key = next(
            (r_key for r_key in results_dict if test_key in r_key),
            None,
        )
        if not full_key:
            continue

        df = results_dict[full_key].reindex(final_index)
        formatted_df = df.map(
            lambda p: format_p_value(
                p,
                info["sig"],
                info["insig"],
                alpha,
            ),
        )
        new_column_names = {
            col: f"{col} - {info['name']}" for col in formatted_df.columns
        }
        formatted_df.rename(columns=new_column_names, inplace=True)
        all_formatted_dfs.append(formatted_df)

    if not all_formatted_dfs:
        return pd.DataFrame(
            {"Info": ["Tidak ada hasil tes yang ditemukan."]},
        )

    return pd.concat(all_formatted_dfs, axis=1)


# ===================================================================
# Ringkasan Hasil / Summary Update (modul outlier_value)
# ===================================================================
# - Menstandardisasi struktur import dan metadata di bagian atas modul.
# - Menambahkan docstring singkat (< 3 baris) untuk semua class & function.
# - Merapikan naming convention (snake_case, PascalCase) dan menambah
#   LOCFStrategyCS sebagai nama class utama dengan alias LOCFStrategy_CS.
# - Merapikan BoxplotStrategy, Orkestrator evaluasi, dan create_summary_table
#   supaya lebih konsisten dan mudah dibaca.