import decimal
import hashlib
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional

import numpy as np
import yaml


def _to_native(value: Any) -> Any:
    if isinstance(value, decimal.Decimal):
        return float(value)
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, (np.integer, np.floating)):
        return value.item()
    if isinstance(value, np.ndarray):
        return value.tolist()
    if isinstance(value, dict):
        return {k: _to_native(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_to_native(v) for v in value]
    mod = type(value).__module__
    if mod.startswith('pandas'):
        return str(value)
    return value


NUMERIC_AGGREGATIONS = ("sum", "mean", "max", "min", "count", "std", "median", "first", "last")
CATEGORICAL_AGGREGATIONS = ("mode", "nunique", "mode_ratio", "entropy", "value_counts")
ALL_AGGREGATIONS = NUMERIC_AGGREGATIONS + CATEGORICAL_AGGREGATIONS


@dataclass
class LayeredRecommendation:
    id: str
    layer: str
    category: str
    action: str
    target_column: str
    parameters: Dict[str, Any]
    rationale: str
    source_notebook: str
    priority: int = 1
    dependencies: List[str] = field(default_factory=list)
    fit_artifact_id: Optional[str] = None


@dataclass
class BronzeRecommendations:
    source_file: str
    null_handling: List[LayeredRecommendation] = field(default_factory=list)
    outlier_handling: List[LayeredRecommendation] = field(default_factory=list)
    type_conversions: List[LayeredRecommendation] = field(default_factory=list)
    deduplication: List[LayeredRecommendation] = field(default_factory=list)
    filtering: List[LayeredRecommendation] = field(default_factory=list)
    text_processing: List[LayeredRecommendation] = field(default_factory=list)
    modeling_strategy: List[LayeredRecommendation] = field(default_factory=list)

    @property
    def all_recommendations(self) -> List[LayeredRecommendation]:
        return (self.null_handling + self.outlier_handling + self.type_conversions +
                self.deduplication + self.filtering + self.text_processing + self.modeling_strategy)


@dataclass
class SilverRecommendations:
    entity_column: str
    time_column: Optional[str] = None
    joins: List[LayeredRecommendation] = field(default_factory=list)
    aggregations: List[LayeredRecommendation] = field(default_factory=list)
    derived_columns: List[LayeredRecommendation] = field(default_factory=list)

    @property
    def all_recommendations(self) -> List[LayeredRecommendation]:
        return self.joins + self.aggregations + self.derived_columns


@dataclass
class GoldRecommendations:
    target_column: str
    encoding: List[LayeredRecommendation] = field(default_factory=list)
    scaling: List[LayeredRecommendation] = field(default_factory=list)
    feature_selection: List[LayeredRecommendation] = field(default_factory=list)
    transformations: List[LayeredRecommendation] = field(default_factory=list)

    @property
    def all_recommendations(self) -> List[LayeredRecommendation]:
        return self.encoding + self.scaling + self.feature_selection + self.transformations


class RecommendationRegistry:
    def __init__(self):
        self.sources: Dict[str, BronzeRecommendations] = {}
        self.bronze: Optional[BronzeRecommendations] = None
        self.silver: Optional[SilverRecommendations] = None
        self.gold: Optional[GoldRecommendations] = None
        self.fit_artifacts: Dict[str, str] = {}
        self._id_counter = 0

    def save(self, path) -> None:
        p = path if isinstance(path, Path) else Path(str(path))
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("w") as f:
            yaml.dump(self.to_dict(), f, default_flow_style=False, sort_keys=False)

    @classmethod
    def load(cls, path) -> "RecommendationRegistry":
        p = path if isinstance(path, Path) else Path(str(path))
        with p.open("r") as f:
            return cls.from_dict(yaml.safe_load(f))

    def link_fit_artifact(self, recommendation_id: str, artifact_id: str) -> None:
        self.fit_artifacts[recommendation_id] = artifact_id

    def get_fit_artifact(self, recommendation_id: str) -> Optional[str]:
        return self.fit_artifacts.get(recommendation_id)

    @property
    def source_names(self) -> List[str]:
        return list(self.sources.keys())

    def add_source(self, name: str, source_file: str) -> None:
        self.sources[name] = BronzeRecommendations(source_file=source_file)

    def get_source_recommendations(self, name: str) -> List[LayeredRecommendation]:
        if name in self.sources:
            return self.sources[name].all_recommendations
        return []

    def init_bronze(self, source_file: str) -> None:
        self.bronze = BronzeRecommendations(source_file=source_file)

    def init_silver(self, entity_column: str, time_column: Optional[str] = None) -> None:
        self.silver = SilverRecommendations(entity_column=entity_column, time_column=time_column)

    def init_gold(self, target_column: str) -> None:
        self.gold = GoldRecommendations(target_column=target_column)

    def add_bronze_null(self, column: str, strategy: str, rationale: str, source_notebook: str,
                        source: Optional[str] = None) -> None:
        rec = self._create_recommendation("bronze", "null", "impute", column,
                                          {"strategy": strategy}, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].null_handling.append(rec)
        elif self.bronze:
            self.bronze.null_handling.append(rec)

    def add_bronze_outlier(self, column: str, action: str, parameters: Dict, rationale: str,
                           source_notebook: str, source: Optional[str] = None) -> None:
        rec = self._create_recommendation("bronze", "outlier", action, column,
                                          parameters, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].outlier_handling.append(rec)
        elif self.bronze:
            self.bronze.outlier_handling.append(rec)

    def add_bronze_text_processing(self, column: str, embedding_model: str,
                                    variance_threshold: float, n_components: int,
                                    rationale: str, source_notebook: str,
                                    source: Optional[str] = None) -> None:
        params = {
            "embedding_model": embedding_model,
            "variance_threshold": variance_threshold,
            "n_components": n_components,
            "approach": "pca"
        }
        rec = self._create_recommendation("bronze", "text", "embed_reduce", column,
                                          params, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].text_processing.append(rec)
        elif self.bronze:
            self.bronze.text_processing.append(rec)

    def add_bronze_filtering(self, column: str, condition: str, action: str, rationale: str,
                             source_notebook: str, source: Optional[str] = None) -> None:
        rec = self._create_recommendation("bronze", "filtering", action, column,
                                          {"condition": condition}, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].filtering.append(rec)
        elif self.bronze:
            self.bronze.filtering.append(rec)

    def add_bronze_modeling_strategy(self, strategy: str, column: str, parameters: Dict,
                                      rationale: str, source_notebook: str,
                                      source: Optional[str] = None) -> None:
        rec = self._create_recommendation("bronze", "modeling", strategy, column,
                                          parameters, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].modeling_strategy.append(rec)
        elif self.bronze:
            self.bronze.modeling_strategy.append(rec)

    def add_bronze_deduplication(self, key_column: str, strategy: str, rationale: str,
                                  source_notebook: str, conflict_columns: Optional[List[str]] = None,
                                  source: Optional[str] = None) -> None:
        params = {"strategy": strategy}
        if conflict_columns:
            params["conflict_columns"] = conflict_columns
        rec = self._create_recommendation("bronze", "deduplication", strategy, key_column,
                                          params, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].deduplication.append(rec)
        elif self.bronze:
            self.bronze.deduplication.append(rec)

    def add_bronze_consistency(self, column: str, issue_type: str, action: str,
                                variants: List[str], rationale: str, source_notebook: str,
                                source: Optional[str] = None) -> None:
        params = {"issue_type": issue_type, "variants": variants}
        rec = self._create_recommendation("bronze", "consistency", action, column,
                                          params, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].type_conversions.append(rec)
        elif self.bronze:
            self.bronze.type_conversions.append(rec)

    def add_bronze_imbalance_strategy(self, target_column: str, imbalance_ratio: float,
                                       minority_class: Any, strategy: str, rationale: str,
                                       source_notebook: str, source: Optional[str] = None) -> None:
        params = {"imbalance_ratio": imbalance_ratio, "minority_class": minority_class}
        rec = self._create_recommendation("bronze", "imbalance", strategy, target_column,
                                          params, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].modeling_strategy.append(rec)
        elif self.bronze:
            self.bronze.modeling_strategy.append(rec)

    def add_silver_derived(self, column: str, expression: str, feature_type: str,
                           rationale: str, source_notebook: str) -> None:
        params = {"expression": expression, "feature_type": feature_type}
        rec = self._create_recommendation("silver", "derived", feature_type, column,
                                          params, rationale, source_notebook)
        self.silver.derived_columns.append(rec)

    def add_gold_transformation(self, column: str, transform: str, parameters: Dict,
                                 rationale: str, source_notebook: str) -> None:
        rec = self._create_recommendation("gold", "transformation", transform, column,
                                          parameters, rationale, source_notebook)
        self.gold.transformations.append(rec)

    def add_silver_aggregation(self, column: str, aggregation: str, windows: List[str],
                               rationale: str, source_notebook: str) -> None:
        params = {"aggregation": aggregation, "windows": windows}
        rec = self._create_recommendation("silver", "aggregation", aggregation, column,
                                          params, rationale, source_notebook)
        self.silver.aggregations.append(rec)

    def add_silver_join(self, left_source: str, right_source: str, join_keys: List[str],
                        join_type: str, rationale: str, source_notebook: str = "") -> None:
        params = {
            "left_source": left_source,
            "right_source": right_source,
            "join_keys": join_keys,
            "join_type": join_type
        }
        rec = self._create_recommendation("silver", "join", "join", "_merge",
                                          params, rationale, source_notebook)
        self.silver.joins.append(rec)

    def add_gold_encoding(self, column: str, method: str, rationale: str,
                          source_notebook: str) -> None:
        rec = self._create_recommendation("gold", "encoding", method, column,
                                          {"method": method}, rationale, source_notebook)
        self.gold.encoding.append(rec)

    def add_gold_scaling(self, column: str, method: str, rationale: str,
                         source_notebook: str) -> None:
        rec = self._create_recommendation("gold", "scaling", method, column,
                                          {"method": method}, rationale, source_notebook)
        self.gold.scaling.append(rec)

    def add_gold_drop_multicollinear(self, column: str, correlated_with: str, correlation: float,
                                      rationale: str, source_notebook: str) -> None:
        params = {"correlated_with": correlated_with, "correlation": correlation}
        rec = self._create_recommendation("gold", "feature_selection", "drop_multicollinear", column,
                                          params, rationale, source_notebook)
        self.gold.feature_selection.append(rec)

    def add_gold_drop_weak(self, column: str, effect_size: float, correlation: float,
                           rationale: str, source_notebook: str) -> None:
        params = {"effect_size": effect_size, "correlation": correlation}
        rec = self._create_recommendation("gold", "feature_selection", "drop_weak", column,
                                          params, rationale, source_notebook)
        self.gold.feature_selection.append(rec)

    def add_gold_prioritize_feature(self, column: str, effect_size: float, correlation: float,
                                     rationale: str, source_notebook: str) -> None:
        params = {"effect_size": effect_size, "correlation": correlation}
        rec = self._create_recommendation("gold", "feature_selection", "prioritize", column,
                                          params, rationale, source_notebook)
        self.gold.feature_selection.append(rec)

    def add_silver_ratio(self, column: str, numerator: str, denominator: str,
                         rationale: str, source_notebook: str) -> None:
        params = {"feature_type": "ratio", "numerator": numerator, "denominator": denominator,
                  "expression": f"{numerator} / {denominator}"}
        rec = self._create_recommendation("silver", "derived", "ratio", column,
                                          params, rationale, source_notebook)
        self.silver.derived_columns.append(rec)

    def add_silver_interaction(self, column: str, features: List[str],
                                rationale: str, source_notebook: str) -> None:
        params = {"feature_type": "interaction", "features": features,
                  "expression": " * ".join(features)}
        rec = self._create_recommendation("silver", "derived", "interaction", column,
                                          params, rationale, source_notebook)
        self.silver.derived_columns.append(rec)

    def add_silver_composite(self, column: str, columns: List[str],
                             rationale: str, source_notebook: str) -> None:
        params = {"feature_type": "composite", "columns": columns,
                  "expression": f"mean({', '.join(columns)})"}
        rec = self._create_recommendation("silver", "derived", "composite", column,
                                          params, rationale, source_notebook)
        self.silver.derived_columns.append(rec)

    def add_silver_temporal_config(self, source_dataset: str, columns: List[str],
                                    lag_windows: int, lag_window_days: int,
                                    aggregations: List[str], feature_groups: List[str],
                                    rationale: str, source_notebook: str) -> None:
        params = {
            "columns": columns, "lag_windows": lag_windows, "lag_window_days": lag_window_days,
            "aggregations": aggregations, "feature_groups": feature_groups
        }
        rec = self._create_recommendation("silver", "temporal", "temporal_aggregation", source_dataset,
                                          params, rationale, source_notebook)
        self.silver.aggregations.append(rec)

    def add_bronze_segmentation_strategy(self, strategy: str, confidence: float, n_segments: int,
                                          silhouette_score: float, rationale: str,
                                          source_notebook: str, source: Optional[str] = None) -> None:
        params = {"confidence": confidence, "n_segments": n_segments, "silhouette_score": silhouette_score}
        rec = self._create_recommendation("bronze", "segmentation", strategy, "target",
                                          params, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].modeling_strategy.append(rec)
        elif self.bronze:
            self.bronze.modeling_strategy.append(rec)

    def add_bronze_feature_capacity(self, epv: float, capacity_status: str, recommended_features: int,
                                     current_features: int, rationale: str,
                                     source_notebook: str, source: Optional[str] = None) -> None:
        params = {"epv": epv, "capacity_status": capacity_status,
                  "recommended_features": recommended_features, "current_features": current_features}
        rec = self._create_recommendation("bronze", "capacity", "feature_capacity", "features",
                                          params, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].modeling_strategy.append(rec)
        elif self.bronze:
            self.bronze.modeling_strategy.append(rec)

    def add_bronze_model_type(self, model_type: str, max_features_linear: int,
                               max_features_regularized: int, max_features_tree: int,
                               rationale: str, source_notebook: str,
                               source: Optional[str] = None) -> None:
        params = {"max_features_linear": max_features_linear,
                  "max_features_regularized": max_features_regularized,
                  "max_features_tree": max_features_tree}
        rec = self._create_recommendation("bronze", "model_selection", model_type, "model",
                                          params, rationale, source_notebook)
        if source and source in self.sources:
            self.sources[source].modeling_strategy.append(rec)
        elif self.bronze:
            self.bronze.modeling_strategy.append(rec)

    @property
    def all_recommendations(self) -> List[LayeredRecommendation]:
        recs = []
        for source_bronze in self.sources.values():
            recs.extend(source_bronze.all_recommendations)
        if self.bronze:
            recs.extend(self.bronze.all_recommendations)
        if self.silver:
            recs.extend(self.silver.all_recommendations)
        if self.gold:
            recs.extend(self.gold.all_recommendations)
        return recs

    def get_by_layer(self, layer: str) -> List[LayeredRecommendation]:
        if layer == "bronze":
            recs = []
            for source_bronze in self.sources.values():
                recs.extend(source_bronze.all_recommendations)
            if self.bronze:
                recs.extend(self.bronze.all_recommendations)
            return recs
        if layer == "silver" and self.silver:
            return self.silver.all_recommendations
        if layer == "gold" and self.gold:
            return self.gold.all_recommendations
        return []

    def to_dict(self) -> Dict[str, Any]:
        result = {}
        if self.sources:
            result["sources"] = {name: self._layer_to_dict(bronze)
                                 for name, bronze in self.sources.items()}
        if self.bronze:
            result["bronze"] = self._layer_to_dict(self.bronze)
        if self.silver:
            result["silver"] = self._layer_to_dict(self.silver)
        if self.gold:
            result["gold"] = self._layer_to_dict(self.gold)
        if self.fit_artifacts:
            result["fit_artifacts"] = self.fit_artifacts.copy()
        return result

    def compute_recommendations_hash(self, length: int = 8) -> str:
        hashable_data = self._build_hashable_gold_data()
        serialized = json.dumps(hashable_data, sort_keys=True, separators=(',', ':'))
        return hashlib.sha256(serialized.encode()).hexdigest()[:length]

    def _build_hashable_gold_data(self) -> Dict[str, Any]:
        if not self.gold:
            return {}
        return {
            "transformations": self._recs_to_hashable(self.gold.transformations),
            "encoding": self._recs_to_hashable(self.gold.encoding),
            "scaling": self._recs_to_hashable(self.gold.scaling),
            "feature_selection": self._recs_to_hashable(self.gold.feature_selection),
        }

    def _recs_to_hashable(self, recs: List[LayeredRecommendation]) -> List[Dict]:
        return sorted(
            [{"column": r.target_column, "action": r.action, "params": r.parameters} for r in recs],
            key=lambda x: (x["column"], x["action"])
        )

    @classmethod
    def merge(cls, registries: List["RecommendationRegistry"]) -> "RecommendationRegistry":
        if not registries:
            return cls()
        result = cls()
        primary = next((r for r in registries if r.gold is not None), None)
        primary = primary or next((r for r in registries if r.silver), None)
        for reg in registries:
            result.sources.update(reg.sources)
            result.fit_artifacts.update(reg.fit_artifacts)
        if primary:
            result.silver, result.gold, result.bronze = primary.silver, primary.gold, primary.bronze
        return result

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "RecommendationRegistry":
        registry = cls()
        if "sources" in data:
            for name, bronze_data in data["sources"].items():
                registry.sources[name] = cls._bronze_from_dict(bronze_data)
        if "bronze" in data:
            registry.bronze = cls._bronze_from_dict(data["bronze"])
        if "silver" in data:
            registry.silver = cls._silver_from_dict(data["silver"])
        if "gold" in data:
            registry.gold = cls._gold_from_dict(data["gold"])
        if "fit_artifacts" in data:
            registry.fit_artifacts = data["fit_artifacts"].copy()
        return registry

    def _create_recommendation(self, layer: str, category: str, action: str, column: str,
                               parameters: Dict, rationale: str,
                               source_notebook: str) -> LayeredRecommendation:
        self._id_counter += 1
        rec_id = f"{layer}_{category}_{column}"
        return LayeredRecommendation(
            id=rec_id, layer=layer, category=category, action=action,
            target_column=column, parameters=_to_native(parameters),
            rationale=rationale, source_notebook=source_notebook
        )

    def _layer_to_dict(self, layer_obj) -> Dict[str, Any]:
        result = {}
        for key, value in asdict(layer_obj).items():
            if isinstance(value, list) and value and isinstance(value[0], dict):
                result[key] = [_to_native(item) for item in value]
            elif isinstance(value, list):
                result[key] = [_to_native(asdict(r)) if hasattr(r, '__dataclass_fields__') else _to_native(r) for r in value]
            else:
                result[key] = _to_native(value)
        return result

    @classmethod
    def _bronze_from_dict(cls, data: Dict) -> BronzeRecommendations:
        return BronzeRecommendations(
            source_file=data["source_file"],
            null_handling=[cls._rec_from_dict(r) for r in data.get("null_handling", [])],
            outlier_handling=[cls._rec_from_dict(r) for r in data.get("outlier_handling", [])],
            type_conversions=[cls._rec_from_dict(r) for r in data.get("type_conversions", [])],
            deduplication=[cls._rec_from_dict(r) for r in data.get("deduplication", [])],
            filtering=[cls._rec_from_dict(r) for r in data.get("filtering", [])],
            text_processing=[cls._rec_from_dict(r) for r in data.get("text_processing", [])],
            modeling_strategy=[cls._rec_from_dict(r) for r in data.get("modeling_strategy", [])]
        )

    @classmethod
    def _silver_from_dict(cls, data: Dict) -> SilverRecommendations:
        return SilverRecommendations(
            entity_column=data["entity_column"],
            time_column=data.get("time_column"),
            joins=[cls._rec_from_dict(r) for r in data.get("joins", [])],
            aggregations=[cls._rec_from_dict(r) for r in data.get("aggregations", [])],
            derived_columns=[cls._rec_from_dict(r) for r in data.get("derived_columns", [])]
        )

    @classmethod
    def _gold_from_dict(cls, data: Dict) -> GoldRecommendations:
        return GoldRecommendations(
            target_column=data["target_column"],
            encoding=[cls._rec_from_dict(r) for r in data.get("encoding", [])],
            scaling=[cls._rec_from_dict(r) for r in data.get("scaling", [])],
            feature_selection=[cls._rec_from_dict(r) for r in data.get("feature_selection", [])],
            transformations=[cls._rec_from_dict(r) for r in data.get("transformations", [])]
        )

    @classmethod
    def _rec_from_dict(cls, data: Dict) -> LayeredRecommendation:
        return LayeredRecommendation(**data)
