# Notebook Title: AA Prepflow - Core Logic
# Author: Advanced Analytics Team
# Date: 2025-11-13
# Purpose: Arsitektur kelas inti untuk pipeline AAPrepflow

from __future__ import annotations

import pandas as pd
from typing import List, Dict, Union, Optional, Tuple, Literal
from abc import ABC, abstractmethod
from .app import AA_Prepflow
 
# Impor modul strategi Anda
from .models import outlier_value as ov
from .models import missing_value as mv
 
# ===================================================================
# BAGIAN 1: AKSESOR MISSING VALUE (Arsitektur Pewarisan)
# ===================================================================
 
class _MVBaseAccessor(ABC):
    """
    Kelas dasar abstrak untuk semua aksesor MV.
    Mengandung logika inti 'transform' dan 'dispatcher' _get_processor.
    """
    def __init__(self, parent_flow: "AAPrepflowBase"):
        """Inisialisasi aksesor."""
        self.flow = parent_flow
        self.mv_processor_: Optional[Union[mv.CrossSectionMVProcessor,
                                          mv.TimeSeriesMVProcessor,
                                          mv.PanelMVProcessor]] = None
 
    def _get_processor(self, strategy: mv.BaseMVStrategy):
        """
        Helper 'Dispatcher' internal.
        Membuat prosesor yang TEPAT berdasarkan self.flow.data_type.
        """
        data_type = self.flow.data_type
       
        if data_type == 'panel':
            return mv.PanelMVProcessor(
                base_impute_strategy=strategy,
                group_by=self.flow.group_by,
                date_col=self.flow.col_time
            )
        elif data_type == 'timeseries':
            return mv.TimeSeriesMVProcessor(
                impute_strategy=strategy,
                date_col=self.flow.col_time
            )
        elif data_type == 'cross_section':
            return mv.CrossSectionMVProcessor(impute_strategy=strategy)
        else:
            raise ValueError(f"Tipe data tidak dikenal: {data_type}")
 
    def transform(self, X: Optional[pd.DataFrame] = None) -> pd.DataFrame:
        """
        Menjalankan transformasi missing value menggunakan prosesor yang sudah di-fit.
 
        Args:
            X (Optional[pd.DataFrame]):
                - Jika None (default): Data internal (self.flow.data) akan
                  ditransformasi DAN diperbarui (in-place).
                - Jika DataFrame diberikan: Data eksternal (X) ini akan ditransformasi
                  dan hasilnya dikembalikan. Data internal flow TIDAK akan berubah.
                  Ini ideal untuk skenario data uji (test set).
 
        Returns:
            pd.DataFrame: DataFrame yang telah ditransformasi.
       
        Raises:
            RuntimeError: Jika metode .transform() dipanggil sebelum .fit_...().
        """
        if self.mv_processor_ is None or not self.mv_processor_.fitted_:
            raise RuntimeError(
                "Metode 'fit' (misal: .fit_locf()) "
                "harus dijalankan terlebih dahulu sebelum memanggil '.transform()'."
            )
 
        processor_name = self.mv_processor_.__class__.__name__
 
        if X is None:
            # Pola: Transformasi data internal (in-place)
            # print(f"Menjalankan transform (data internal) menggunakan {processor_name}...")
            data_to_transform = self.flow.data
            transformed_data = self.mv_processor_.transform(data_to_transform)
            self.flow.data = transformed_data
            # print("Transformasi data internal selesai.")
            return self.flow.data
        else:
            # Pola: Transformasi data eksternal (return result)
            # print(f"Menjalankan transform (data eksternal) menggunakan {processor_name}...")
            transformed_data = self.mv_processor_.transform(X)
            # print("Transformasi data eksternal selesai.")
            return transformed_data
 
 
class _MVCrossSectionAccessor(_MVBaseAccessor):
    """
    Aksesor MV untuk data Cross-Section.
    Hanya berisi metode yang valid untuk Cross-Section.
    """
   
    # --- METODE CROSS-SECTION ---
 
    def fit_listwise_deletion(self, columns: Optional[List[str]] = None):
        """
        Fit strategi Listwise Deletion (menghapus baris dengan NA).
       
        Konteks: [cross_section]
       
        Args:
            columns (Optional[List[str]]):
                Daftar kolom yang akan diperiksa. Jika None, semua kolom akan digunakan.
        """
        strategy = mv.ListwiseDeletionStrategy(columns=columns)
        self.mv_processor_ = self._get_processor(strategy)
        self.mv_processor_.fit(self.flow.data)
        # print("Fit ListwiseDeletionStrategy (CrossSection) selesai. Prosesor disimpan.")
        return self
 
    def clean_listwise_deletion(self, columns: Optional[List[str]] = None) -> pd.DataFrame:
        """
        Menjalankan fit_transform Listwise Deletion (menghapus baris dengan NA).
       
        Konteks: [cross_section]
       
        Args:
            columns (Optional[List[str]]):
                Daftar kolom yang akan diperiksa. Jika None, semua kolom akan digunakan.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_listwise_deletion(columns=columns)
        return self.transform()
 
    def fit_mean_median_mode_imputer(self, strategy: Literal["mean", "median", "mode"] = "median", columns: Optional[List[str]] = None):
        """
        Fit strategi imputasi Mean, Median, atau Mode.
       
        Konteks: [cross_section]
       
        Args:
            strategy (Literal["mean", "median", "mode"]):
                Metode imputasi yang akan digunakan. Default: 'median'.
            columns (Optional[List[str]]):
                Daftar kolom yang akan diimputasi. Jika None, semua kolom akan digunakan.
        """
        strat = mv.MeanMedianModeImputer(strategy=strategy, columns=columns)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print(f"Fit MeanMedianModeImputer (strategy='{strategy}') (CrossSection) selesai. Prosesor disimpan.")
        return self
 
    def clean_mean_median_mode_imputer(self, strategy: Literal["mean", "median", "mode"] = "median", columns: Optional[List[str]] = None) -> pd.DataFrame:
        """
        Menjalankan fit_transform imputasi Mean, Median, atau Mode.
       
        Konteks: [cross_section]
       
        Args:
            strategy (Literal["mean", "median", "mode"]):
                Metode imputasi yang akan digunakan. Default: 'median'.
            columns (Optional[List[str]]):
                Daftar kolom yang akan diimputasi. Jika None, semua kolom akan digunakan.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_mean_median_mode_imputer(strategy=strategy, columns=columns)
        return self.transform()
 
    def fit_hot_deck_imputer(self, group_by: Optional[Union[str, List[str]]] = None, random_state: int = 42):
        """
        Fit strategi Hot-Deck Imputer (mengisi NA dengan sampel acak dari data non-NA).
       
        Konteks: [cross_section]
       
        Args:
            group_by (Optional[Union[str, List[str]]]):
                Jika disediakan, donor akan diambil dari grup yang sama.
            random_state (int): Seed untuk reproduktibilitas.
        """
        strat = mv.HotDeckImputer(group_by=group_by, random_state=random_state)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print("Fit HotDeckImputer (CrossSection) selesai. Prosesor disimpan.")
        return self
 
    def clean_hot_deck_imputer(self, group_by: Optional[Union[str, List[str]]] = None, random_state: int = 42) -> pd.DataFrame:
        """
        Menjalankan fit_transform Hot-Deck Imputer.
       
        Konteks: [cross_section]
       
        Args:
            group_by (Optional[Union[str, List[str]]]):
                Jika disediakan, donor akan diambil dari grup yang sama.
            random_state (int): Seed untuk reproduktibilitas.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_hot_deck_imputer(group_by=group_by, random_state=random_state)
        return self.transform()
       
    def fit_regression_imputer(self, target_columns: Optional[List[str]] = None):
        """
        Fit strategi Regresi Imputer (mengisi NA di 'target_columns' menggunakan regresi linier).
       
        Konteks: [cross_section]
       
        Args:
            target_columns (Optional[List[str]]):
                Kolom yang akan diimputasi. Jika None, semua kolom numerik akan dicoba.
        """
        strat = mv.RegressionImputer(target_columns=target_columns)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print("Fit RegressionImputer (CrossSection) selesai. Prosesor disimpan.")
        return self
 
    def clean_regression_imputer(self, target_columns: Optional[List[str]] = None) -> pd.DataFrame:
        """
        Menjalankan fit_transform Regresi Imputer.
       
        Konteks: [cross_section]
       
        Args:
            target_columns (Optional[List[str]]):
                Kolom yang akan diimputasi. Jika None, semua kolom numerik akan dicoba.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_regression_imputer(target_columns=target_columns)
        return self.transform()
 
    def fit_knn_imputer(self, n_neighbors: int = 5, weights: str = "uniform"):
        """
        Fit strategi KNN Imputer (mengisi NA menggunakan rata-rata K-Nearest Neighbors).
       
        Konteks: [cross_section]
       
        Args:
            n_neighbors (int): Jumlah tetangga yang akan digunakan. Default: 5.
            weights (str): 'uniform' (semua tetangga berbobot sama) atau 'distance'.
        """
        strat = mv.KNNImputerStrategy(n_neighbors=n_neighbors, weights=weights)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print("Fit KNNImputerStrategy (CrossSection) selesai. Prosesor disimpan.")
        return self
 
    def clean_knn_imputer(self, n_neighbors: int = 5, weights: str = "uniform") -> pd.DataFrame:
        """
        Menjalankan fit_transform KNN Imputer.
       
        Konteks: [cross_section]
       
        Args:
            n_neighbors (int): Jumlah tetangga yang akan digunakan. Default: 5.
            weights (str): 'uniform' (semua tetangga berbobot sama) atau 'distance'.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_knn_imputer(n_neighbors=n_neighbors, weights=weights)
        return self.transform()
 
 
class _MVTimeSeriesAccessor(_MVBaseAccessor):
    """
    Aksesor MV untuk data Time-Series.
    Berisi metode yang valid untuk Time-Series (dan Panel, melalui pewarisan).
    """
 
    # --- METODE TIME SERIES & PANEL ---
 
    def fit_locf(self):
        """
        Fit strategi LOCF (Last Observation Carried Forward).
       
        Konteks: [timeseries, panel]
        """
        strat = mv.LOCFStrategy()
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print(f"Fit LOCFStrategy ({self.flow.data_type}) selesai. Prosesor disimpan.")
        return self
 
    def clean_locf(self) -> pd.DataFrame:
        """
        Menjalankan fit_transform LOCF (Last Observation Carried Forward).
       
        Konteks: [timeseries, panel]
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_locf()
        return self.transform()
 
    def fit_nocb(self):
        """
        Fit strategi NOCB (Next Observation Carried Backward).
       
        Konteks: [timeseries, panel]
        """
        strat = mv.NOCBStrategy()
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print(f"Fit NOCBStrategy ({self.flow.data_type}) selesai. Prosesor disimpan.")
        return self
 
    def clean_nocb(self) -> pd.DataFrame:
        """
        Menjalankan fit_transform NOCB (Next Observation Carried Backward).
       
        Konteks: [timeseries, panel]
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_nocb()
        return self.transform()
 
    def fit_interpolation(self, method: str = "linear", order: Optional[int] = None, window_ma: Optional[int] = None):
        """
        Fit strategi Interpolasi (linear, polynomial, dll) atau Moving Average.
       
        Konteks: [timeseries, panel]
       
        Args:
            method (str): Metode interpolasi (misal 'linear', 'polynomial', 'time'). Default: 'linear'.
            order (Optional[int]): Order untuk interpolasi polynomial.
            window_ma (Optional[int]): Jika diisi, akan menggunakan rolling mean.
        """
        strat = mv.InterpolationStrategy(method=method, order=order, window_ma=window_ma)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print(f"Fit InterpolationStrategy (method='{method}') ({self.flow.data_type}) selesai. Prosesor disimpan.")
        return self
 
    def clean_interpolation(self, method: str = "linear", order: Optional[int] = None, window_ma: Optional[int] = None) -> pd.DataFrame:
        """
        Menjalankan fit_transform Interpolasi (linear, polynomial, dll) atau Moving Average.
       
        Konteks: [timeseries, panel]
       
        Args:
            method (str): Metode interpolasi (misal 'linear', 'polynomial', 'time'). Default: 'linear'.
            order (Optional[int]): Order untuk interpolasi polynomial.
            window_ma (Optional[int]): Jika diisi, akan menggunakan rolling mean.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_interpolation(method=method, order=order, window_ma=window_ma)
        return self.transform()
 
    def fit_seasonal_imputer(self, by: str = "month"):
        """
        Fit strategi Seasonal Imputer (mengisi NA dengan rata-rata musiman).
       
        Konteks: [timeseries, panel]
       
        Args:
            by (str): Kunci musiman ('month', 'weekofyear', 'quarter'). Default: 'month'.
        """
        strat = mv.SeasonalImputerStrategy(by=by)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print(f"Fit SeasonalImputerStrategy (by='{by}') ({self.flow.data_type}) selesai. Prosesor disimpan.")
        return self
 
    def clean_seasonal_imputer(self, by: str = "month") -> pd.DataFrame:
        """
        Menjalankan fit_transform Seasonal Imputer.
       
        Konteks: [timeseries, panel]
       
        Args:
            by (str): Kunci musiman ('month', 'weekofyear', 'quarter'). Default: 'month'.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_seasonal_imputer(by=by)
        return self.transform()
 
    def fit_arima_imputer(self, order: Tuple = (1,1,0), seasonal_order: Tuple = (0,0,0,0)):
        """
        Fit strategi ARIMA Imputer (mengisi NA dengan prediksi in-sample SARIMAX).
       
        Konteks: [timeseries, panel]
       
        Args:
            order (Tuple): (p,d,q) untuk orde non-musiman. Default: (1,1,0).
            seasonal_order (Tuple): (P,D,Q,s) untuk orde musiman. Default: (0,0,0,0).
        """
        strat = mv.ARIMAImputerStrategy(order=order, seasonal_order=seasonal_order)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print(f"Fit ARIMAImputerStrategy ({self.flow.data_type}) selesai. Prosesor disimpan.")
        return self
 
    def clean_arima_imputer(self, order: Tuple = (1,1,0), seasonal_order: Tuple = (0,0,0,0)) -> pd.DataFrame:
        """
        Menjalankan fit_transform ARIMA Imputer.
       
        Konteks: [timeseries, panel]
       
        Args:
            order (Tuple): (p,d,q) untuk orde non-musiman. Default: (1,1,0).
            seasonal_order (Tuple): (P,D,Q,s) untuk orde musiman. Default: (0,0,0,0).
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_arima_imputer(order=order, seasonal_order=seasonal_order)
        return self.transform()
 
    def fit_kalman_imputer(self, level: str = "local level", seasonal: Optional[int] = None):
        """
        Fit strategi Kalman Imputer (Unobserved Components Model).
       
        Konteks: [timeseries, panel]
       
        Args:
            level (str): Tipe level (misal 'local level', 'local linear trend'). Default: 'local level'.
            seasonal (Optional[int]): Periode musiman (misal 12 untuk bulanan). Default: None.
        """
        strat = mv.KalmanImputerStrategy(level=level, seasonal=seasonal)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print(f"Fit KalmanImputerStrategy ({self.flow.data_type}) selesai. Prosesor disimpan.")
        return self
 
    def clean_kalman_imputer(self, level: str = "local level", seasonal: Optional[int] = None) -> pd.DataFrame:
        """
        Menjalankan fit_transform Kalman Imputer.
       
        Konteks: [timeseries, panel]
       
        Args:
            level (str): Tipe level (misal 'local level', 'local linear trend'). Default: 'local level'.
            seasonal (Optional[int]): Periode musiman (misal 12 untuk bulanan). Default: None.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_kalman_imputer(level=level, seasonal=seasonal)
        return self.transform()
 
    def fit_moving_average_imputer(self, k: int = 2, side: str = "both"):
        """
        Fit strategi Moving Average Imputer (fallback ke expanding mean).
       
        Konteks: [timeseries, panel]
       
        Args:
            k (int): Jendela (window) MA. Default: 2.
            side (str): 'both' (centered), 'past', atau 'future'. Default: 'both'.
        """
        strat = mv.MovingAverageImputerStrategy(k=k, side=side)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print(f"Fit MovingAverageImputerStrategy ({self.flow.data_type}) selesai. Prosesor disimpan.")
        return self
 
    def clean_moving_average_imputer(self, k: int = 2, side: str = "both") -> pd.DataFrame:
        """
        Menjalankan fit_transform Moving Average Imputer.
       
        Konteks: [timeseries, panel]
       
        Args:
            k (int): Jendela (window) MA. Default: 2.
            side (str): 'both' (centered), 'past', atau 'future'. Default: 'both'.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_moving_average_imputer(k=k, side=side)
        return self.transform()
 
    def fit_cagr_imputer(self, min_positive: float = 1e-9):
        """
        Fit strategi CAGR Imputer (interpolasi/ekstrapolasi geometris).
       
        Konteks: [timeseries, panel]
       
        Args:
            min_positive (float): Nilai positif terkecil untuk perhitungan CAGR.
        """
        strat = mv.CAGRImputerStrategy(min_positive=min_positive)
        self.mv_processor_ = self._get_processor(strat)
        self.mv_processor_.fit(self.flow.data)
        # print(f"Fit CAGRImputerStrategy ({self.flow.data_type}) selesai. Prosesor disimpan.")
        return self
 
    def clean_cagr_imputer(self, min_positive: float = 1e-9) -> pd.DataFrame:
        """
        Menjalankan fit_transform CAGR Imputer.
       
        Konteks: [timeseries, panel]
       
        Args:
            min_positive (float): Nilai positif terkecil untuk perhitungan CAGR.
               
        Returns:
            pd.DataFrame: DataFrame yang telah dibersihkan.
        """
        self.fit_cagr_imputer(min_positive=min_positive)
        return self.transform()
 
 
class _MVPanelAccessor(_MVTimeSeriesAccessor):
    """
    Aksesor MV untuk data Panel.
    Mewarisi semua metode dari TimeSeries, karena semua metode TS
    juga kompatibel dengan Panel (melalui dispatcher _get_processor).
    """
    # Saat ini tidak ada metode yang SPESIFIK hanya untuk Panel.
    # Jika ada di masa depan, tambahkan di sini.
    pass
 
 
# ===================================================================
# BAGIAN 2: AKSESOR OUTLIER (Arsitektur Pewarisan)
# ===================================================================
 
class _OutlierBaseAccessor(ABC):
    """
    Kelas dasar abstrak untuk semua aksesor Outlier.
    Mengandung logika inti 'transform' dan 'dispatcher' _get_processor.
    """
    def __init__(self, parent_flow: "AAPrepflowBase"):
        """Inisialisasi aksesor."""
        self.flow = parent_flow
        self.outlier_processor_: Optional[ov.BaseOutlierProcessor] = None
 
    def _get_processor(self,
                       detection_strategy: ov.BaseDetectionStrategy,
                       handling_strategy: ov.BaseHandlingStrategy
                       ) -> ov.BaseOutlierProcessor:
        """
        Helper 'Dispatcher' internal.
        Membuat prosesor yang TEPAT berdasarkan self.flow.data_type.
        """
        data_type = self.flow.data_type
       
        if data_type == 'panel':
            return ov.PanelProcessor(
                detection_strategy,
                handling_strategy,
                group_by_col=self.flow.group_by,
                date_col=self.flow.col_time
            )
        elif data_type == 'timeseries':
            return ov.TimeSeriesProcessor(
                detection_strategy,
                handling_strategy,
                date_col=self.flow.col_time
            )
        elif data_type == 'cross_section':
            return ov.CrossSectionalProcessor(
                detection_strategy,
                handling_strategy
            )
        else:
            raise ValueError(f"Tipe data tidak dikenal: {data_type}")
           
    def _check_processor_fitted(self, method_name: str):
        """Memunculkan error jika prosesor Outlier (fit) belum dijalankan."""
        if self.outlier_processor_ is None or not self.outlier_processor_.fitted_:
            raise RuntimeError(
                f"Metode deteksi (misal: .fit_iqr()) harus dijalankan "
                f"sebelum memanggil '{method_name}()'."
            )
           
    def _warn_if_processor_exists(self, new_fit_method: str):
        """Helper untuk memberi peringatan jika prosesor lama ada."""
        if self.outlier_processor_ is not None and self.outlier_processor_.fitted_:
            old_name = self.outlier_processor_.detection_strategy.__class__.__name__
            # print(
            #     f"PERINGATAN: Menjalankan '{new_fit_method}' akan menimpa (overwrite) "
            #     f"prosesor '{old_name}' yang sudah di-fit sebelumnya."
            # )
 
    # --- FUNGSI-FUNGSI TRANSFORM (HANDLE) GENERIK ---
 
    def _transform(self,
                     handler: ov.BaseHandlingStrategy,
                     X: Optional[pd.DataFrame] = None
                     ) -> pd.DataFrame:
        """
        Helper internal generik untuk menjalankan transformasi (penanganan) outlier.
       
        PENTING: Ini tidak berfungsi untuk ov.LOCFStrategy_CS yang memerlukan
        penanganan khusus karena desain `handle(..., df=...)` yang unik.
        """
        method_name = f"transform_{handler.__class__.__name__.replace('Strategy', '').lower()}"
        self._check_processor_fitted(method_name)
       
        # Tetapkan strategi penanganan yang baru ke prosesor yang sudah di-fit
        self.outlier_processor_.handling_strategy = handler
        processor_name = self.outlier_processor_.__class__.__name__
 
        if X is None:
            # Pola: Transformasi data internal (in-place)
            # print(f"Menjalankan transform (data internal) menggunakan {processor_name}...")
            data_to_transform = self.flow.data
            transformed_data = self.outlier_processor_.transform(data_to_transform)
            self.flow.data = transformed_data
            # print("Transformasi data internal selesai.")
            return self.flow.data
        else:
            # Pola: Transformasi data eksternal (return result)
            # print(f"Menjalankan transform (data eksternal) menggunakan {processor_name}...")
            transformed_data = self.outlier_processor_.transform(X)
            # print("Transformasi data eksternal selesai.")
            return transformed_data
 
    def transform_capping(self, X: Optional[pd.DataFrame] = None) -> pd.DataFrame:
        """
        Transform (menangani) outlier dengan Capping (mengganti dengan nilai batas).
       
        Konteks: [cross_section, timeseries, panel]
       
        Args:
            X (Optional[pd.DataFrame]): Data eksternal yang akan ditransformasi (misal, data uji).
                                        Jika None, data internal akan ditransformasi.
        Returns:
            pd.DataFrame: DataFrame yang telah ditransformasi.
        """
        # print("Penanganan outlier dengan Capping...")
        return self._transform(ov.CappingStrategy(), X)
 
    def transform_imputation(self,
                             strategy: Literal['median', 'mean'] = 'median',
                             X: Optional[pd.DataFrame] = None
                             ) -> pd.DataFrame:
        """
        Transform (menangani) outlier dengan Imputasi (mengganti dengan mean/median).
       
        Konteks: [cross_section, timeseries, panel]
       
        Args:
            strategy (Literal['median', 'mean']): Metode imputasi. Default: 'median'.
            X (Optional[pd.DataFrame]): Data eksternal yang akan ditransformasi (misal, data uji).
                                        Jika None, data internal akan ditransformasi.
        Returns:
            pd.DataFrame: DataFrame yang telah ditransformasi.
        """
        # print(f"Penanganan outlier dengan Imputasi (strategy='{strategy}')...")
        return self._transform(ov.ImputationStrategy(strategy=strategy), X)
 
    def transform_locf(self, X: Optional[pd.DataFrame] = None) -> pd.DataFrame:
        """
        Transform (menangani) outlier dengan LOCF (Last Observation Carried Forward).
       
        Catatan: Ini menggunakan loop kustom karena LOCFStrategy_CS memiliki
        API `handle(..., df=...)` yang unik dan tidak kompatibel dengan
        prosesor transform generik.
       
        Konteks: [cross_section, timeseries, panel]
       
        Args:
            X (Optional[pd.DataFrame]): Data eksternal yang akan ditransformasi (misal, data uji).
                                        Jika None, data internal akan ditransformasi.
        Returns:
            pd.DataFrame: DataFrame yang telah ditransformasi.
        """
        self._check_processor_fitted('transform_locf')
       
        data_type = self.flow.data_type
        # Pastikan self.flow.group_by diakses dengan aman
        groupby_col = getattr(self.flow, 'group_by', None) if data_type == 'panel' else None
       
        # Tentukan data mana yang akan digunakan
        if X is None:
            # print("Menjalankan transform_locf (data internal)...")
            data_to_transform = self.flow.data.copy()
            is_external = False
        else:
            # print("Menjalankan transform_locf (data eksternal)...")
            data_to_transform = X.copy()
            is_external = True
           
        # Inisialisasi strategi handler
        handling_strategy = ov.LOCFStrategy_CS(groupby=groupby_col)
       
        processor = self.outlier_processor_
       
        # Logika loop manual yang diadaptasi dari kode asli Anda
        if data_type == 'panel' and groupby_col is not None:
            cols_to_group = [groupby_col] if isinstance(groupby_col, str) else list(groupby_col)
            handled_groups = []
            for group_name, group_df in data_to_transform.groupby(cols_to_group):
                handled_group = group_df.copy()
                for col in processor.numeric_cols_:
                    key = (group_name, col)
                    if key in processor._bounds:
                        lower, upper = processor._bounds[key]
                        handled_group[col] = handling_strategy.handle(
                            handled_group[col], lower, upper, df=handled_group
                        )
                handled_groups.append(handled_group)
            X_transformed = pd.concat(handled_groups).sort_index()
        else:
            # Mode CrossSection atau TimeSeries
            X_transformed = data_to_transform.copy()
            for col in processor.numeric_cols_:
                if col in processor._bounds: # CS/TS bounds adalah dict sederhana
                    lower, upper = processor._bounds[col]
                    X_transformed[col] = handling_strategy.handle(
                        X_transformed[col], lower, upper, df=None # df=None untuk mode non-panel
                    )
 
        # print("Penanganan outlier dengan LOCF selesai.")
       
        # Perbarui data internal HANYA jika X adalah None
        if not is_external:
            self.flow.data = X_transformed
           
        return X_transformed
 
 
class _OutlierCrossSectionAccessor(_OutlierBaseAccessor):
    """
    Aksesor Outlier untuk data Cross-Section.
    Hanya berisi metode deteksi/apply yang valid untuk Cross-Section.
    """
 
    # --- FUNGSI-FUNGSI FIT (DETEKSI) ---
 
    def fit_iqr(self, factor: float = 1.5):
        """
        Fit (deteksi) outlier menggunakan metode IQR (Interquartile Range).
       
        Konteks: [cross_section]
       
        Args:
            factor (float): Faktor pengali IQR. Nilai di luar Q1-(f*IQR) dan
                            Q3+(f*IQR) akan dianggap outlier. Default: 1.5.
        """
        self._warn_if_processor_exists('fit_iqr')
        detection_strategy = ov.IQRStrategy(factor=factor)
        dummy_handler = ov.CappingStrategy()
        self.outlier_processor_ = self._get_processor(detection_strategy, dummy_handler)
        self.outlier_processor_.fit(self.flow.data)
        # print(f"Fit IQR (factor={factor}) ({self.flow.data_type}) selesai. Batasan (bounds) telah disimpan.")
        return self
 
    def fit_zscore(self, factor: float = 3.0):
        """
        Fit (deteksi) outlier menggunakan metode Z-Score (Standar Deviasi).
       
        Konteks: [cross_section]
       
        Args:
            factor (float): Ambang batas Z-Score. Nilai di luar (mean ± factor*std)
                            akan dianggap outlier. Default: 3.0.
        """
        self._warn_if_processor_exists('fit_zscore')
        detection_strategy = ov.ZScoreStrategy(factor=factor)
        dummy_handler = ov.CappingStrategy()
        self.outlier_processor_ = self._get_processor(detection_strategy, dummy_handler)
        self.outlier_processor_.fit(self.flow.data)
        # print(f"Fit Z-Score (factor={factor}) ({self.flow.data_type}) selesai. Batasan (bounds) telah disimpan.")
        return self
 
    # --- FUNGSI-FUNGSI APPLY (FIT + TRANSFORM) ---
   
    def apply_iqr_capping(self, factor: float = 1.5) -> pd.DataFrame:
        """
        Menjalankan fit_iqr (deteksi) DAN transform_capping (penanganan)
        dalam satu panggilan pada data internal.
       
        Konteks: [cross_section]
       
        Args:
            factor (float): Faktor pengali IQR. Default: 1.5.
           
        Returns:
            pd.DataFrame: DataFrame internal yang telah dibersihkan.
        """
        # print("Menjalankan apply_iqr_capping (fit+transform)...")
        self.fit_iqr(factor=factor)
        return self.transform_capping()
 
    def apply_zscore_imputation(self,
                                factor: float = 3.0,
                                strategy: Literal['median', 'mean'] = 'median'
                                ) -> pd.DataFrame:
        """
        Menjalankan fit_zscore (deteksi) DAN transform_imputation (penanganan)
        dalam satu panggilan pada data internal.
       
        Konteks: [cross_section]
       
        Args:
            factor (float): Ambang batas Z-Score. Default: 3.0.
            strategy (Literal['median', 'mean']): Metode imputasi. Default: 'median'.
           
        Returns:
            pd.DataFrame: DataFrame internal yang telah dibersihkan.
        """
        # print(f"Menjalankan apply_zscore_imputation (fit+transform) dengan strategy='{strategy}'...")
        self.fit_zscore(factor=factor)
        return self.transform_imputation(strategy=strategy)
 
    def apply_iqr_locf(self, factor: float = 1.5) -> pd.DataFrame:
        """
        Menjalankan fit_iqr (deteksi) DAN transform_locf (penanganan)
        dalam satu panggilan pada data internal.
       
        Konteks: [cross_section]
       
        Args:
            factor (float): Faktor pengali IQR. Default: 1.5.
           
        Returns:
            pd.DataFrame: DataFrame internal yang telah dibersihkan.
        """
        # print("Menjalankan apply_iqr_locf (fit+transform)...")
        self.fit_iqr(factor=factor)
        return self.transform_locf()
 
 
class _OutlierTimeSeriesAccessor(_OutlierBaseAccessor):
    """
    Aksesor Outlier untuk data Time-Series.
    Berisi metode deteksi/apply yang valid untuk Time-Series.
    """
 
    # --- FUNGSI-FUNGSI FIT (DETEKSI) ---
 
    def fit_iqr(self, factor: float = 1.5):
        """
        Fit (deteksi) outlier menggunakan metode IQR (Interquartile Range).
       
        Konteks: [timeseries, panel]
       
        Args:
            factor (float): Faktor pengali IQR. Nilai di luar Q1-(f*IQR) dan
                            Q3+(f*IQR) akan dianggap outlier. Default: 1.5.
        """
        self._warn_if_processor_exists('fit_iqr')
        detection_strategy = ov.IQRStrategy(factor=factor)
        dummy_handler = ov.CappingStrategy()
        self.outlier_processor_ = self._get_processor(detection_strategy, dummy_handler)
        self.outlier_processor_.fit(self.flow.data)
        # print(f"Fit IQR (factor={factor}) ({self.flow.data_type}) selesai. Batasan (bounds) telah disimpan.")
        return self
 
    def fit_zscore(self, factor: float = 3.0):
        """
        Fit (deteksi) outlier menggunakan metode Z-Score (Standar Deviasi).
       
        Konteks: [timeseries, panel]
       
        Args:
            factor (float): Ambang batas Z-Score. Nilai di luar (mean ± factor*std)
                            akan dianggap outlier. Default: 3.0.
        """
        self._warn_if_processor_exists('fit_zscore')
        detection_strategy = ov.ZScoreStrategy(factor=factor)
        dummy_handler = ov.CappingStrategy()
        self.outlier_processor_ = self._get_processor(detection_strategy, dummy_handler)
        self.outlier_processor_.fit(self.flow.data)
        # print(f"Fit Z-Score (factor={factor}) ({self.flow.data_type}) selesai. Batasan (bounds) telah disimpan.")
        return self
 
    def fit_rolling_iqr(self, window: int = 20, factor: float = 1.5):
        """
        Fit (deteksi) outlier menggunakan metode Rolling IQR.
       
        Konteks: [timeseries, panel]
       
        Args:
            window (int): Ukuran jendela (window) rolling. Default: 20.
            factor (float): Faktor pengali IQR. Default: 1.5.
        """
        self._warn_if_processor_exists('fit_rolling_iqr')
        detection_strategy = ov.RollingIQRStrategy(window=window, factor=factor)
        dummy_handler = ov.CappingStrategy()
        self.outlier_processor_ = self._get_processor(detection_strategy, dummy_handler)
        self.outlier_processor_.fit(self.flow.data)
        # print(f"Fit Rolling IQR (window={window}) ({self.flow.data_type}) selesai. Batasan (bounds) telah disimpan.")
        return self
 
    # --- FUNGSI-FUNGSI APPLY (FIT + TRANSFORM) ---
   
    def apply_iqr_capping(self, factor: float = 1.5) -> pd.DataFrame:
        """
        Menjalankan fit_iqr (deteksi) DAN transform_capping (penanganan)
        dalam satu panggilan pada data internal.
       
        Konteks: [timeseries, panel]
       
        Args:
            factor (float): Faktor pengali IQR. Default: 1.5.
           
        Returns:
            pd.DataFrame: DataFrame internal yang telah dibersihkan.
        """
        # print("Menjalankan apply_iqr_capping (fit+transform)...")
        self.fit_iqr(factor=factor)
        return self.transform_capping()
 
    def apply_zscore_imputation(self,
                                factor: float = 3.0,
                                strategy: Literal['median', 'mean'] = 'median'
                                ) -> pd.DataFrame:
        """
        Menjalankan fit_zscore (deteksi) DAN transform_imputation (penanganan)
        dalam satu panggilan pada data internal.
       
        Konteks: [timeseries, panel]
       
        Args:
            factor (float): Ambang batas Z-Score. Default: 3.0.
            strategy (Literal['median', 'mean']): Metode imputasi. Default: 'median'.
           
        Returns:
            pd.DataFrame: DataFrame internal yang telah dibersihkan.
        """
        # print(f"Menjalankan apply_zscore_imputation (fit+transform) dengan strategy='{strategy}'...")
        self.fit_zscore(factor=factor)
        return self.transform_imputation(strategy=strategy)
 
    def apply_rolling_iqr_locf(self, window: int = 20, factor: float = 1.5) -> pd.DataFrame:
        """
        Menjalankan fit_rolling_iqr (deteksi) DAN transform_locf (penanganan)
        dalam satu panggilan pada data internal.
       
        Konteks: [timeseries, panel]
       
        Args:
            window (int): Ukuran jendela (window) rolling. Default: 20.
            factor (float): Faktor pengali IQR. Default: 1.5.
           
        Returns:
            pd.DataFrame: DataFrame internal yang telah dibersihkan.
        """
        # print("Menjalankan apply_rolling_iqr_locf (fit+transform)...")
        self.fit_rolling_iqr(window=window, factor=factor)
        return self.transform_locf()
 
 
class _OutlierPanelAccessor(_OutlierTimeSeriesAccessor):
    """
    Aksesor Outlier untuk data Panel.
    Mewarisi semua metode dari TimeSeries, karena semua metode TS
    juga kompatibel dengan Panel (melalui dispatcher _get_processor).
    """
    # Saat ini tidak ada metode yang SPESIFIK hanya untuk Panel.
    # Jika ada di masa depan, tambahkan di sini.
    pass


# ===================================================================
# KELAS UTAMA (PUBLIK)
# ===================================================================

class AAPrepflowBase(ABC):
    """
    Kelas dasar untuk semua alur kerja AAPrepflow.
    Berisi logika umum yang tidak bergantung pada tipe data.
    """
    def __init__(self,
                 data: pd.DataFrame = pd.DataFrame(),
                 group_by: Optional[Union[str, List[str]]] = None,
                 col_time: Optional[str] = None):
        """
        Inisialisasi alur kerja (workflow) dasar.
        """
        self.data = data.copy()
        self.group_by = group_by
        self.col_time = col_time
        # Properti data_type masih berguna untuk logika internal _get_processor
        self.data_type: Literal['panel', 'timeseries', 'cross_section'] = self._determine_data_type()
 
    def _determine_data_type(self) -> Literal['panel', 'timeseries', 'cross_section']:
        """Helper internal untuk menentukan tipe data berdasarkan input."""
        if self.group_by is not None and self.col_time is not None:
            return 'panel'
        elif self.col_time is not None:
            return 'timeseries'
        else:
            return 'cross_section'
 
    def get_data(self) -> pd.DataFrame:
        """
        Mengembalikan DataFrame saat ini yang ada di dalam flow.
       
        Returns:
            pd.DataFrame: DataFrame saat ini.
        """
        return self.data
    
    def flow_lab(self):
        """Meluncurkan Lab UI Gradio untuk data internal."""
        lab = AA_Prepflow(self.data)
        self.lab = lab.call_preparation_lab()

# --- Implementasi Spesifik Tipe Data ---
 
class AAPrepflowCrossSection(AAPrepflowBase):
    """
    Alur kerja AAPrepflow khusus untuk data Cross-Section.
   
    Menyediakan aksesor .mv dan .outlier yang HANYA berisi
    metode-metode yang valid untuk data cross-section.
   
    Contoh:
    >>> from your_script import AAPrepflowCrossSection
    >>> flow_cs = AAPrepflowCrossSection(df)
    >>> # flow_cs.mv. akan menyarankan fit_knn_imputer, BUKAN fit_locf
    """
    # Type-hint statis untuk VSCode
    mv: _MVCrossSectionAccessor
    outlier: _OutlierCrossSectionAccessor
   
    def __init__(self, data: pd.DataFrame):
        """
        Inisialisasi alur kerja Cross-Section.
 
        Args:
            data (pd.DataFrame): DataFrame yang akan diproses.
        """
        # Panggil __init__ induk
        super().__init__(data, group_by=None, col_time=None)
       
        # Validasi bahwa tipenya benar
        if self.data_type != 'cross_section':
            # print(f"Peringatan: Data yang diberikan memiliki 'col_time' atau 'group_by', "
            #       "tetapi akan diperlakukan sebagai 'cross_section'.")
            self.data_type = 'cross_section' # Paksa tipe
       
        # Tetapkan aksesor yang TEPAT secara statis
        self.mv = _MVCrossSectionAccessor(self)
        self.outlier = _OutlierCrossSectionAccessor(self)
        # print("Info: AAPrepflowCrossSection diinisialisasi.")
 
 
class AAPrepflowTimeSeries(AAPrepflowBase):
    """
    Alur kerja AAPrepflow khusus untuk data Time-Series.
   
    Menyediakan aksesor .mv dan .outlier yang HANYA berisi
    metode-metode yang valid untuk data time-series.
   
    Contoh:
    >>> from your_script import AAPrepflowTimeSeries
    >>> flow_ts = AAPrepflowTimeSeries(df, col_time='Date')
    >>> # flow_ts.mv. akan menyarankan fit_locf, BUKAN fit_knn_imputer
    """
    # Type-hint statis untuk VSCode
    mv: _MVTimeSeriesAccessor
    outlier: _OutlierTimeSeriesAccessor
   
    def __init__(self, data: pd.DataFrame, col_time: str):
        """
        Inisialisasi alur kerja Time-Series.
 
        Args:
            data (pd.DataFrame): DataFrame yang akan diproses.
            col_time (str): Nama kolom waktu/tanggal.
        """
        super().__init__(data, group_by=None, col_time=col_time)
       
        if self.data_type != 'timeseries':
             raise ValueError("Inisialisasi AAPrepflowTimeSeries gagal: "
                              "pastikan 'col_time' disediakan dan 'group_by' tidak.")
 
        # Tetapkan aksesor yang TEPAT secara statis
        self.mv = _MVTimeSeriesAccessor(self)
        self.outlier = _OutlierTimeSeriesAccessor(self)
        # print("Info: AAPrepflowTimeSeries diinisialisasi.")
 
 
class AAPrepflowPanel(AAPrepflowTimeSeries):
    """
    Alur kerja AAPrepflow khusus untuk data Panel.
   
    Mewarisi dari AAPrepflowTimeSeries dan menggunakan aksesor Panel.
    Ini secara otomatis mendapatkan semua metode time-series.
   
    Contoh:
    >>> from your_script import AAPrepflowPanel
    >>> flow_panel = AAPrepflowPanel(df, group_by='City', col_time='Date')
    >>> # flow_panel.mv. akan menyarankan fit_locf dan metode panel lainnya.
    """
    # Type-hint statis untuk VSCode (meng-override induknya)
    mv: _MVPanelAccessor
    outlier: _OutlierPanelAccessor
   
    # Properti ini perlu didefinisikan ulang di sini untuk type-hinting yang tepat
    group_by: Optional[Union[str, List[str]]]
   
    def __init__(self,
                 data: pd.DataFrame,
                 group_by: Union[str, List[str]],
                 col_time: str):
        """
        Inisialisasi alur kerja Panel.
 
        Args:
            data (pd.DataFrame): DataFrame yang akan diproses.
            group_by (Union[str, List[str]]): Nama kolom grup.
            col_time (str): Nama kolom waktu/tanggal.
        """
        # Panggil __init__ dari TimeSeries (induknya)
        super().__init__(data, col_time=col_time)
       
        # Tambahkan properti group_by (tidak ada di induk TS)
        self.group_by = group_by
       
        # Perbarui/validasi data_type
        self.data_type = self._determine_data_type()
        if self.data_type != 'panel':
            raise ValueError("Inisialisasi AAPrepflowPanel gagal: "
                             "pastikan 'group_by' dan 'col_time' disediakan dengan benar.")
                             
        # Tetapkan aksesor yang TEPAT secara statis
        self.mv = _MVPanelAccessor(self)
        self.outlier = _OutlierPanelAccessor(self)
        # Ganti pesan info dari induk
        # print("Info: AAPrepflowPanel diinisialisasi.")


# ===================================================================
# Ringkasan Hasil / Summary Update (AAPrepflow Core)
# ===================================================================
# - Menambahkan metadata header (Notebook Title, Author, Date, Purpose).
# - Menambahkan docstring dan type hints di semua kelas dan metode publik/internal.
# - Menggunakan `from __future__ import annotations` untuk type hint yang lebih bersih.
# - Menambahkan docstring ke `flow_lab()`.
# - Tidak ada perubahan logika, hanya penambahan anotasi dan dokumentasi.