# Notebook Title: Missing Value Strategies
# Author: Advanced Analytics Team
# Date: 2025-11-13
# Purpose: Framework strategi penanganan missing value (CS, TS, Panel)

from __future__ import annotations

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

import numpy as np
import pandas as pd

# ML & TS Models (opsional, akan fallback bila tidak tersedia)
from sklearn.impute import KNNImputer
from sklearn.linear_model import LinearRegression
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.statespace.structural import UnobservedComponents


# ===================================================================
# Base Strategy Abstraction
# ===================================================================


class BaseMVStrategy(ABC):
    """Abstract base class untuk semua strategi missing value."""

    @abstractmethod
    def fit(self, df: pd.DataFrame) -> "BaseMVStrategy":
        """Fit strategi ke dataframe."""
        raise NotImplementedError

    @abstractmethod
    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Terapkan strategi ke dataframe."""
        raise NotImplementedError

    def fit_transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Shortcut untuk fit lalu transform."""
        return self.fit(df).transform(df)


# ===================================================================
# Cross-Section Strategies
# ===================================================================


class ListwiseDeletionStrategy(BaseMVStrategy):
    """Hapus baris yang memiliki NA di kolom tertentu atau semua kolom."""

    def __init__(self, columns: Optional[List[str]] = None):
        """Inisialisasi dengan daftar kolom target (opsional)."""
        self.columns = columns

    def fit(self, df: pd.DataFrame) -> "ListwiseDeletionStrategy":
        """Tidak mempelajari apa pun, hanya untuk konsistensi API."""
        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Drop baris yang mengandung NA di kolom target."""
        cols = self.columns if self.columns is not None else df.columns.tolist()
        return df.dropna(subset=cols)


class MeanMedianModeImputer(BaseMVStrategy):
    """Imputasi sederhana per kolom menggunakan mean/median/mode."""

    def __init__(
        self,
        strategy: str = "median",
        columns: Optional[List[str]] = None,
    ):
        """Set strategi (mean/median/mode) dan kolom target (opsional)."""
        assert strategy in ("mean", "median", "mode")
        self.strategy = strategy
        self.columns = columns
        self.fill_values_: Dict[str, float] = {}

    def fit(self, df: pd.DataFrame) -> "MeanMedianModeImputer":
        """Hitung nilai pengganti per kolom sesuai strategi."""
        import pandas.api.types as ptypes

        cols = self.columns if self.columns is not None else df.columns.tolist()
        for col in cols:
            series = df[col]
            if self.strategy in ("mean", "median"):
                if not ptypes.is_numeric_dtype(series):
                    continue
                if self.strategy == "mean":
                    self.fill_values_[col] = series.mean()
                else:
                    self.fill_values_[col] = series.median()
            else:
                if not series.mode().empty:
                    self.fill_values_[col] = series.mode().iloc[0]
                else:
                    self.fill_values_[col] = (
                        series.dropna().iloc[0] if series.dropna().size else np.nan
                    )
        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA dengan nilai yang sudah dihitung di fit()."""
        out = df.copy()
        for col, value in self.fill_values_.items():
            if col in out.columns:
                out[col] = out[col].fillna(value)
        return out


class HotDeckImputer(BaseMVStrategy):
    """Hot-deck imputation dengan donor global atau per grup."""

    def __init__(
        self,
        group_by: Optional[Union[str, List[str]]] = None,
        random_state: int = 42,
    ):
        """Set kolom pengelompokan (opsional) dan random state."""
        self.group_by = [group_by] if isinstance(group_by, str) else group_by
        self.random_state = random_state
        self.donors_: Dict[tuple, Dict[str, np.ndarray]] = {}
        self.global_donors_: Dict[str, np.ndarray] = {}
        self.rng_ = np.random.default_rng(random_state)

    def fit(self, df: pd.DataFrame) -> "HotDeckImputer":
        """Bangun pool donor global dan/atau per grup."""
        if self.group_by:
            for name, group in df.groupby(self.group_by):
                self.donors_[name] = {
                    col: group[col].dropna().values for col in df.columns
                }
        self.global_donors_ = {col: df[col].dropna().values for col in df.columns}
        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Sampel acak donor untuk mengisi NA di masing-masing kolom."""
        out = df.copy()

        if self.group_by:
            for name, idx in out.groupby(self.group_by).groups.items():
                sub = out.loc[idx]
                for col in sub.columns:
                    mask = sub[col].isna()
                    pool = self.donors_.get(name, {}).get(
                        col,
                        self.global_donors_.get(col, np.array([])),
                    )
                    if pool.size:
                        sub.loc[mask, col] = self.rng_.choice(
                            pool,
                            size=mask.sum(),
                            replace=True,
                        )
                out.loc[idx] = sub
        else:
            for col in out.columns:
                mask = out[col].isna()
                pool = self.global_donors_.get(col, np.array([]))
                if pool.size:
                    out.loc[mask, col] = self.rng_.choice(
                        pool,
                        size=mask.sum(),
                        replace=True,
                    )

        return out


class RegressionImputer(BaseMVStrategy):
    """Regresi linier per kolom target dari kolom numerik lain."""

    def __init__(self, target_columns: Optional[List[str]] = None):
        """Set daftar kolom target; default semua kolom numerik."""
        self.target_columns = target_columns
        self.coefs_: Dict[str, Dict[str, np.ndarray]] = {}
        self.used_features_: Dict[str, List[str]] = {}

    def fit(self, df: pd.DataFrame) -> "RegressionImputer":
        """Fit model regresi untuk tiap kolom target."""
        num_df = df.select_dtypes(include=[np.number])
        targets = self.target_columns or num_df.columns.tolist()

        for y_col in targets:
            x_cols = [col for col in num_df.columns if col != y_col]
            train = num_df.dropna(subset=[y_col] + x_cols)
            if train.empty:
                continue

            x_values = train[x_cols].values
            y_values = train[y_col].values
            if x_values.size == 0:
                continue

            model = LinearRegression()
            model.fit(x_values, y_values)
            self.coefs_[y_col] = {
                "coef": model.coef_,
                "intercept": model.intercept_,
            }
            self.used_features_[y_col] = x_cols

        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Prediksi dan isi NA di kolom target dengan regresi."""
        out = df.copy()

        for y_col, params in self.coefs_.items():
            if y_col not in out.columns:
                continue

            mask = out[y_col].isna()
            if not mask.any():
                continue

            x_cols = self.used_features_[y_col]
            x_missing = out.loc[mask, x_cols].dropna()
            idx_to_fill = x_missing.index
            if len(idx_to_fill) == 0:
                continue

            y_hat = x_missing.values @ params["coef"] + params["intercept"]
            out.loc[idx_to_fill, y_col] = y_hat

        return out


class KNNImputerStrategy(BaseMVStrategy):
    """KNN imputer untuk kolom numerik."""

    def __init__(self, n_neighbors: int = 5, weights: str = "uniform"):
        """Set jumlah tetangga dan jenis bobot."""
        self.n_neighbors = n_neighbors
        self.weights = weights
        self.imputer_: Optional[KNNImputer] = None
        self.columns_: Optional[List[str]] = None

    def fit(self, df: pd.DataFrame) -> "KNNImputerStrategy":
        """Fit KNNImputer pada kolom numerik."""
        num_df = df.select_dtypes(include=[np.number])
        self.columns_ = num_df.columns.tolist()

        if self.columns_:
            self.imputer_ = KNNImputer(
                n_neighbors=self.n_neighbors,
                weights=self.weights,
            )
            self.imputer_.fit(num_df[self.columns_])

        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA numerik menggunakan hasil KNNImputer."""
        out = df.copy()

        if not self.columns_:
            return out

        values = self.imputer_.transform(out[self.columns_])
        out[self.columns_] = values
        return out


# ===================================================================
# Time-Series Strategies
# ===================================================================


class _EnsureDatetimeIndexMixin:
    """Mixin untuk memastikan index adalah DatetimeIndex."""

    def _ensure_dt_index(self, df: pd.DataFrame) -> pd.DataFrame:
        """Validasi bahwa index bertipe DatetimeIndex."""
        if not isinstance(df.index, pd.DatetimeIndex):
            raise ValueError("Time series strategy expects a DatetimeIndex.")
        return df


class LOCFStrategy(BaseMVStrategy, _EnsureDatetimeIndexMixin):
    """Last Observation Carried Forward (forward-fill)."""

    def fit(self, df: pd.DataFrame) -> "LOCFStrategy":
        """Tidak mempelajari apa pun, hanya validasi tipe index."""
        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA dengan nilai terakhir yang tersedia (ffill)."""
        self._ensure_dt_index(df)
        return df.ffill()


class NOCBStrategy(BaseMVStrategy, _EnsureDatetimeIndexMixin):
    """Next Observation Carried Backward (backward-fill)."""

    def fit(self, df: pd.DataFrame) -> "NOCBStrategy":
        """Tidak mempelajari apa pun, hanya validasi tipe index."""
        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA dengan nilai berikutnya yang tersedia (bfill)."""
        self._ensure_dt_index(df)
        return df.bfill()


class InterpolationStrategy(BaseMVStrategy, _EnsureDatetimeIndexMixin):
    """Interpolasi time series numerik dengan beberapa metode."""

    def __init__(
        self,
        method: str = "linear",
        order: Optional[int] = None,
        window_ma: Optional[int] = None,
    ):
        """Set metode interpolasi, order (polynomial), dan window MA."""
        self.method = method
        self.order = order
        self.window_ma = window_ma

    def fit(self, df: pd.DataFrame) -> "InterpolationStrategy":
        """Tidak mempelajari apa pun, hanya validasi saat transform."""
        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA dengan interpolasi atau moving average."""
        import pandas.api.types as ptypes

        self._ensure_dt_index(df)
        out = df.copy()
        num_cols = [c for c in out.columns if ptypes.is_numeric_dtype(out[c])]

        if self.window_ma:
            for col in num_cols:
                roll = out[col].rolling(self.window_ma, min_periods=1).mean()
                out[col] = out[col].where(out[col].notna(), roll)
            return out

        for col in num_cols:
            if self.method == "polynomial":
                if self.order is None:
                    raise ValueError(
                        "Untuk polynomial interpolation, 'order' wajib diisi.",
                    )
                out[col] = out[col].interpolate(
                    method="polynomial",
                    order=self.order,
                    limit_direction="both",
                )
            else:
                out[col] = out[col].interpolate(
                    method=self.method,
                    limit_direction="both",
                )
        return out


class SeasonalImputerStrategy(BaseMVStrategy, _EnsureDatetimeIndexMixin):
    """Isi NA dengan rata-rata musiman (bulan/minggu/kuartal)."""

    def __init__(self, by: str = "month"):
        """Set level musiman: month, weekofyear, atau quarter."""
        assert by in ("month", "weekofyear", "quarter")
        self.by = by
        self.lookup_: Dict[str, pd.Series] = {}

    def fit(self, df: pd.DataFrame) -> "SeasonalImputerStrategy":
        """Hitung rata-rata musiman untuk tiap kolom numerik."""
        import pandas.api.types as ptypes

        self._ensure_dt_index(df)
        key_arr = {
            "month": df.index.month,
            "weekofyear": df.index.isocalendar().week.astype(int),
            "quarter": df.index.quarter,
        }[self.by]
        key_series = pd.Series(key_arr, index=df.index, name="__seasonal_key__")
        agg: Dict[str, pd.Series] = {}

        for col in df.columns:
            if not ptypes.is_numeric_dtype(df[col]):
                continue
            val_series = df[col].rename("__val__")
            tmp = pd.concat([key_series, val_series], axis=1).dropna(subset=["__val__"])
            if tmp.empty:
                continue
            agg[col] = tmp.groupby("__seasonal_key__")["__val__"].mean()

        self.lookup_ = agg
        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA dengan rata-rata per key musiman."""
        self._ensure_dt_index(df)
        out = df.copy()
        key = {
            "month": out.index.month,
            "weekofyear": out.index.isocalendar().week.astype(int),
            "quarter": out.index.quarter,
        }[self.by]

        for col in out.columns:
            if col not in self.lookup_:
                continue
            mask = out[col].isna()
            if not mask.any():
                continue
            fill = pd.Series(key, index=out.index).map(self.lookup_[col])
            out.loc[mask, col] = fill[mask]

        return out


class ARIMAImputerStrategy(BaseMVStrategy, _EnsureDatetimeIndexMixin):
    """Imputasi berbasis SARIMAX dengan nilai fitted time series."""

    def __init__(
        self,
        order: Tuple[int, int, int] = (1, 1, 0),
        seasonal_order: Tuple[int, int, int, int] = (0, 0, 0, 0),
    ):
        """Set parameter ARIMA dan seasonal_order."""
        self.order = order
        self.seasonal_order = seasonal_order
        self.fitted_: Dict[str, object] = {}
        self.freq_: Optional[str] = None

    def fit(self, df: pd.DataFrame) -> "ARIMAImputerStrategy":
        """Fit model SARIMAX per kolom numerik."""
        import pandas.api.types as ptypes

        self._ensure_dt_index(df)

        self.freq_ = pd.infer_freq(df.index)
        if self.freq_ is None:
            self.freq_ = "MS"

        for col in df.columns:
            if not ptypes.is_numeric_dtype(df[col]):
                continue
            try:
                model = SARIMAX(
                    df[col],
                    order=self.order,
                    seasonal_order=self.seasonal_order,
                    freq=self.freq_,
                    enforce_stationarity=False,
                    enforce_invertibility=False,
                )
                res = model.fit(disp=False)
                self.fitted_[col] = res
            except Exception:
                continue

        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA dengan prediksi SARIMAX pada rentang index penuh."""
        self._ensure_dt_index(df)
        out = df.copy()

        for col, res in self.fitted_.items():
            if col not in out.columns:
                continue
            if out[col].isna().any():
                fitted = res.predict(
                    start=out.index.min(),
                    end=out.index.max(),
                )
                out[col] = out[col].where(out[col].notna(), fitted)

        return out


class KalmanImputerStrategy(BaseMVStrategy, _EnsureDatetimeIndexMixin):
    """Imputasi berbasis Unobserved Components (Kalman smoothing)."""

    def __init__(self, level: str = "local level", seasonal: Optional[int] = None):
        """Set level komponen dan panjang musiman (opsional)."""
        self.level = level
        self.seasonal = seasonal
        self.fitted_: Dict[str, object] = {}
        self.freq_: Optional[str] = None

    def fit(self, df: pd.DataFrame) -> "KalmanImputerStrategy":
        """Fit UnobservedComponents per kolom numerik."""
        import pandas.api.types as ptypes

        self._ensure_dt_index(df)

        self.freq_ = pd.infer_freq(df.index)
        if self.freq_ is None:
            self.freq_ = "D"

        for col in df.columns:
            if not ptypes.is_numeric_dtype(df[col]):
                continue
            try:
                model = UnobservedComponents(
                    df[col],
                    level=self.level,
                    seasonal=self.seasonal,
                    freq=self.freq_,
                )
                res = model.fit(disp=False)
                self.fitted_[col] = res
            except Exception as e:
                pass

        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA dengan hasil smoothing Kalman yang diaplikasikan ke data baru."""
        self._ensure_dt_index(df)
        out = df.copy()

        for col, res in self.fitted_.items():
            if col not in out.columns:
                continue

            if out[col].isna().any():
                try:
                    new_res = res.apply(out[col])
                    fitted = new_res.predict(
                        start=out.index.min(),
                        end=out.index.max(),
                    )
                    out[col] = out[col].where(out[col].notna(), fitted)
                except Exception as e:
                    pass

        return out


class MovingAverageImputerStrategy(BaseMVStrategy):
    """
    Imputasi berbasis Moving Average dengan jaminan fill 100%.

    Time series: MA + expanding mean + fallback mean/0.
    Non-TS: mean per kolom, atau 0 jika semua NA.
    """

    def __init__(self, k: int = 2, side: str = "both"):
        """Set window k dan arah (both/past/future)."""
        assert isinstance(k, int) and k >= 1
        assert side in ("both", "past", "future")
        self.k = k
        self.side = side

    def fit(self, df: pd.DataFrame) -> "MovingAverageImputerStrategy":
        """Tidak mempelajari apa pun, hanya transform berbasis statistik lokal."""
        return self

    def _expanding_mean_past(self, s: pd.Series) -> pd.Series:
        """Hitung expanding mean ke arah masa lalu."""
        return s.shift(1).expanding(min_periods=1).mean()

    def _expanding_mean_future(self, s: pd.Series) -> pd.Series:
        """Hitung expanding mean ke arah masa depan."""
        rev = s[::-1].shift(1).expanding(min_periods=1).mean()
        return rev[::-1]

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA dengan MA, lalu expanding mean, lalu mean/0 jika perlu."""
        import pandas.api.types as ptypes

        out = df.copy()
        num_cols = [c for c in out.columns if ptypes.is_numeric_dtype(out[c])]
        if not num_cols:
            return out

        is_ts = isinstance(out.index, pd.DatetimeIndex)
        k = self.k

        for col in num_cols:
            series = out[col]

            if is_ts:
                if self.side == "both":
                    ma = series.rolling(
                        window=2 * k + 1,
                        center=True,
                        min_periods=1,
                    ).mean()
                elif self.side == "past":
                    ma = series.rolling(window=k, min_periods=1).mean()
                else:
                    ma = series.iloc[::-1].rolling(
                        window=k,
                        min_periods=1,
                    ).mean().iloc[::-1]

                filled = series.where(series.notna(), ma)

                if filled.isna().any():
                    if self.side == "past":
                        past = self._expanding_mean_past(filled)
                        filled = filled.where(filled.notna(), past)
                    elif self.side == "future":
                        fut = self._expanding_mean_future(filled)
                        filled = filled.where(filled.notna(), fut)
                    else:
                        past = self._expanding_mean_past(filled)
                        fut = self._expanding_mean_future(filled)
                        combo = pd.concat([past, fut], axis=1)
                        both_mean = combo.mean(axis=1, skipna=True)
                        filled = filled.where(filled.notna(), both_mean)

                if filled.isna().any():
                    col_mean = filled.mean(skipna=True)
                    if pd.isna(col_mean):
                        col_mean = 0.0
                    filled = filled.fillna(col_mean)

                out[col] = filled

            else:
                col_mean = series.mean(skipna=True)
                if pd.isna(col_mean):
                    col_mean = 0.0
                out[col] = series.fillna(col_mean)

        return out


class CAGRImputerStrategy(BaseMVStrategy):
    """
    Imputasi missing value dengan metode CAGR untuk time series.

    Tengah: geometric/CAGR, fallback linear.
    Ujung: geo extrapolation, fallback linear, lalu CF/BF, lalu mean/0.
    """

    def __init__(self, min_positive: float = 1e-9):
        """Set nilai minimum positif untuk perhitungan growth."""
        assert isinstance(min_positive, (int, float)) and min_positive >= 0
        self.min_positive = float(min_positive)

    def fit(self, df: pd.DataFrame) -> "CAGRImputerStrategy":
        """Tidak mempelajari apa pun, logika murni di transform()."""
        return self

    def _median_growth(self, s: pd.Series) -> float:
        """Hitung median growth berdasarkan perubahan persentase."""
        x = s.dropna().astype(float)
        valid = x[(x > self.min_positive)]
        if len(valid) < 2:
            return np.nan
        pct = (
            valid.pct_change()
            .dropna()
            .replace([np.inf, -np.inf], np.nan)
            .dropna()
        )
        if pct.empty:
            return np.nan
        return float(pct.median())

    def _fill_block_cagr(
        self,
        arr: List[Optional[float]],
        start_idx: int,
        end_idx: int,
    ) -> None:
        """Isi blok NA tengah dengan interpolasi CAGR bila memungkinkan."""
        v0 = arr[start_idx]
        v1 = arr[end_idx]
        steps = end_idx - start_idx
        if steps <= 1:
            return
        if (
            v0 is not None
            and v1 is not None
            and np.isfinite(v0)
            and np.isfinite(v1)
            and v0 > self.min_positive
            and v1 > self.min_positive
        ):
            r = (v1 / v0) ** (1.0 / steps) - 1.0
            for k in range(1, steps):
                arr[start_idx + k] = v0 * ((1.0 + r) ** k)

    def _fill_block_linear(
        self,
        arr: List[Optional[float]],
        start_idx: int,
        end_idx: int,
    ) -> None:
        """Isi blok NA tengah dengan interpolasi linear."""
        v0 = arr[start_idx]
        v1 = arr[end_idx]
        steps = end_idx - start_idx
        if (
            steps <= 1
            or v0 is None
            or v1 is None
            or not np.isfinite(v0)
            or not np.isfinite(v1)
        ):
            return
        delta = (v1 - v0) / steps
        for k in range(1, steps):
            arr[start_idx + k] = v0 + delta * k

    def _extrapolate_forward_geo(
        self,
        arr: List[Optional[float]],
        anchor_idx: int,
        g: float,
    ) -> bool:
        """Extrapolasi ke depan dengan growth rate geometrik."""
        v = arr[anchor_idx]
        if (
            v is None
            or not np.isfinite(v)
            or v <= self.min_positive
            or not np.isfinite(g)
        ):
            return False
        cur = v
        for i in range(anchor_idx + 1, len(arr)):
            if arr[i] is None or not np.isfinite(arr[i]):
                cur = cur * (1.0 + g)
                arr[i] = cur
        return True

    def _extrapolate_backward_geo(
        self,
        arr: List[Optional[float]],
        anchor_idx: int,
        g: float,
    ) -> bool:
        """Extrapolasi ke belakang dengan growth rate geometrik."""
        v = arr[anchor_idx]
        if (
            v is None
            or not np.isfinite(v)
            or v <= self.min_positive
            or not np.isfinite(g)
        ):
            return False
        cur = v
        for i in range(anchor_idx - 1, -1, -1):
            if arr[i] is None or not np.isfinite(arr[i]):
                cur = cur / (1.0 + g)
                arr[i] = cur
        return True

    def _extrapolate_linear(self, s: pd.Series) -> pd.Series:
        """Extrapolasi linear berdasarkan trend global seri."""
        y = s.values.astype(float)
        idx = np.arange(len(y), dtype=float)
        mask = np.isfinite(y)
        if mask.sum() >= 2:
            coef = np.polyfit(idx[mask], y[mask], 1)
            y_pred = np.polyval(coef, idx)
            y[~mask] = y_pred[~mask]
            return pd.Series(y, index=s.index)
        return s

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Isi NA time series dengan CAGR, linear, lalu fallback lain."""
        import pandas.api.types as ptypes

        out = df.copy()
        num_cols = [c for c in out.columns if ptypes.is_numeric_dtype(out[c])]
        if not num_cols:
            return out

        is_ts = isinstance(out.index, pd.DatetimeIndex)

        for col in num_cols:
            series = out[col]

            if is_ts:
                y = series.astype(float).values.copy()
                arr = [
                    None if (not np.isfinite(v)) else float(v)
                    for v in y
                ]

                i = 0
                n = len(arr)
                while i < n:
                    if arr[i] is not None and np.isfinite(arr[i]):
                        i += 1
                        continue
                    start = i - 1
                    while i < n and (
                        arr[i] is None or not np.isfinite(arr[i])
                    ):
                        i += 1
                    end = i
                    left_ok = start >= 0
                    right_ok = end < n
                    if left_ok and right_ok:
                        prev_snapshot = arr.copy()
                        self._fill_block_cagr(arr, start, end)
                        if any(
                            arr[k] is None
                            for k in range(start + 1, end)
                        ):
                            arr = prev_snapshot
                            self._fill_block_linear(arr, start, end)

                g_med = self._median_growth(series)
                last_valid = max(
                    [
                        i
                        for i, v in enumerate(arr)
                        if v is not None and np.isfinite(v)
                    ],
                    default=None,
                )
                if last_valid is not None and last_valid < n - 1:
                    self._extrapolate_forward_geo(arr, last_valid, g_med)

                first_valid = min(
                    [
                        i
                        for i, v in enumerate(arr)
                        if v is not None and np.isfinite(v)
                    ],
                    default=None,
                )
                if first_valid is not None and first_valid > 0:
                    self._extrapolate_backward_geo(arr, first_valid, g_med)

                ser = pd.Series(
                    [
                        np.nan
                        if (v is None or not np.isfinite(v))
                        else v
                        for v in arr
                    ],
                    index=series.index,
                )
                if ser.isna().any():
                    ser = self._extrapolate_linear(ser)
                if ser.isna().any():
                    ser = ser.ffill().bfill()
                if ser.isna().any():
                    col_mean = float(ser.mean(skipna=True))
                    if not np.isfinite(col_mean):
                        col_mean = 0.0
                    ser = ser.fillna(col_mean)
                out[col] = ser

            else:
                col_mean = series.mean(skipna=True)
                if pd.isna(col_mean) or not np.isfinite(col_mean):
                    col_mean = 0.0
                out[col] = series.fillna(col_mean)

        return out


# ===================================================================
# PANEL — core wrapper + processor + convenience strategies
# ===================================================================


class PanelGroupStrategy:
    """
    Bungkus base_strategy (TS) per grup, aware date_col.

    Date jadi index sementara per grup untuk strategi TS, lalu dikembalikan.
    """

    def __init__(
        self,
        base_strategy: BaseMVStrategy,
        group_by: Union[str, List[str]],
        date_col: Optional[str] = None,
        prefer_dayfirst: bool = True,
    ):
        """Set strategi dasar, kolom grup, kolom tanggal, dan preferensi parsing."""
        self.base = base_strategy
        self.group_by = [group_by] if isinstance(group_by, str) else list(group_by)
        self.date_col = date_col
        self.prefer_dayfirst = prefer_dayfirst
        self.fitted_: Dict[tuple, BaseMVStrategy] = {}

    def _clone_base(self) -> BaseMVStrategy:
        """Duplikasi objek base_strategy tanpa shared state."""
        obj = self.base.__class__.__new__(self.base.__class__)
        obj.__dict__ = {**self.base.__dict__}
        return obj

    def _prep_payload(self, group_df: pd.DataFrame) -> pd.DataFrame:
        """Siapkan payload per grup: drop group_by, set index ke date (sementara)."""
        payload = group_df.drop(columns=self.group_by, errors="ignore").copy()
        payload["__rowid__"] = np.arange(len(payload))
        if self.date_col and self.date_col in payload.columns:
            try:
                payload[self.date_col] = pd.to_datetime(
                    payload[self.date_col],
                    errors="coerce",
                    dayfirst=self.prefer_dayfirst,
                )
            except Exception:
                payload[self.date_col] = pd.to_datetime(
                    payload[self.date_col],
                    errors="coerce",
                )
            payload = payload.set_index(self.date_col).sort_index()
        return payload

    def _merge_back(
        self,
        original_group: pd.DataFrame,
        imputed_payload: pd.DataFrame,
    ) -> pd.DataFrame:
        """Gabungkan hasil imputasi ke dataframe grup asli dengan __rowid__."""
        group_df = original_group.copy()
        group_df["__rowid__"] = np.arange(len(group_df))
        imp = imputed_payload.reset_index()

        if (
            self.date_col
            and self.date_col in imp.columns
            and self.date_col in group_df.columns
        ):
            imp = imp.drop(columns=[self.date_col])

        merged = group_df.merge(
            imp,
            on="__rowid__",
            how="left",
            suffixes=("", "_imp"),
        )

        payload_cols = [
            c
            for c in imputed_payload.columns
            if c not in (self.date_col, "__rowid__")
        ]
        for col in payload_cols:
            col_imp = f"{col}_imp"
            if col_imp in merged.columns:
                mask = merged[col_imp].notna()
                merged.loc[mask, col] = merged.loc[mask, col_imp]
                merged.drop(columns=[col_imp], inplace=True)

        merged.drop(columns=["__rowid__"], inplace=True)
        return merged

    def fit(self, df: pd.DataFrame) -> "PanelGroupStrategy":
        """Fit strategi per grup berdasarkan kolom group_by dan date_col."""
        missing_groups = [g for g in self.group_by if g not in df.columns]
        if missing_groups:
            raise ValueError(f"Kolom grup hilang: {missing_groups}")
        if self.date_col and self.date_col not in df.columns:
            raise ValueError(f"Kolom tanggal '{self.date_col}' tidak ditemukan.")

        self.fitted_.clear()
        for key, group in df.groupby(self.group_by, dropna=False):
            strat = self._clone_base()
            payload = self._prep_payload(group)
            strat.fit(payload)
            self.fitted_[key] = strat
        return self

    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """Terapkan strategi per grup dan gabungkan hasilnya."""
        if not self.fitted_:
            raise RuntimeError("PanelGroupStrategy: transform sebelum fit.")
        missing_groups = [g for g in self.group_by if g not in df.columns]
        if missing_groups:
            raise ValueError(f"Kolom grup hilang: {missing_groups}")
        if self.date_col and self.date_col not in df.columns:
            raise ValueError(f"Kolom tanggal '{self.date_col}' tidak ditemukan.")

        chunks: List[pd.DataFrame] = []
        for key, group in df.groupby(self.group_by, dropna=False):
            strat = self.fitted_.get(key)
            if strat is None:
                strat = self._clone_base()
                strat.fit(self._prep_payload(group))
            payload = self._prep_payload(group)
            imputed = strat.transform(payload)
            merged = self._merge_back(group, imputed)
            chunks.append(merged)

        out = pd.concat(chunks, axis=0)
        return out


# ===================================================================
# Processors (struktur pemakaian seragam)
# ===================================================================


class CrossSectionMVProcessor:
    """Prosesor untuk data cross-section dengan strategi tunggal."""

    def __init__(self, impute_strategy: BaseMVStrategy):
        """Simpan strategi imputasi yang akan digunakan."""
        self.impute_strategy = impute_strategy
        self.fitted_ = False

    def fit(self, X: pd.DataFrame) -> "CrossSectionMVProcessor":
        """Fit strategi pada dataframe input."""
        self.impute_strategy.fit(X)
        self.fitted_ = True
        return self

    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Transform dataframe dengan strategi yang sudah di-fit."""
        if not self.fitted_:
            raise RuntimeError("Transform called before fit.")
        return self.impute_strategy.transform(X)

    def fit_transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Shortcut untuk fit lalu transform pada X yang sama."""
        return self.fit(X).transform(X)


class TimeSeriesMVProcessor:
    """
    Prosesor untuk time series dengan kolom tanggal eksplisit.

    date_col otomatis dikonversi jadi index saat fit/transform, lalu dikembalikan.
    """

    def __init__(self, impute_strategy: BaseMVStrategy, date_col: str):
        """Set strategi imputasi dan nama kolom tanggal."""
        self.impute_strategy = impute_strategy
        self.date_col = date_col
        self.fitted_ = False

    def _prepare(self, X: pd.DataFrame) -> pd.DataFrame:
        """Konversi date_col ke DatetimeIndex dan sort index."""
        if self.date_col not in X.columns:
            raise ValueError(f"Kolom tanggal '{self.date_col}' tidak ditemukan.")
        df = X.copy()
        df[self.date_col] = pd.to_datetime(df[self.date_col], errors="coerce")
        df = df.set_index(self.date_col).sort_index()
        return df

    def fit(self, X: pd.DataFrame) -> "TimeSeriesMVProcessor":
        """Fit strategi imputasi pada data yang sudah disiapkan."""
        df = self._prepare(X)
        self.impute_strategy.fit(df)
        self.fitted_ = True
        return self

    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Terapkan strategi pada data baru, lalu reset index ke kolom tanggal."""
        if not self.fitted_:
            raise RuntimeError("Transform called before fit.")
        df = self._prepare(X)
        out = (
            self.impute_strategy.transform(df)
            .reset_index()
            .rename(columns={"index": self.date_col})
        )
        return out

    def fit_transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Shortcut untuk fit lalu transform pada X yang sama."""
        return self.fit(X).transform(X)


class PanelMVProcessor:
    """
    Prosesor panel data dengan strategi time series per grup.

    Contoh:
      base_ts_imputer = InterpolationStrategy('linear')
      processor = PanelMVProcessor(base_ts_imputer, group_by='City', date_col='Date')
    """

    def __init__(
        self,
        base_impute_strategy: BaseMVStrategy,
        group_by: Union[str, List[str]],
        date_col: str,
        prefer_dayfirst: bool = True,
        sort_output: Literal["index", "group_date", "none"] = "group_date",
    ):
        """Set strategi, kolom grup, kolom tanggal, dan opsi sorting output."""
        self.group_by = [group_by] if isinstance(group_by, str) else list(group_by)
        self.date_col = date_col
        self.pref = prefer_dayfirst
        self.sort_output = sort_output
        self.panel = PanelGroupStrategy(
            base_impute_strategy,
            group_by=self.group_by,
            date_col=date_col,
            prefer_dayfirst=prefer_dayfirst,
        )
        self.fitted_ = False

    def _coerce_date(self, X: pd.DataFrame) -> pd.DataFrame:
        """Pastikan kolom tanggal terkonversi ke datetime."""
        if self.date_col not in X.columns:
            raise ValueError(f"Kolom tanggal '{self.date_col}' tidak ditemukan.")
        out = X.copy()
        try:
            out[self.date_col] = pd.to_datetime(
                out[self.date_col],
                errors="coerce",
                dayfirst=self.pref,
            )
        except Exception:
            out[self.date_col] = pd.to_datetime(out[self.date_col], errors="coerce")
        return out

    def _check_group(self, X: pd.DataFrame) -> None:
        """Validasi bahwa semua kolom grup tersedia di dataframe."""
        missing = [g for g in self.group_by if g not in X.columns]
        if missing:
            raise ValueError(f"Kolom grup hilang: {missing}")

    def fit(self, X: pd.DataFrame) -> "PanelMVProcessor":
        """Fit strategi panel per grup dan tanggal."""
        Xc = self._coerce_date(X)
        self._check_group(Xc)
        self.panel.fit(Xc)
        self.fitted_ = True
        return self

    def transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Terapkan strategi panel ke data baru, dengan opsi sort output."""
        if not self.fitted_:
            raise RuntimeError("PanelMVProcessor: transform sebelum fit.")
        Xc = self._coerce_date(X)
        self._check_group(Xc)
        out = self.panel.transform(Xc)

        if self.sort_output == "index":
            out = out.sort_index()
        elif self.sort_output == "group_date":
            keys = [*self.group_by, self.date_col]
            keys = [k for k in keys if k in out.columns]
            if keys:
                out = out.sort_values(keys).reset_index(drop=True)

        return out

    def fit_transform(self, X: pd.DataFrame) -> pd.DataFrame:
        """Shortcut untuk fit lalu transform pada X yang sama."""
        return self.fit(X).transform(X)


# ===================================================================
# Ringkasan Hasil / Summary Update (v1.2)
# ===================================================================
# - Menambahkan docstring singkat untuk semua class, method, dan helper.
# - Menstandardisasi struktur import: Standard Libraries, Visualization, ML & TS Models.
# - Memperjelas strategi time series (ARIMA, Kalman, MA, CAGR) dan panel:
#   * ARIMAImputerStrategy & KalmanImputerStrategy sekarang infer freq index.
#   * PanelGroupStrategy tidak lagi double-wrapping panel, merge via __rowid__.
# - Menyelaraskan naming convention (snake_case / PascalCase / UPPER_CASE).
# - Menjadikan modul siap dipakai sebagai backend untuk notebook yang mengikuti guideline.