"""
Feature generators for AutoFE-PG v0.3.0.

Each generator produces one or more columns and implements:
- fit_transform(df, y, fold_indices) → pd.DataFrame (train features)
- transform(df) → pd.DataFrame (test features)

All generators that use the target y do so in an out-of-fold manner
to prevent target leakage.
"""

import numpy as np
import pandas as pd
from typing import Dict, List, Optional, Tuple

from sklearn.preprocessing import LabelEncoder


# ============================================================================
# BASE CLASS
# ============================================================================
class FeatureGenerator:
    """Abstract base class for feature generators."""

    def __init__(self, name: str):
        self.name = name

    def fit_transform(self, df, y, fold_indices):
        raise NotImplementedError

    def transform(self, df):
        raise NotImplementedError


# ============================================================================
# TARGET ENCODING (OUT-OF-FOLD)
# ============================================================================
class TargetEncoding(FeatureGenerator):
    """Out-of-fold target encoding for a single column."""

    def __init__(self, col: str, smoothing: float = 20.0, suffix: str = ""):
        nm = f"te__{col}" if not suffix else f"te__{col}_{suffix}"
        super().__init__(nm)
        self.col = col
        self.smoothing = smoothing
        self.global_mean_ = None
        self.mapping_ = None

    def fit_transform(self, df, y, fold_indices):
        self.global_mean_ = y.mean()
        result = pd.Series(np.nan, index=df.index, dtype=float)
        for fold_idx, (tr_idx, va_idx) in enumerate(fold_indices):
            tr_col = df[self.col].iloc[tr_idx]
            tr_y = y.iloc[tr_idx]
            stats = tr_y.groupby(tr_col).agg(["mean", "count"])
            smooth = (
                stats["count"] * stats["mean"] + self.smoothing * self.global_mean_
            ) / (stats["count"] + self.smoothing)
            result.iloc[va_idx] = df[self.col].iloc[va_idx].map(smooth)
        full_stats = y.groupby(df[self.col]).agg(["mean", "count"])
        self.mapping_ = (
            full_stats["count"] * full_stats["mean"]
            + self.smoothing * self.global_mean_
        ) / (full_stats["count"] + self.smoothing)
        result = result.fillna(self.global_mean_)
        return pd.DataFrame({self.name: result.values}, index=df.index)

    def transform(self, df):
        vals = df[self.col].map(self.mapping_).fillna(self.global_mean_)
        return pd.DataFrame({self.name: vals.values}, index=df.index)


# ============================================================================
# COUNT ENCODING
# ============================================================================
class CountEncoding(FeatureGenerator):
    """Count encoding for a single column."""

    def __init__(self, col: str, suffix: str = ""):
        nm = f"ce__{col}" if not suffix else f"ce__{col}_{suffix}"
        super().__init__(nm)
        self.col = col
        self.mapping_ = None

    def fit_transform(self, df, y, fold_indices):
        self.mapping_ = df[self.col].value_counts()
        vals = df[self.col].map(self.mapping_).fillna(0)
        return pd.DataFrame({self.name: vals.values}, index=df.index)

    def transform(self, df):
        vals = df[self.col].map(self.mapping_).fillna(0)
        return pd.DataFrame({self.name: vals.values}, index=df.index)


# ============================================================================
# FREQUENCY ENCODING
# ============================================================================
class FrequencyEncoding(FeatureGenerator):
    """Frequency (normalized count) encoding for a column."""

    def __init__(self, col: str):
        super().__init__(f"freq__{col}")
        self.col = col
        self.mapping_ = None

    def fit_transform(self, df, y, fold_indices):
        vc = df[self.col].value_counts(normalize=True)
        self.mapping_ = vc
        vals = df[self.col].map(vc).fillna(0)
        return pd.DataFrame({self.name: vals.values}, index=df.index)

    def transform(self, df):
        vals = df[self.col].map(self.mapping_).fillna(0)
        return pd.DataFrame({self.name: vals.values}, index=df.index)


# ============================================================================
# DIGIT EXTRACTION (INTEGER)
# ============================================================================
class DigitFeature(FeatureGenerator):
    """Extract the i-th integer digit of a numerical feature."""

    def __init__(self, col: str, digit_pos: int):
        super().__init__(f"digit__{col}_pos{digit_pos}")
        self.col = col
        self.digit_pos = digit_pos

    def _extract(self, series: pd.Series) -> pd.Series:
        abs_int = series.fillna(0).abs().astype(np.int64)
        divisor = np.int64(10 ** self.digit_pos)
        return ((abs_int // divisor) % 10).astype(np.int8)

    def fit_transform(self, df, y, fold_indices):
        vals = self._extract(df[self.col])
        return pd.DataFrame({self.name: vals.values}, index=df.index)

    def transform(self, df):
        vals = self._extract(df[self.col])
        return pd.DataFrame({self.name: vals.values}, index=df.index)


# ============================================================================
# DECIMAL DIGIT EXTRACTION
# ============================================================================
class DecimalDigitFeature(FeatureGenerator):
    """Extract the i-th decimal digit of a numerical feature.

    For 3.14159: decimal_pos=0 → 1, decimal_pos=1 → 4, etc.
    """

    def __init__(self, col: str, decimal_pos: int):
        super().__init__(f"decdgt__{col}_pos{decimal_pos}")
        self.col = col
        self.decimal_pos = decimal_pos

    def _extract(self, series: pd.Series) -> pd.Series:
        vals = series.fillna(0).abs().astype(float)
        shifted = vals * (10 ** (self.decimal_pos + 1))
        return (shifted.astype(np.int64) % 10).astype(np.int8)

    def fit_transform(self, df, y, fold_indices):
        vals = self._extract(df[self.col])
        return pd.DataFrame({self.name: vals.values}, index=df.index)

    def transform(self, df):
        vals = self._extract(df[self.col])
        return pd.DataFrame({self.name: vals.values}, index=df.index)


# ============================================================================
# DIGIT INTERACTION
# ============================================================================
class DigitInteraction(FeatureGenerator):
    """Interaction between digits of numerical features."""

    def __init__(self, col_digit_pairs: List[Tuple[str, int]]):
        name_parts = "_".join([f"{c}d{d}" for c, d in col_digit_pairs])
        super().__init__(f"digitint__{name_parts}")
        self.col_digit_pairs = col_digit_pairs

    def _extract_digit(self, series: pd.Series, pos: int) -> np.ndarray:
        abs_int = series.fillna(0).abs().astype(np.int64).values
        divisor = np.int64(10 ** pos)
        return (abs_int // divisor) % 10

    def _compute(self, df):
        result = np.zeros(len(df), dtype=np.int64)
        for col, dpos in self.col_digit_pairs:
            digits = self._extract_digit(df[col], dpos)
            result = result * 10 + digits
        return result

    def fit_transform(self, df, y, fold_indices):
        vals = self._compute(df)
        return pd.DataFrame({self.name: vals}, index=df.index)

    def transform(self, df):
        vals = self._compute(df)
        return pd.DataFrame({self.name: vals}, index=df.index)


# ============================================================================
# QUANTILE BINNING (qcut)
# ============================================================================
class QuantileBinFeature(FeatureGenerator):
    """Bin a numerical column into quantile bins."""

    def __init__(self, col: str, n_bins: int = 10):
        super().__init__(f"qbin__{col}_b{n_bins}")
        self.col = col
        self.n_bins = n_bins
        self.bin_edges_ = None
        self.fill_median_ = None

    def fit_transform(self, df, y, fold_indices):
        self.fill_median_ = df[self.col].median()
        vals = df[self.col].fillna(self.fill_median_)
        try:
            binned, edges = pd.qcut(
                vals, q=self.n_bins, labels=False, retbins=True, duplicates="drop"
            )
        except Exception:
            binned = pd.Series(0, index=df.index)
            edges = None
        self.bin_edges_ = edges
        return pd.DataFrame({self.name: binned.values}, index=df.index)

    def transform(self, df):
        vals = df[self.col].fillna(
            self.fill_median_ if self.fill_median_ is not None else 0
        )
        if self.bin_edges_ is not None:
            binned = pd.cut(vals, bins=self.bin_edges_, labels=False, include_lowest=True)
            binned = binned.fillna(0).astype(int)
        else:
            binned = pd.Series(0, index=df.index)
        return pd.DataFrame({self.name: binned.values}, index=df.index)


# ============================================================================
# EQUAL-WIDTH BINNING (cut) / NUM TO CAT
# ============================================================================
class NumToCat(FeatureGenerator):
    """Convert a numerical column into a categorical one via equal-width binning."""

    def __init__(self, col: str, n_bins: int = 5):
        super().__init__(f"num2cat__{col}_b{n_bins}")
        self.col = col
        self.n_bins = n_bins
        self.bin_edges_ = None
        self.fill_median_ = None

    def fit_transform(self, df, y, fold_indices):
        self.fill_median_ = df[self.col].median()
        vals = df[self.col].fillna(self.fill_median_)
        try:
            binned, edges = pd.cut(
                vals, bins=self.n_bins, labels=False, retbins=True, duplicates="drop"
            )
        except Exception:
            binned = pd.Series(0, index=df.index)
            edges = None
        self.bin_edges_ = edges
        return pd.DataFrame(
            {self.name: binned.fillna(0).astype(int).values}, index=df.index
        )

    def transform(self, df):
        vals = df[self.col].fillna(
            self.fill_median_ if self.fill_median_ is not None else 0
        )
        if self.bin_edges_ is not None:
            binned = pd.cut(vals, bins=self.bin_edges_, labels=False, include_lowest=True)
            binned = binned.fillna(0).astype(int)
        else:
            binned = pd.Series(0, index=df.index)
        return pd.DataFrame({self.name: binned.values}, index=df.index)


# ============================================================================
# ROUNDING
# ============================================================================
class RoundFeature(FeatureGenerator):
    """Round a numerical feature to a given number of decimals or magnitude."""

    def __init__(self, col: str, decimals: int):
        super().__init__(f"round__{col}_dec{decimals}")
        self.col = col
        self.decimals = decimals

    def fit_transform(self, df, y, fold_indices):
        vals = df[self.col].fillna(0).round(self.decimals)
        return pd.DataFrame({self.name: vals.values}, index=df.index)

    def transform(self, df):
        vals = df[self.col].fillna(0).round(self.decimals)
        return pd.DataFrame({self.name: vals.values}, index=df.index)


# ============================================================================
# DUAL REPRESENTATION (CONTINUOUS + CATEGORICAL COPY)
# ============================================================================
class DualRepresentationFeature(FeatureGenerator):
    """Emit both a continuous and a categorical (label-encoded) copy of a column."""

    def __init__(self, col: str):
        super().__init__(f"dual__{col}")
        self.col = col
        self._le_dict: Optional[Dict] = None

    def fit_transform(self, df, y, fold_indices):
        continuous = df[self.col].fillna(0).astype(float)
        cat_key = df[self.col].fillna("__NAN__").astype(str)
        le = LabelEncoder()
        categorical = le.fit_transform(cat_key)
        self._le_dict = {cls: idx for idx, cls in enumerate(le.classes_)}
        return pd.DataFrame(
            {f"{self.name}_cont": continuous.values, f"{self.name}_cat": categorical},
            index=df.index,
        )

    def transform(self, df):
        continuous = df[self.col].fillna(0).astype(float)
        cat_key = df[self.col].fillna("__NAN__").astype(str)
        categorical = cat_key.map(self._le_dict).fillna(-1).astype(int)
        return pd.DataFrame(
            {f"{self.name}_cont": continuous.values, f"{self.name}_cat": categorical.values},
            index=df.index,
        )


# ============================================================================
# PAIR INTERACTION (LABEL-ENCODED)
# ============================================================================
class PairInteraction(FeatureGenerator):
    """Create a label-encoded interaction feature between two columns."""

    def __init__(self, col_a: str, col_b: str):
        super().__init__(f"pair__{col_a}_x_{col_b}")
        self.col_a = col_a
        self.col_b = col_b
        self._le_dict = None

    def fit_transform(self, df, y, fold_indices):
        s = df[self.col_a].astype(str) + "__" + df[self.col_b].astype(str)
        le = LabelEncoder()
        vals = le.fit_transform(s.fillna("__NAN__"))
        self._le_dict = {cls: idx for idx, cls in enumerate(le.classes_)}
        return pd.DataFrame({self.name: vals}, index=df.index)

    def transform(self, df):
        s = df[self.col_a].astype(str) + "__" + df[self.col_b].astype(str)
        s = s.fillna("__NAN__")
        mapped = s.map(self._le_dict).fillna(-1).astype(int)
        return pd.DataFrame({self.name: mapped.values}, index=df.index)


# ============================================================================
# PRODUCTS OF BIGRAMS (PAIR PRODUCT)
# ============================================================================
class PairProductFeature(FeatureGenerator):
    """Product of two numerical columns (NaN-propagating)."""

    def __init__(self, col_a: str, col_b: str):
        super().__init__(f"prod__{col_a}_x_{col_b}")
        self.col_a = col_a
        self.col_b = col_b

    def _compute(self, df):
        return df[self.col_a].astype(float) * df[self.col_b].astype(float)

    def fit_transform(self, df, y, fold_indices):
        vals = self._compute(df)
        return pd.DataFrame({self.name: vals.values}, index=df.index)

    def transform(self, df):
        vals = self._compute(df)
        return pd.DataFrame({self.name: vals.values}, index=df.index)


# ============================================================================
# COMPOSITE: TE/CE ON PAIRS
# ============================================================================
class TargetEncodingOnPair(FeatureGenerator):
    """Target encoding on a pair interaction. OOF — no leakage."""

    def __init__(self, col_a: str, col_b: str, smoothing: float = 20.0):
        super().__init__(f"te__pair_{col_a}_x_{col_b}")
        self.col_a = col_a
        self.col_b = col_b
        self.smoothing = smoothing
        self.te_ = None

    def _make_pair_col(self, df):
        return df[self.col_a].astype(str) + "__" + df[self.col_b].astype(str)

    def fit_transform(self, df, y, fold_indices):
        pair_col_name = "__pair__"
        df[pair_col_name] = self._make_pair_col(df)
        self.te_ = TargetEncoding(pair_col_name, self.smoothing, suffix=f"pair_{self.col_a}_{self.col_b}")
        self.te_.name = self.name
        result = self.te_.fit_transform(df, y, fold_indices)
        del df[pair_col_name]
        result.columns = [self.name]
        return result

    def transform(self, df):
        pair_col_name = "__pair__"
        df[pair_col_name] = self._make_pair_col(df)
        result = self.te_.transform(df)
        del df[pair_col_name]
        result.columns = [self.name]
        return result


class CountEncodingOnPair(FeatureGenerator):
    """Count encoding on a pair interaction. No target used."""

    def __init__(self, col_a: str, col_b: str):
        super().__init__(f"ce__pair_{col_a}_x_{col_b}")
        self.col_a = col_a
        self.col_b = col_b
        self.ce_ = None

    def _make_pair_col(self, df):
        return df[self.col_a].astype(str) + "__" + df[self.col_b].astype(str)

    def fit_transform(self, df, y, fold_indices):
        pair_col_name = "__pair__"
        df[pair_col_name] = self._make_pair_col(df)
        self.ce_ = CountEncoding(pair_col_name, suffix=f"pair_{self.col_a}_{self.col_b}")
        self.ce_.name = self.name
        result = self.ce_.fit_transform(df, y, fold_indices)
        del df[pair_col_name]
        result.columns = [self.name]
        return result

    def transform(self, df):
        pair_col_name = "__pair__"
        df[pair_col_name] = self._make_pair_col(df)
        result = self.ce_.transform(df)
        del df[pair_col_name]
        result.columns = [self.name]
        return result


# ============================================================================
# COMPOSITE: TE/CE ON DIGITS
# ============================================================================
class TargetEncodingOnDigit(FeatureGenerator):
    """Target encoding on a digit feature. OOF — no leakage."""

    def __init__(self, col: str, digit_pos: int, smoothing: float = 20.0):
        super().__init__(f"te__digit_{col}_pos{digit_pos}")
        self.col = col
        self.digit_pos = digit_pos
        self.smoothing = smoothing
        self.digit_gen_ = None
        self.te_ = None

    def fit_transform(self, df, y, fold_indices):
        self.digit_gen_ = DigitFeature(self.col, self.digit_pos)
        digit_df = self.digit_gen_.fit_transform(df, y, fold_indices)
        digit_col_name = self.digit_gen_.name
        df[digit_col_name] = digit_df[digit_col_name].values
        self.te_ = TargetEncoding(digit_col_name, self.smoothing)
        self.te_.name = self.name
        result = self.te_.fit_transform(df, y, fold_indices)
        del df[digit_col_name]
        result.columns = [self.name]
        return result

    def transform(self, df):
        digit_df = self.digit_gen_.transform(df)
        digit_col_name = self.digit_gen_.name
        df[digit_col_name] = digit_df[digit_col_name].values
        result = self.te_.transform(df)
        del df[digit_col_name]
        result.columns = [self.name]
        return result


class CountEncodingOnDigit(FeatureGenerator):
    """Count encoding on a digit feature. No target used."""

    def __init__(self, col: str, digit_pos: int):
        super().__init__(f"ce__digit_{col}_pos{digit_pos}")
        self.col = col
        self.digit_pos = digit_pos
        self.digit_gen_ = None
        self.ce_ = None

    def fit_transform(self, df, y, fold_indices):
        self.digit_gen_ = DigitFeature(self.col, self.digit_pos)
        digit_df = self.digit_gen_.fit_transform(df, y, fold_indices)
        digit_col_name = self.digit_gen_.name
        df[digit_col_name] = digit_df[digit_col_name].values
        self.ce_ = CountEncoding(digit_col_name)
        self.ce_.name = self.name
        result = self.ce_.fit_transform(df, y, fold_indices)
        del df[digit_col_name]
        result.columns = [self.name]
        return result

    def transform(self, df):
        digit_df = self.digit_gen_.transform(df)
        digit_col_name = self.digit_gen_.name
        df[digit_col_name] = digit_df[digit_col_name].values
        result = self.ce_.transform(df)
        del df[digit_col_name]
        result.columns = [self.name]
        return result


# ============================================================================
# COMPOSITE: DIGIT x CATEGORY TE
# ============================================================================
class DigitBasePairTE(FeatureGenerator):
    """Interaction of a digit of a numerical column with a categorical column, then TE. OOF."""

    def __init__(self, num_col: str, digit_pos: int, cat_col: str, smoothing: float = 20.0):
        super().__init__(f"te__digit_{num_col}_d{digit_pos}_x_{cat_col}")
        self.num_col = num_col
        self.digit_pos = digit_pos
        self.cat_col = cat_col
        self.smoothing = smoothing
        self.digit_gen_ = None
        self.te_ = None

    def _make_pair(self, df, digit_vals):
        return digit_vals.astype(str) + "__" + df[self.cat_col].astype(str)

    def fit_transform(self, df, y, fold_indices):
        self.digit_gen_ = DigitFeature(self.num_col, self.digit_pos)
        digit_df = self.digit_gen_.fit_transform(df, y, fold_indices)
        digit_vals = digit_df.iloc[:, 0]
        tmp_col_name = "__dbc__"
        df[tmp_col_name] = self._make_pair(df, digit_vals)
        self.te_ = TargetEncoding(tmp_col_name, self.smoothing)
        self.te_.name = self.name
        result = self.te_.fit_transform(df, y, fold_indices)
        del df[tmp_col_name]
        result.columns = [self.name]
        return result

    def transform(self, df):
        digit_df = self.digit_gen_.transform(df)
        digit_vals = digit_df.iloc[:, 0]
        tmp_col_name = "__dbc__"
        df[tmp_col_name] = self._make_pair(df, digit_vals)
        result = self.te_.transform(df)
        del df[tmp_col_name]
        result.columns = [self.name]
        return result


# ============================================================================
# OOF TARGET AGGREGATION (MEAN / STD / SKEW)
# ============================================================================
class OOFTargetAggFeature(FeatureGenerator):
    """Out-of-fold target aggregation with multiple statistics per group.

    Supported stats: "mean", "std", "skew", "count", "min", "max".
    """

    def __init__(self, col: str, stats: Optional[List[str]] = None, smoothing: float = 20.0):
        super().__init__(f"oof_agg__{col}")
        self.col = col
        self.stats = stats or ["mean", "std", "skew"]
        self.smoothing = smoothing
        self.global_mean_: Optional[float] = None
        self.global_min_: Optional[float] = None
        self.global_max_: Optional[float] = None
        self.mappings_: Optional[Dict[str, pd.Series]] = None

    def _col_name(self, stat: str) -> str:
        return f"oof_agg__{self.col}__{stat}"

    def _compute_fold_stats(self, group_col, target):
        grouped = target.groupby(group_col)
        result = {}
        if "mean" in self.stats:
            agg = grouped.agg(["mean", "count"])
            smoothed = (
                agg["count"] * agg["mean"] + self.smoothing * self.global_mean_
            ) / (agg["count"] + self.smoothing)
            result["mean"] = smoothed
        if "std" in self.stats:
            result["std"] = grouped.std().fillna(0.0)
        if "skew" in self.stats:
            try:
                result["skew"] = grouped.apply(
                    lambda x: x.skew() if len(x) >= 3 else 0.0
                )
            except Exception:
                result["skew"] = pd.Series(0.0, index=grouped.ngroups)
        if "count" in self.stats:
            result["count"] = grouped.count().astype(float)
        if "min" in self.stats:
            result["min"] = grouped.min()
        if "max" in self.stats:
            result["max"] = grouped.max()
        return result

    def _get_fill_value(self, stat: str) -> float:
        if stat == "mean":
            return self.global_mean_ if self.global_mean_ is not None else 0.5
        elif stat == "std":
            return 0.0
        elif stat == "skew":
            return 0.0
        elif stat == "count":
            return 1.0
        elif stat == "min":
            return self.global_min_ if self.global_min_ is not None else 0.0
        elif stat == "max":
            return self.global_max_ if self.global_max_ is not None else 1.0
        return 0.0

    def fit_transform(self, df, y, fold_indices):
        self.global_mean_ = float(y.mean())
        self.global_min_ = float(y.min())
        self.global_max_ = float(y.max())
        results = {stat: pd.Series(np.nan, index=df.index, dtype=float) for stat in self.stats}
        for tr_idx, va_idx in fold_indices:
            tr_col = df[self.col].iloc[tr_idx]
            tr_y = y.iloc[tr_idx]
            fold_stats = self._compute_fold_stats(tr_col, tr_y)
            for stat in self.stats:
                if stat in fold_stats:
                    results[stat].iloc[va_idx] = df[self.col].iloc[va_idx].map(fold_stats[stat])
        for stat in self.stats:
            results[stat] = results[stat].fillna(self._get_fill_value(stat))
        self.mappings_ = self._compute_fold_stats(df[self.col], y)
        out = {}
        for stat in self.stats:
            out[self._col_name(stat)] = results[stat].values
        return pd.DataFrame(out, index=df.index)

    def transform(self, df):
        out = {}
        for stat in self.stats:
            fill_val = self._get_fill_value(stat)
            if self.mappings_ is not None and stat in self.mappings_:
                vals = df[self.col].map(self.mappings_[stat]).fillna(fill_val)
            else:
                vals = pd.Series(fill_val, index=df.index, dtype=float)
            out[self._col_name(stat)] = vals.values
        return pd.DataFrame(out, index=df.index)


# ============================================================================
# CYCLICAL ENCODING
# ============================================================================
class CyclicalFeature(FeatureGenerator):
    """Sine/cosine encoding for potentially cyclical numerical features."""

    def __init__(self, col: str):
        super().__init__(f"cyc__{col}")
        self.col = col
        self.period_: Optional[float] = None
        self.min_: Optional[float] = None

    def fit_transform(self, df, y, fold_indices):
        vals = df[self.col].fillna(0).astype(float)
        self.min_ = float(vals.min())
        self.period_ = float(vals.max() - vals.min())
        if self.period_ == 0:
            self.period_ = 1.0
        normed = 2 * np.pi * (vals - self.min_) / self.period_
        return pd.DataFrame(
            {f"{self.name}_sin": np.sin(normed).values, f"{self.name}_cos": np.cos(normed).values},
            index=df.index,
        )

    def transform(self, df):
        vals = df[self.col].fillna(0).astype(float)
        normed = 2 * np.pi * (vals - self.min_) / self.period_
        return pd.DataFrame(
            {f"{self.name}_sin": np.sin(normed).values, f"{self.name}_cos": np.cos(normed).values},
            index=df.index,
        )


# ============================================================================
# WEIGHT OF EVIDENCE (OOF)
# ============================================================================
class WoEFeature(FeatureGenerator):
    """Weight of Evidence encoding for a column. OOF — no leakage.

    WoE = ln(distribution of events / distribution of non-events).
    Only meaningful for binary classification targets.
    """

    def __init__(self, col: str, smoothing: float = 1.0):
        super().__init__(f"woe__{col}")
        self.col = col
        self.smoothing = smoothing
        self.mapping_ = None

    def _compute_woe(self, col_vals, y_vals):
        total_events = (y_vals == 1).sum()
        total_non_events = (y_vals == 0).sum()
        if total_events == 0 or total_non_events == 0:
            return pd.Series(dtype=float)
        tmp = pd.DataFrame({"col": col_vals.values, "y": y_vals.values})
        grouped = tmp.groupby("col")["y"]
        events = grouped.sum()
        counts = grouped.count()
        non_events = counts - events
        dist_events = (events + self.smoothing) / (total_events + self.smoothing * len(events))
        dist_non_events = (non_events + self.smoothing) / (total_non_events + self.smoothing * len(non_events))
        woe = np.log(dist_events / dist_non_events)
        return woe.replace([np.inf, -np.inf], 0.0)

    def fit_transform(self, df, y, fold_indices):
        result = pd.Series(np.nan, index=df.index, dtype=float)
        for tr_idx, va_idx in fold_indices:
            woe_map = self._compute_woe(df[self.col].iloc[tr_idx], y.iloc[tr_idx])
            result.iloc[va_idx] = df[self.col].iloc[va_idx].map(woe_map)
        self.mapping_ = self._compute_woe(df[self.col], y)
        result = result.fillna(0.0)
        return pd.DataFrame({self.name: result.values}, index=df.index)

    def transform(self, df):
        if self.mapping_ is None:
            return pd.DataFrame({self.name: np.zeros(len(df))}, index=df.index)
        vals = df[self.col].map(self.mapping_).fillna(0.0)
        return pd.DataFrame({self.name: vals.values}, index=df.index)


# ============================================================================
# ENTROPY ENCODING (OOF)
# ============================================================================
class EntropyFeature(FeatureGenerator):
    """Target entropy per group. OOF — no leakage.

    High entropy = mixed group; low entropy = pure group.
    """

    def __init__(self, col: str):
        super().__init__(f"entropy__{col}")
        self.col = col
        self.mapping_ = None
        self.global_entropy_ = None

    @staticmethod
    def _compute_entropy_map(col_vals, y_vals):
        tmp = pd.DataFrame({"col": col_vals.values, "y": y_vals.values})
        grouped = tmp.groupby("col")["y"]

        def entropy(series):
            p = series.mean()
            if p <= 0 or p >= 1:
                return 0.0
            return -(p * np.log2(p) + (1 - p) * np.log2(1 - p))

        return grouped.apply(entropy)

    def fit_transform(self, df, y, fold_indices):
        p_global = y.mean()
        if 0 < p_global < 1:
            self.global_entropy_ = -(p_global * np.log2(p_global) + (1 - p_global) * np.log2(1 - p_global))
        else:
            self.global_entropy_ = 0.0
        result = pd.Series(np.nan, index=df.index, dtype=float)
        for tr_idx, va_idx in fold_indices:
            ent_map = self._compute_entropy_map(df[self.col].iloc[tr_idx], y.iloc[tr_idx])
            result.iloc[va_idx] = df[self.col].iloc[va_idx].map(ent_map)
        self.mapping_ = self._compute_entropy_map(df[self.col], y)
        result = result.fillna(self.global_entropy_)
        return pd.DataFrame({self.name: result.values}, index=df.index)

    def transform(self, df):
        if self.mapping_ is None:
            return pd.DataFrame({self.name: np.zeros(len(df))}, index=df.index)
        vals = df[self.col].map(self.mapping_).fillna(self.global_entropy_ or 0.0)
        return pd.DataFrame({self.name: vals.values}, index=df.index)


# ============================================================================
# GENETIC PROGRAMMING FEATURES
# ============================================================================
class GeneticProgrammingFeature(FeatureGenerator):
    """Generate nonlinear interaction features using gplearn SymbolicTransformer.

    Falls back gracefully if gplearn is not installed.
    """

    def __init__(
        self,
        num_cols: List[str],
        n_components: int = 5,
        generations: int = 5,
        population_size: int = 200,
        random_state: int = 42,
    ):
        super().__init__("gp_features")
        self.num_cols = num_cols
        self.n_components = n_components
        self.generations = generations
        self.population_size = population_size
        self.random_state = random_state
        self.transformer_ = None
        self.col_names_: List[str] = []
        self._available = True

    def fit_transform(self, df, y, fold_indices):
        try:
            from gplearn.genetic import SymbolicTransformer
        except ImportError:
            self._available = False
            return pd.DataFrame(index=df.index)

        X_sub = df[self.num_cols].fillna(0).astype(float).values
        y_arr = y.values.astype(float)

        self.transformer_ = SymbolicTransformer(
            generations=self.generations,
            population_size=self.population_size,
            hall_of_fame=self.n_components,
            n_components=self.n_components,
            function_set=["add", "sub", "mul", "div", "sqrt", "log", "abs"],
            parsimony_coefficient=0.01,
            max_samples=min(1.0, 5000 / max(len(X_sub), 1)),
            random_state=self.random_state,
            verbose=0,
        )

        try:
            gp_features = self.transformer_.fit_transform(X_sub, y_arr)
        except Exception:
            self._available = False
            return pd.DataFrame(index=df.index)

        if gp_features.shape[1] == 0:
            self._available = False
            return pd.DataFrame(index=df.index)

        self.col_names_ = [f"gp__{i}" for i in range(gp_features.shape[1])]
        out = pd.DataFrame(gp_features, columns=self.col_names_, index=df.index)
        out = out.replace([np.inf, -np.inf], np.nan).fillna(0.0)
        return out

    def transform(self, df):
        if not self._available or self.transformer_ is None:
            return pd.DataFrame(index=df.index)
        X_sub = df[self.num_cols].fillna(0).astype(float).values
        try:
            gp_features = self.transformer_.transform(X_sub)
        except Exception:
            return pd.DataFrame(index=df.index)
        out = pd.DataFrame(gp_features, columns=self.col_names_, index=df.index)
        out = out.replace([np.inf, -np.inf], np.nan).fillna(0.0)
        return out


# ============================================================================
# BAYESIAN-STYLE PRIORS (TARGET MEAN FROM ORIGINAL DATASET)
# ============================================================================
class BayesianPriorFeature(FeatureGenerator):
    """Inject an external prior probability of the target for each value.

    Given a pre-computed mapping ``{value: P(target | value)}`` derived from an
    external (original) dataset, each row receives its historical probability.
    This uses no information from the training target — zero leakage.
    """

    def __init__(self, col: str, prior_map: Dict, global_prior: Optional[float] = None):
        super().__init__(f"prior__{col}")
        self.col = col
        self.prior_map = prior_map
        self.global_prior = (
            global_prior
            if global_prior is not None
            else float(np.mean(list(prior_map.values()))) if prior_map else 0.0
        )

    def fit_transform(self, df, y, fold_indices):
        vals = df[self.col].map(self.prior_map).fillna(self.global_prior)
        return pd.DataFrame({self.name: vals.values.astype(float)}, index=df.index)

    def transform(self, df):
        vals = df[self.col].map(self.prior_map).fillna(self.global_prior)
        return pd.DataFrame({self.name: vals.values.astype(float)}, index=df.index)


# ============================================================================
# EXTERNAL WoE (WEIGHT OF EVIDENCE FROM ORIGINAL DATASET)
# ============================================================================
class ExternalWoEFeature(FeatureGenerator):
    """Weight of Evidence computed from an external (original) dataset.

    Given a pre-computed WoE mapping ``{value: woe_score}`` from the original
    dataset, each row gets its WoE score. Zero leakage since no train target is used.
    """

    def __init__(self, col: str, woe_map: Dict, global_woe: float = 0.0):
        super().__init__(f"ext_woe__{col}")
        self.col = col
        self.woe_map = woe_map
        self.global_woe = global_woe

    def fit_transform(self, df, y, fold_indices):
        vals = df[self.col].map(self.woe_map).fillna(self.global_woe)
        return pd.DataFrame({self.name: vals.values.astype(float)}, index=df.index)

    def transform(self, df):
        vals = df[self.col].map(self.woe_map).fillna(self.global_woe)
        return pd.DataFrame({self.name: vals.values.astype(float)}, index=df.index)


# ============================================================================
# EXTERNAL ENTROPY (FROM ORIGINAL DATASET)
# ============================================================================
class ExternalEntropyFeature(FeatureGenerator):
    """Target entropy per group from an external (original) dataset.

    Given a pre-computed entropy mapping ``{value: entropy_score}`` from the
    original dataset, each row gets its entropy. Zero leakage.
    """

    def __init__(self, col: str, entropy_map: Dict, global_entropy: float = 0.0):
        super().__init__(f"ext_entropy__{col}")
        self.col = col
        self.entropy_map = entropy_map
        self.global_entropy = global_entropy

    def fit_transform(self, df, y, fold_indices):
        vals = df[self.col].map(self.entropy_map).fillna(self.global_entropy)
        return pd.DataFrame({self.name: vals.values.astype(float)}, index=df.index)

    def transform(self, df):
        vals = df[self.col].map(self.entropy_map).fillna(self.global_entropy)
        return pd.DataFrame({self.name: vals.values.astype(float)}, index=df.index)
