import numpy as np
from sklearn.base import BaseEstimator, ClassifierMixin, clone
from sklearn.utils.validation import check_X_y, check_array, check_is_fitted
from sklearn.utils.multiclass import unique_labels
from sklearn.model_selection import cross_val_predict
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier


class MetaSynthesisClassifier(BaseEstimator, ClassifierMixin):
    """
    A meta-learning classifier that synthesizes predictions from multiple base estimators.
    
    This classifier uses a two-level stacking approach:
    1. Base level: Multiple diverse classifiers make predictions
    2. Meta level: A meta-classifier learns to combine base predictions optimally
    
    Parameters
    ----------
    base_estimators : list of estimators, default=None
        List of base classifiers. If None, uses default ensemble of
        LogisticRegression, RandomForestClassifier, and DecisionTreeClassifier.
    
    meta_estimator : estimator, default=None
        The meta-classifier that combines base predictions.
        If None, uses LogisticRegression.
    
    cv : int, default=5
        Number of cross-validation folds for generating meta-features during training.
    
    use_probas : bool, default=True
        If True, uses predicted probabilities as meta-features.
        If False, uses predicted class labels.
    
    use_original_features : bool, default=False
        If True, concatenates original features with meta-features.
    
    Attributes
    ----------
    classes_ : ndarray of shape (n_classes,)
        The classes labels.
    
    base_estimators_ : list of estimators
        The fitted base estimators.
    
    meta_estimator_ : estimator
        The fitted meta-classifier.
    """
    
    def __init__(self, base_estimators=None, meta_estimator=None, cv=5, 
                 use_probas=True, use_original_features=False):
        self.base_estimators = base_estimators
        self.meta_estimator = meta_estimator
        self.cv = cv
        self.use_probas = use_probas
        self.use_original_features = use_original_features
    
    def fit(self, X, y):
        """
        Fit the meta-synthesis classifier.
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training data.
        
        y : array-like of shape (n_samples,)
            Target values.
        
        Returns
        -------
        self : object
            Returns self.
        """
        # Validate input
        X, y = check_X_y(X, y)
        self.classes_ = unique_labels(y)
        self.n_features_in_ = X.shape[1]
        
        # Initialize base estimators if not provided
        if self.base_estimators is None:
            self.base_estimators_ = [
                LogisticRegression(max_iter=1000, random_state=42),
                RandomForestClassifier(n_estimators=100, random_state=42),
                DecisionTreeClassifier(random_state=42)
            ]
        else:
            self.base_estimators_ = [clone(est) for est in self.base_estimators]
        
        # Initialize meta estimator if not provided
        if self.meta_estimator is None:
            self.meta_estimator_ = LogisticRegression(max_iter=1000, random_state=42)
        else:
            self.meta_estimator_ = clone(self.meta_estimator)
        
        # Generate meta-features using cross-validation predictions
        meta_features = self._generate_meta_features(X, y, fit_base=False)
        
        # Fit all base estimators on full training data
        for estimator in self.base_estimators_:
            estimator.fit(X, y)
        
        # Fit meta estimator on meta-features
        if self.use_original_features:
            meta_features = np.hstack([X, meta_features])
        
        self.meta_estimator_.fit(meta_features, y)
        
        return self
    
    def predict(self, X):
        """
        Predict class labels for samples in X.
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Samples to predict.
        
        Returns
        -------
        y_pred : ndarray of shape (n_samples,)
            Predicted class labels.
        """
        check_is_fitted(self)
        X = check_array(X)
        
        # Generate meta-features from base estimators
        meta_features = self._generate_meta_features_predict(X)
        
        # Concatenate with original features if specified
        if self.use_original_features:
            meta_features = np.hstack([X, meta_features])
        
        # Make final prediction using meta estimator
        return self.meta_estimator_.predict(meta_features)
    
    def predict_proba(self, X):
        """
        Predict class probabilities for samples in X.
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Samples to predict.
        
        Returns
        -------
        proba : ndarray of shape (n_samples, n_classes)
            Predicted class probabilities.
        """
        check_is_fitted(self)
        X = check_array(X)
        
        # Generate meta-features from base estimators
        meta_features = self._generate_meta_features_predict(X)
        
        # Concatenate with original features if specified
        if self.use_original_features:
            meta_features = np.hstack([X, meta_features])
        
        # Make final prediction using meta estimator
        if hasattr(self.meta_estimator_, 'predict_proba'):
            return self.meta_estimator_.predict_proba(meta_features)
        else:
            raise AttributeError("Meta estimator does not support predict_proba")
    
    def _generate_meta_features(self, X, y, fit_base=False):
        """
        Generate meta-features using cross-validation predictions.
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training data.
        
        y : array-like of shape (n_samples,)
            Target values.
        
        fit_base : bool, default=False
            Whether to fit base estimators (not used, kept for compatibility).
        
        Returns
        -------
        meta_features : ndarray
            Meta-features for training the meta-classifier.
        """
        meta_features_list = []
        
        for estimator in self.base_estimators_:
            if self.use_probas and hasattr(estimator, 'predict_proba'):
                # Use cross-validated probability predictions
                cv_preds = cross_val_predict(
                    estimator, X, y, cv=self.cv, method='predict_proba'
                )
                meta_features_list.append(cv_preds)
            else:
                # Use cross-validated class predictions
                cv_preds = cross_val_predict(estimator, X, y, cv=self.cv)
                meta_features_list.append(cv_preds.reshape(-1, 1))
        
        # Concatenate all meta-features
        meta_features = np.hstack(meta_features_list)
        
        return meta_features
    
    def _generate_meta_features_predict(self, X):
        """
        Generate meta-features for prediction using fitted base estimators.
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Test data.
        
        Returns
        -------
        meta_features : ndarray
            Meta-features for prediction.
        """
        meta_features_list = []
        
        for estimator in self.base_estimators_:
            if self.use_probas and hasattr(estimator, 'predict_proba'):
                preds = estimator.predict_proba(X)
                meta_features_list.append(preds)
            else:
                preds = estimator.predict(X)
                meta_features_list.append(preds.reshape(-1, 1))
        
        # Concatenate all meta-features
        meta_features = np.hstack(meta_features_list)
        
        return meta_features