# scripts/helpers/engines/synthetic_engine.py
import os
import numpy as np
import pandas as pd
from typing import Any
from .base_engine import BaseEngine
from ..utils import set_global_seed
from ..transforms import add_missing_values, add_outliers


class SyntheticEngine(BaseEngine):
    """
    Engine for generating fully synthetic datasets.

    Used for:
    - ML experiments (scaling, regression, classification)
    - Creating synthetic relationships between features
    - Simulating noise, outliers, and missing values
    """

    def __init__(self, config: dict[str, Any]):
        super().__init__(config)

        # Configuration blocks
        self.noise = config.get("noise", {}) or {}
        self.clip_ranges = config.get("clip", {}) or {}
        self.post_cfg = config.get("postprocess", {}) or {}

        # Output settings
        self.output_path = config.get("output_path", None)
        self.filename = config.get("filename", "synthetic.csv")
        self.as_df = config.get("as_df", True)

        # Ensure reproducibility
        set_global_seed(self.seed)

    # ---------------------------------------------------------
    # Main generation method
    # ---------------------------------------------------------
    def generate(self) -> pd.DataFrame:
        """
        Generate synthetic dataset according to the configured features.

        Supports:
        - Categorical + numeric features
        - Function-based dependencies
        - Optional noise per feature (float, tuple, or lambda)
        - Optional clipping (value restriction)
        - Optional derived (post-processed) features
        - Optional postprocessing (missing values, outliers)
        """
        # -----------------------------------------------------
        # 1️⃣ Base feature generation
        # -----------------------------------------------------
        df = self._evaluate_features()

        # -----------------------------------------------------
        # 2️⃣ Noise injection (per feature)
        # -----------------------------------------------------
        for col, noise_val in self.noise.items():
            if col not in df.columns or noise_val is None:
                continue

            try:
                # Simple numeric σ
                if isinstance(noise_val, (int, float)):
                    df[col] = df[col] + np.random.normal(0, noise_val, len(df))
                # Tuple (mean, std)
                elif isinstance(noise_val, tuple):
                    mean, std = noise_val
                    df[col] = df[col] + np.random.normal(mean, std, len(df))
                # Lambda as string
                elif isinstance(noise_val, str):
                    func = eval(noise_val, {"np": np})
                    df[col] = df[col] + func(len(df))
            except Exception as e:
                print(f"⚠️ Failed to apply noise for '{col}': {e}")

        # -----------------------------------------------------
        # 3️⃣ Optional clipping (range control)
        # -----------------------------------------------------
        for col, (lo, hi) in self.clip_ranges.items():
            if col in df.columns:
                df[col] = np.clip(df[col], lo, hi)

        # -----------------------------------------------------
        # 4️⃣ Derived features (post-calculated)
        # -----------------------------------------------------
        df = self.apply_derived_features(df, self.config)

        # -----------------------------------------------------
        # 5️⃣ Postprocessing: missing values & outliers
        # -----------------------------------------------------
        post = self.post_cfg

        # ---- Missing values ----
        miss_cfg = post.get("missing", {})
        def_miss = miss_cfg.get("default_ratio", 0.0)
        cols_cfg = miss_cfg.get("columns", {})

        # Apply per-column overrides
        for col, ratio in cols_cfg.items():
            if col in df.columns and ratio > 0:
                df = add_missing_values(df, [col], ratio)

        # Apply global default (if defined)
        if def_miss > 0 and not cols_cfg:
            df = add_missing_values(df, df.columns.tolist(), def_miss)

        # ---- Outliers ----
        out_cfg = post.get("outliers", {})
        def_ratio = out_cfg.get("default_ratio", 0.0)
        def_mag = out_cfg.get("default_magnitude", 3.0)
        col_cfg = out_cfg.get("columns", {})

        for col, params in col_cfg.items():
            if col not in df.columns:
                continue
            ratio = params.get("ratio", def_ratio)
            mag = params.get("magnitude", def_mag)
            df = add_outliers(df, col, ratio=ratio, magnitude=mag)

        # -----------------------------------------------------
        # 6️⃣ Save to file (optional)
        # -----------------------------------------------------
        if self.output_path:
            os.makedirs(self.output_path, exist_ok=True)
            file_path = os.path.join(self.output_path, self.filename)
            df.to_csv(file_path, index=False)
            print(f"✅ Synthetic dataset saved → {file_path} ({len(df)} rows)")

            if not self.as_df:
                return None

        # -----------------------------------------------------
        # 7️⃣ Return final DataFrame
        # -----------------------------------------------------
        return df




# # scripts/helpers/engines/synthetic_engine.py
# import numpy as np
# import pandas as pd
# from typing import Any, Callable
# from .base_engine import BaseEngine
# from ..utils import set_global_seed, apply_noise


# class SyntheticEngine(BaseEngine):
#     """
#     Engine for generating fully synthetic datasets.

#     Used for:
#     - ML experiments (scaling, binning, regression, classification)
#     - Creating synthetic relationships between features
#     - Easily combining categorical and numeric variables
#     """

#     def __init__(self, config: dict[str, Any]):
#         super().__init__(config)

#         self.noise = config.get("noise", {}) or {}
#         self.clip_ranges = config.get("clip", {}) or {}
#         self.output_path = config.get("output_path", None)
#         self.as_df = config.get("as_df", True)
#         self.filename = config.get("filename", "synthetic.csv")

#         set_global_seed(self.seed)

#     # ---------------------------------------------------------
#     # Main generation method
#     # ---------------------------------------------------------
#     def generate(self) -> pd.DataFrame:
#         """
#         Generate synthetic dataset according to the configured features.

#         Supports:
#         - Categorical + numeric features
#         - Function-based dependencies
#         - Optional noise per feature
#         - Optional clipping (e.g. restrict ranges)
#         - Optional derived features (post-processing)
#         """
#         df = self._evaluate_features()

#         # Optional per-feature noise injection
#         for col, noise_val in self.noise.items():
#             if col in df.columns and noise_val is not None:
#                 df[col] = df[col] + np.random.normal(0, noise_val, len(df))

#         # Optional clipping (value ranges)
#         for col, (lo, hi) in self.clip_ranges.items():
#             if col in df.columns:
#                 df[col] = np.clip(df[col], lo, hi)

#         # ✅ Optional derived features (post-processing)
#         df = self.apply_derived_features(df, self.config)

#         # Optional output
#         if self.output_path:
#             import os
#             os.makedirs(self.output_path, exist_ok=True)
#             filename = self.filename or f"synthetic_{self.scenario}.csv"
#             file_path = f"{self.output_path}/{filename}"
#             df.to_csv(file_path, index=False)
#             print(f"✅ Synthetic dataset saved → {file_path} ({len(df)} rows)")
#             return df if self.as_df else None

#         return df
