"""
Main pipeline class for cluster analysis.

This module provides the ClusterAnalysisPipeline class, which orchestrates
the entire clustering workflow from preprocessing to interpretation.
"""

from typing import Optional, Union, List, Dict, Any, Callable
import warnings
import pandas as pd
import numpy as np


class ClusterAnalysisPipeline:
    """
    Complete pipeline for cluster analysis.

    This class orchestrates the entire clustering workflow including:
    - Data preprocessing (missing values, outliers, scaling)
    - Feature selection (correlation, variance)
    - Dimensionality reduction (PCA)
    - Clustering (K-Means, GMM, Hierarchical, DBSCAN)
    - Evaluation (metrics, optimal k finding)
    - Interpretation (profiling, naming)
    - Visualization (optional - requires matplotlib and seaborn)
      * plot_clusters_2d() - 2D scatter plots using t-SNE/UMAP/PCA
      * plot_cluster_sizes() - cluster size distribution
      * plot_cluster_heatmap() - cluster profiles heatmap
      * plot_cluster_radar() - cluster profiles radar chart
      * plot_feature_importance() - top distinguishing features
      * plot_pca_variance() - PCA explained variance
      * plot_pca_loadings() - PCA component loadings
      * plot_correlation_matrix() - correlation matrix heatmap
      * plot_correlation_network() - correlation network graph
      * plot_feature_distributions() - feature distribution histograms

    Parameters
    ----------
    handle_missing : str or callable, default='median'
        Strategy for handling missing values.
        Options: 'median', 'mean', 'drop', or a custom function.

    handle_outliers : str or None, default='robust'
        Strategy for handling outliers.
        Options: 'robust' (RobustScaler), 'clip', 'remove', None.

    scaling : str, default='robust'
        Scaling method to use.
        Options: 'standard', 'robust', 'minmax', 'auto'.

    log_transform_skewed : bool, default=False
        Whether to apply log transformation to skewed features.

    skewness_threshold : float, default=2.0
        Threshold for detecting skewed features (if log_transform_skewed=True).

    correlation_threshold : float, default=0.85
        Threshold for removing highly correlated features.
        Features with |correlation| > threshold will be removed.

    variance_threshold : float, default=0.01
        Minimum variance threshold for feature selection.
        Features with variance < threshold will be removed.

    pca_variance : float, default=0.9
        Minimum proportion of variance to explain with PCA components.

    pca_min_components : int, default=2
        Minimum number of PCA components to keep (for visualization).

    clustering_algorithm : str or object, default='kmeans'
        Clustering algorithm to use.
        Options: 'kmeans', 'gmm', 'hierarchical', 'dbscan', or a custom clusterer.

    n_clusters : int, list, or None, default=None
        Number of clusters. If None, will be automatically determined.
        If list, will try all values and select the best.

    n_clusters_range : tuple, default=(2, 10)
        Range of cluster numbers to try for automatic selection (if n_clusters=None).

    auto_name_clusters : bool, default=False
        Whether to automatically generate descriptive names for clusters.

    naming_max_features : int, default=2
        Maximum number of features to include in cluster names.

    random_state : int, default=42
        Random state for reproducibility.

    verbose : bool, default=True
        Whether to print progress messages.

    Attributes
    ----------
    data_ : pd.DataFrame
        Original input data.

    data_preprocessed_ : pd.DataFrame
        Data after preprocessing (missing values, outliers handled).

    data_scaled_ : pd.DataFrame
        Data after scaling.

    selected_features_ : list
        List of selected feature names after feature selection.

    data_reduced_ : pd.DataFrame
        Data after dimensionality reduction (PCA components).

    labels_ : np.ndarray
        Cluster labels for each sample.

    n_clusters_ : int
        Final number of clusters used.

    cluster_profiles_ : pd.DataFrame
        Profile of each cluster (mean values of features).

    cluster_names_ : dict or None
        Descriptive names for each cluster (if auto_name_clusters=True).

    metrics_ : dict
        Clustering evaluation metrics.

    model_ : object
        Fitted clustering model.

    Examples
    --------
    >>> from clustertk import ClusterAnalysisPipeline
    >>> import pandas as pd
    >>>
    >>> # Create sample data
    >>> df = pd.DataFrame({'feature1': [1, 2, 3, 4, 5],
    ...                    'feature2': [2, 4, 6, 8, 10]})
    >>>
    >>> # Create and fit pipeline
    >>> pipeline = ClusterAnalysisPipeline(
    ...     handle_missing='median',
    ...     correlation_threshold=0.85,
    ...     n_clusters=2
    ... )
    >>> pipeline.fit(df)
    >>>
    >>> # Get results
    >>> labels = pipeline.labels_
    >>> profiles = pipeline.cluster_profiles_
    """

    def __init__(
        self,
        # Preprocessing parameters
        handle_missing: Union[str, Callable] = 'median',
        handle_outliers: Optional[str] = 'robust',
        scaling: str = 'robust',
        log_transform_skewed: bool = False,
        skewness_threshold: float = 2.0,
        # Feature selection parameters
        correlation_threshold: float = 0.85,
        variance_threshold: float = 0.01,
        # Dimensionality reduction parameters
        pca_variance: float = 0.9,
        pca_min_components: int = 2,
        # Clustering parameters
        clustering_algorithm: Union[str, object] = 'kmeans',
        n_clusters: Optional[Union[int, List[int]]] = None,
        n_clusters_range: tuple = (2, 10),
        # Naming parameters
        auto_name_clusters: bool = False,
        naming_max_features: int = 2,
        # General parameters
        random_state: int = 42,
        verbose: bool = True
    ):
        # Store parameters
        self.handle_missing = handle_missing
        self.handle_outliers = handle_outliers
        self.scaling = scaling
        self.log_transform_skewed = log_transform_skewed
        self.skewness_threshold = skewness_threshold
        self.correlation_threshold = correlation_threshold
        self.variance_threshold = variance_threshold
        self.pca_variance = pca_variance
        self.pca_min_components = pca_min_components
        self.clustering_algorithm = clustering_algorithm
        self.n_clusters = n_clusters
        self.n_clusters_range = n_clusters_range
        self.auto_name_clusters = auto_name_clusters
        self.naming_max_features = naming_max_features
        self.random_state = random_state
        self.verbose = verbose

        # Initialize attributes that will be set during fitting
        self.data_: Optional[pd.DataFrame] = None
        self.data_preprocessed_: Optional[pd.DataFrame] = None
        self.data_scaled_: Optional[pd.DataFrame] = None
        self.selected_features_: Optional[List[str]] = None
        self.data_reduced_: Optional[pd.DataFrame] = None
        self.labels_: Optional[np.ndarray] = None
        self.n_clusters_: Optional[int] = None
        self.cluster_profiles_: Optional[pd.DataFrame] = None
        self.cluster_names_: Optional[Dict[int, str]] = None
        self.metrics_: Optional[Dict[str, Any]] = None
        self.model_: Optional[object] = None

        # Internal components (will be initialized during fit)
        self._missing_handler = None
        self._outlier_handler = None
        self._scaler = None
        self._transformer = None
        self._correlation_filter = None
        self._variance_filter = None
        self._pca_reducer = None
        self._clusterer = None
        self._profiler = None
        self._namer = None

    def fit(
        self,
        X: pd.DataFrame,
        feature_columns: Optional[List[str]] = None,
        category_mapping: Optional[Dict[str, List[str]]] = None
    ) -> 'ClusterAnalysisPipeline':
        """
        Run the complete clustering pipeline.

        This method executes all steps sequentially:
        1. Preprocessing
        2. Feature selection
        3. Dimensionality reduction
        4. Optimal cluster finding (if n_clusters not specified)
        5. Clustering
        6. Profiling

        Parameters
        ----------
        X : pd.DataFrame
            Input data.

        feature_columns : list of str, optional
            List of column names to use as features.
            If None, all numeric columns will be used.

        category_mapping : dict, optional
            Mapping of category names to feature names for interpretation.
            Example: {'behavioral': ['feature1', 'feature2'], 'social': ['feature3']}

        Returns
        -------
        self : ClusterAnalysisPipeline
            Fitted pipeline instance.
        """
        if self.verbose:
            print("=" * 80)
            print("ClusterTK Pipeline - Starting cluster analysis")
            print("=" * 80)

        # Run all steps
        self.preprocess(X, feature_columns)
        self.select_features()
        self.reduce_dimensions()

        if self.n_clusters is None:
            self.find_optimal_clusters()

        self.cluster()
        self.create_profiles(category_mapping)

        if self.verbose:
            print("\n" + "=" * 80)
            print("Pipeline completed successfully!")
            print(f"Final clusters: {self.n_clusters_}")
            print("=" * 80)

        return self

    def preprocess(
        self,
        X: pd.DataFrame,
        feature_columns: Optional[List[str]] = None
    ) -> 'ClusterAnalysisPipeline':
        """
        Preprocess the data (missing values, outliers, scaling).

        Parameters
        ----------
        X : pd.DataFrame
            Input data.

        feature_columns : list of str, optional
            List of column names to use as features.
            If None, all numeric columns will be used.

        Returns
        -------
        self : ClusterAnalysisPipeline
        """
        from clustertk.preprocessing import (
            MissingValueHandler,
            OutlierHandler,
            ScalerSelector,
            SkewnessTransformer
        )

        if self.verbose:
            print("\nStep 1/6: Preprocessing data...")

        # Store original data
        self.data_ = X.copy()

        # Select feature columns
        if feature_columns is None:
            feature_columns = X.select_dtypes(include=[np.number]).columns.tolist()
            if self.verbose:
                print(f"  No feature_columns specified, using {len(feature_columns)} numeric columns")

        data_working = X[feature_columns].copy()

        if self.verbose:
            print(f"  Initial data shape: {data_working.shape}")

        # Step 1.1: Handle infinity and extreme values (replace with NaN)
        # Check for infinity
        inf_mask = np.isinf(data_working.values)
        inf_count = inf_mask.sum()

        # Check for extremely large values that may cause issues
        # Use a threshold of 1e308 (close to float64 max ~1.8e308)
        extreme_mask = np.abs(data_working.values) > 1e100
        extreme_count = extreme_mask.sum() - inf_count  # Don't double count infinity

        total_problematic = inf_count + extreme_count

        if total_problematic > 0:
            if self.verbose:
                if inf_count > 0 and extreme_count > 0:
                    print(f"  Detected {inf_count} infinity and {extreme_count} extreme values, replacing with NaN...")
                elif inf_count > 0:
                    print(f"  Detected {inf_count} infinity values, replacing with NaN...")
                else:
                    print(f"  Detected {extreme_count} extreme values, replacing with NaN...")

            # Replace infinity with NaN
            data_working = data_working.replace([np.inf, -np.inf], np.nan)

            # Replace extreme values with NaN
            data_working = data_working.mask(np.abs(data_working) > 1e100)

            if self.verbose:
                print(f"    ✓ Problematic values replaced with NaN")

        # Step 1.2: Handle missing values
        if self.verbose:
            missing_count = data_working.isnull().sum().sum()
            if missing_count > 0:
                print(f"  Handling {missing_count} missing values (strategy: {self.handle_missing})...")

        self._missing_handler = MissingValueHandler(strategy=self.handle_missing)
        data_working = self._missing_handler.fit_transform(data_working)

        if self.verbose and missing_count > 0:
            print(f"    ✓ Missing values handled")

        # Step 1.3: Apply log transformation to skewed features (if enabled)
        if self.log_transform_skewed:
            if self.verbose:
                print(f"  Detecting skewed features (threshold: {self.skewness_threshold})...")

            self._transformer = SkewnessTransformer(
                threshold=self.skewness_threshold,
                method='log1p'
            )
            data_working = self._transformer.fit_transform(data_working)

            if self.verbose and len(self._transformer.skewed_features_) > 0:
                print(f"    ✓ Transformed {len(self._transformer.skewed_features_)} skewed features")

            # Check again for infinity after transformation
            inf_after_transform = np.isinf(data_working.values).sum()
            if inf_after_transform > 0:
                if self.verbose:
                    print(f"  Detected {inf_after_transform} infinity values after transformation, replacing with NaN...")
                data_working = data_working.replace([np.inf, -np.inf], np.nan)
                # Re-impute new missing values
                data_working = self._missing_handler.transform(data_working)
                if self.verbose:
                    print(f"    ✓ Post-transformation infinity handled")

        self.data_preprocessed_ = data_working.copy()

        # Step 1.4: Handle outliers and scaling
        if self.verbose:
            print(f"  Scaling data (method: {self.scaling})...")

        # Handle outliers if specified
        if self.handle_outliers == 'robust':
            # Use RobustScaler which is robust to outliers
            self._scaler = ScalerSelector(scaler_type='robust')
        elif self.handle_outliers == 'clip':
            # Clip outliers before scaling
            self._outlier_handler = OutlierHandler(method='iqr', action='clip', threshold=1.5)
            data_working = self._outlier_handler.fit_transform(data_working)
            self._scaler = ScalerSelector(scaler_type=self.scaling)
        elif self.handle_outliers == 'remove':
            # Remove outliers
            self._outlier_handler = OutlierHandler(method='iqr', action='remove', threshold=1.5)
            data_working = self._outlier_handler.fit_transform(data_working)
            self._scaler = ScalerSelector(scaler_type=self.scaling)
            if self.verbose:
                print(f"    Rows after outlier removal: {len(data_working)}")
        else:
            # No outlier handling, just scale
            self._scaler = ScalerSelector(scaler_type=self.scaling)

        # Apply scaling
        self.data_scaled_ = self._scaler.fit_transform(data_working)

        if self.verbose:
            print(f"    ✓ Data scaled using {self._scaler.selected_scaler_type_} scaler")
            print(f"  Final preprocessed shape: {self.data_scaled_.shape}")
            print("  ✓ Preprocessing completed")

        return self

    def select_features(self) -> 'ClusterAnalysisPipeline':
        """
        Select features based on correlation and variance.

        Returns
        -------
        self : ClusterAnalysisPipeline
        """
        from clustertk.feature_selection import CorrelationFilter, VarianceFilter

        if self.verbose:
            print("\nStep 2/6: Selecting features...")

        initial_features = len(self.data_scaled_.columns)
        data_working = self.data_scaled_.copy()

        # Step 2.1: Remove low-variance features
        if self.variance_threshold > 0:
            if self.verbose:
                print(f"  Removing low-variance features (threshold: {self.variance_threshold})...")

            self._variance_filter = VarianceFilter(threshold=self.variance_threshold)
            data_working = self._variance_filter.fit_transform(data_working)

            removed_count = len(self._variance_filter.features_to_drop_)
            if self.verbose and removed_count > 0:
                print(f"    ✓ Removed {removed_count} low-variance features")

        # Step 2.2: Remove highly correlated features
        if self.correlation_threshold < 1.0:
            if self.verbose:
                print(f"  Removing highly correlated features (threshold: {self.correlation_threshold})...")

            self._correlation_filter = CorrelationFilter(
                threshold=self.correlation_threshold,
                method='pearson'
            )
            data_working = self._correlation_filter.fit_transform(data_working)

            removed_count = len(self._correlation_filter.features_to_drop_)
            if self.verbose and removed_count > 0:
                print(f"    ✓ Removed {removed_count} highly correlated features")
                if self._correlation_filter.high_correlation_pairs_:
                    print(f"    Found {len(self._correlation_filter.high_correlation_pairs_)} "
                          f"high correlation pairs")

        # Store selected features and update scaled data
        self.selected_features_ = data_working.columns.tolist()
        self.data_scaled_ = data_working

        final_features = len(self.selected_features_)

        if self.verbose:
            print(f"  Feature selection: {initial_features} → {final_features} features")
            print("  ✓ Feature selection completed")

        return self

    def reduce_dimensions(self) -> 'ClusterAnalysisPipeline':
        """
        Reduce dimensionality using PCA.

        Returns
        -------
        self : ClusterAnalysisPipeline
        """
        from clustertk.dimensionality import PCAReducer

        if self.verbose:
            print("\nStep 3/6: Reducing dimensions with PCA...")

        initial_dims = len(self.selected_features_)

        # Apply PCA
        self._pca_reducer = PCAReducer(
            variance_threshold=self.pca_variance,
            min_components=self.pca_min_components,
            random_state=self.random_state
        )

        self.data_reduced_ = self._pca_reducer.fit_transform(
            self.data_scaled_[self.selected_features_]
        )

        final_dims = self.data_reduced_.shape[1]
        variance_explained = self._pca_reducer.cumulative_variance_[final_dims - 1]

        if self.verbose:
            print(f"  Dimensionality: {initial_dims} → {final_dims} components")
            print(f"  Variance explained: {variance_explained:.1%}")

            # Show top loadings for first 2 components
            if final_dims >= 2:
                print(f"  Top features in PC1:")
                loadings = self._pca_reducer.get_loadings(n_top=3)
                for feature, loading in loadings['PC1']:
                    sign = '+' if loading > 0 else ''
                    print(f"    {sign}{loading:.3f} {feature}")

            print("  ✓ Dimensionality reduction completed")

        return self

    def find_optimal_clusters(self) -> 'ClusterAnalysisPipeline':
        """
        Find optimal number of clusters if not specified.

        Note: This step is skipped for DBSCAN as it doesn't require
        specifying the number of clusters.

        Returns
        -------
        self : ClusterAnalysisPipeline
        """
        from clustertk.evaluation import OptimalKFinder
        from clustertk.clustering import KMeansClustering, GMMClustering, HierarchicalClustering

        # Skip for DBSCAN
        if isinstance(self.clustering_algorithm, str) and self.clustering_algorithm == 'dbscan':
            if self.verbose:
                print("\nStep 4/6: Finding optimal number of clusters...")
                print("  Skipping for DBSCAN (density-based clustering)")
                print("  ✓ Optimal cluster finding completed")
            return self

        if self.verbose:
            print("\nStep 4/6: Finding optimal number of clusters...")
            print(f"  Testing k in range {self.n_clusters_range}...")

        # Select clusterer class based on algorithm
        if isinstance(self.clustering_algorithm, str):
            if self.clustering_algorithm == 'kmeans':
                clusterer_class = KMeansClustering
            elif self.clustering_algorithm == 'gmm':
                clusterer_class = GMMClustering
            elif self.clustering_algorithm == 'hierarchical':
                clusterer_class = HierarchicalClustering
            else:
                # Default to KMeans
                clusterer_class = KMeansClustering
        else:
            # Custom clusterer provided
            clusterer_class = self.clustering_algorithm.__class__

        # Find optimal k
        finder = OptimalKFinder(
            k_range=self.n_clusters_range,
            method='voting',
            random_state=self.random_state
        )

        self.n_clusters_ = finder.find_optimal_k(self.data_reduced_, clusterer_class)

        if self.verbose:
            print(f"  Optimal k: {self.n_clusters_}")

            # Show metric votes
            if finder.metric_votes_:
                print("  Metric votes:")
                for metric, k in finder.metric_votes_.items():
                    print(f"    {metric}: k={k}")

            print("  ✓ Optimal cluster finding completed")

        return self

    def cluster(
        self,
        n_clusters: Optional[int] = None,
        algorithm: Optional[str] = None
    ) -> 'ClusterAnalysisPipeline':
        """
        Perform clustering.

        Parameters
        ----------
        n_clusters : int, optional
            Number of clusters. If None, uses self.n_clusters_.

        algorithm : str, optional
            Clustering algorithm. If None, uses self.clustering_algorithm.

        Returns
        -------
        self : ClusterAnalysisPipeline
        """
        from clustertk.clustering import (
            KMeansClustering,
            GMMClustering,
            HierarchicalClustering,
            DBSCANClustering
        )
        from clustertk.evaluation import compute_clustering_metrics

        if self.verbose:
            print("\nStep 5/6: Performing clustering...")

        # Determine algorithm
        algo = algorithm or self.clustering_algorithm

        # Determine number of clusters (not used for DBSCAN)
        if isinstance(algo, str) and algo != 'dbscan':
            if n_clusters is not None:
                self.n_clusters_ = n_clusters
            elif self.n_clusters_ is None:
                if isinstance(self.n_clusters, int):
                    self.n_clusters_ = self.n_clusters
                elif isinstance(self.n_clusters, list):
                    self.n_clusters_ = self.n_clusters[0]
                else:
                    self.n_clusters_ = 3

        # Initialize clusterer
        if isinstance(algo, str):
            if algo == 'kmeans':
                self._clusterer = KMeansClustering(
                    n_clusters=self.n_clusters_,
                    random_state=self.random_state
                )
            elif algo == 'gmm':
                self._clusterer = GMMClustering(
                    n_clusters=self.n_clusters_,
                    random_state=self.random_state
                )
            elif algo == 'hierarchical':
                self._clusterer = HierarchicalClustering(
                    n_clusters=self.n_clusters_,
                    linkage='ward',  # Default linkage
                    metric='euclidean'
                )
            elif algo == 'dbscan':
                self._clusterer = DBSCANClustering(
                    eps='auto',  # Auto-estimate eps
                    min_samples='auto'  # Auto-estimate min_samples
                )
            else:
                raise ValueError(
                    f"Unknown algorithm '{algo}'. "
                    f"Use 'kmeans', 'gmm', 'hierarchical', or 'dbscan'."
                )
        else:
            # Custom clusterer provided
            self._clusterer = algo

        # Fit clustering
        self.labels_ = self._clusterer.fit_predict(self.data_reduced_)
        self.model_ = self._clusterer

        # Update n_clusters_ from actual result (important for DBSCAN)
        self.n_clusters_ = self._clusterer.n_clusters_

        # Compute metrics
        self.metrics_ = compute_clustering_metrics(self.data_reduced_, self.labels_)

        if self.verbose:
            print(f"  Algorithm: {algo}")
            print(f"  Number of clusters: {self.n_clusters_}")

            # Show cluster sizes (handle noise for DBSCAN)
            cluster_sizes = dict(pd.Series(self.labels_).value_counts().sort_index())
            if -1 in cluster_sizes:
                noise_count = cluster_sizes.pop(-1)
                print(f"  Cluster sizes: {cluster_sizes}")
                print(f"  Noise points: {noise_count}")
            else:
                print(f"  Cluster sizes: {cluster_sizes}")

            print(f"  Silhouette score: {self.metrics_['silhouette']:.3f}")
            print("  ✓ Clustering completed")

        return self

    def create_profiles(
        self,
        category_mapping: Optional[Dict[str, List[str]]] = None
    ) -> 'ClusterAnalysisPipeline':
        """
        Create cluster profiles and interpretations.

        Parameters
        ----------
        category_mapping : dict, optional
            Mapping of category names to feature names.
            Example: {'behavioral': ['feat1', 'feat2'], 'social': ['feat3']}

        Returns
        -------
        self : ClusterAnalysisPipeline
        """
        from clustertk.interpretation import ClusterProfiler

        if self.verbose:
            print("\nStep 6/6: Creating cluster profiles...")

        # Create profiler
        self._profiler = ClusterProfiler(normalize_per_feature=True)

        # Create profiles using scaled data
        self.cluster_profiles_ = self._profiler.create_profiles(
            self.data_scaled_[self.selected_features_],
            self.labels_,
            feature_names=self.selected_features_
        )

        # Store normalized profiles for visualization
        self.cluster_profiles_normalized_ = self._profiler.profiles_normalized_

        # Get top distinguishing features
        self._profiler.get_top_features(n=5)

        if self.verbose:
            print(f"  Profiles created for {len(self.cluster_profiles_)} clusters")

            # Show cluster sizes
            cluster_sizes = pd.Series(self.labels_).value_counts().sort_index()
            print(f"  Cluster sizes:")
            for cluster, size in cluster_sizes.items():
                pct = size / len(self.labels_) * 100
                print(f"    Cluster {cluster}: {size} samples ({pct:.1f}%)")

        # Analyze by categories if provided
        if category_mapping is not None:
            if self.verbose:
                print(f"  Analyzing {len(category_mapping)} feature categories...")

            self._profiler.analyze_by_categories(
                self.data_scaled_[self.selected_features_],
                self.labels_,
                category_mapping
            )

            if self.verbose:
                print("  ✓ Category analysis completed")

        # Generate cluster names if enabled
        if self.auto_name_clusters:
            if self.verbose:
                print(f"  Generating cluster names...")

            self.name_clusters(
                category_mapping=category_mapping,
                max_features=self.naming_max_features
            )

            if self.verbose:
                print("  ✓ Cluster naming completed")

        if self.verbose:
            print("  ✓ Profiling completed")

        return self

    def name_clusters(
        self,
        category_mapping: Optional[Dict[str, List[str]]] = None,
        naming_strategy: str = 'auto',
        max_features: Optional[int] = None,
        min_deviation: float = 0.5
    ) -> Dict[int, str]:
        """
        Generate descriptive names for clusters.

        Parameters
        ----------
        category_mapping : dict, optional
            Mapping of category names to feature names.
            Used for category-based naming if available.

        naming_strategy : str, default='auto'
            Naming strategy: 'auto', 'top_features', 'categories', or 'combined'.

        max_features : int, optional
            Maximum number of features to include in names.
            If None, uses self.naming_max_features.

        min_deviation : float, default=0.5
            Minimum deviation from mean to consider a feature significant.

        Returns
        -------
        names : dict
            Dictionary mapping cluster ID to generated name.
        """
        from clustertk.interpretation import ClusterNamer

        if self._profiler is None or self.cluster_profiles_ is None:
            raise ValueError(
                "Must call create_profiles() before generating names"
            )

        # Get category scores if category mapping was used
        category_scores = None
        if hasattr(self._profiler, 'category_scores_') and self._profiler.category_scores_ is not None:
            category_scores = self._profiler.category_scores_

        # Create namer
        self._namer = ClusterNamer(
            naming_strategy=naming_strategy,
            max_features=max_features or self.naming_max_features,
            min_deviation=min_deviation,
            use_directions=True,
            short_names=False
        )

        # Generate names
        self.cluster_names_ = self._namer.generate_names(
            profiles=self.cluster_profiles_,
            top_features=self._profiler.top_features_,
            category_scores=category_scores,
            category_mapping=category_mapping
        )

        if self.verbose:
            print("\n  Generated cluster names:")
            for cluster_id in sorted(self.cluster_names_.keys()):
                name = self.cluster_names_[cluster_id]
                print(f"    Cluster {cluster_id}: {name}")

        return self.cluster_names_

    def get_cluster_name(self, cluster_id: int) -> Optional[str]:
        """
        Get the name for a specific cluster.

        Parameters
        ----------
        cluster_id : int
            Cluster ID.

        Returns
        -------
        name : str or None
            Cluster name, or None if naming not performed or cluster not found.
        """
        if self.cluster_names_ is None:
            return None

        return self.cluster_names_.get(cluster_id)

    def print_cluster_summary(self) -> None:
        """
        Print a comprehensive summary of all clusters with names and profiles.
        """
        if self.cluster_profiles_ is None:
            raise ValueError("Must call create_profiles() first")

        print("\n" + "=" * 80)
        print("CLUSTER SUMMARY")
        print("=" * 80)

        cluster_sizes = pd.Series(self.labels_).value_counts().sort_index()

        for cluster_id in sorted(self.cluster_profiles_.index):
            # Cluster header
            size = cluster_sizes.get(cluster_id, 0)
            pct = size / len(self.labels_) * 100

            print(f"\nCluster {cluster_id} ({size} samples, {pct:.1f}%)")

            # Name if available
            if self.cluster_names_ is not None and cluster_id in self.cluster_names_:
                print(f"  Name: {self.cluster_names_[cluster_id]}")

            # Top features
            if hasattr(self._profiler, 'top_features_') and self._profiler.top_features_:
                top_feats = self._profiler.top_features_.get(cluster_id, {})
                high_feats = top_feats.get('high', [])[:3]
                low_feats = top_feats.get('low', [])[:3]

                if high_feats:
                    print("  Top features:")
                    for feat, val in high_feats:
                        print(f"    ↑ {feat}: {val:+.3f}")

                if low_feats:
                    for feat, val in low_feats:
                        print(f"    ↓ {feat}: {val:+.3f}")

        print("\n" + "=" * 80)

    # ============================================================
    # Visualization Methods
    # ============================================================

    def plot_clusters_2d(
        self,
        method: str = 'tsne',
        title: Optional[str] = None,
        figsize: tuple = (10, 8),
        show_centers: bool = False,
        ax: Optional[Any] = None
    ):
        """
        Plot clusters in 2D space using dimensionality reduction.

        Parameters
        ----------
        method : str, default='tsne'
            Dimensionality reduction method: 'tsne', 'umap', or 'pca'.

        title : str, optional
            Plot title.

        figsize : tuple, default=(10, 8)
            Figure size.

        show_centers : bool, default=False
            Whether to show cluster centers (if available).

        ax : matplotlib.axes.Axes, optional
            Axes to plot on.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_clusters_2d

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self.labels_ is None:
            raise ValueError("No clustering results available. Run fit() or cluster() first.")

        # Get cluster centers if available
        centers = None
        if show_centers and hasattr(self._clusterer, 'cluster_centers_'):
            centers = self._clusterer.cluster_centers_

        return plot_clusters_2d(
            X=self.data_reduced_,
            labels=self.labels_,
            method=method,
            title=title,
            figsize=figsize,
            show_centers=show_centers,
            centers=centers,
            ax=ax
        )

    def plot_cluster_sizes(
        self,
        title: Optional[str] = None,
        figsize: tuple = (10, 6),
        ax: Optional[Any] = None
    ):
        """
        Plot cluster size distribution as a bar chart.

        Parameters
        ----------
        title : str, optional
            Plot title.

        figsize : tuple, default=(10, 6)
            Figure size.

        ax : matplotlib.axes.Axes, optional
            Axes to plot on.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_cluster_sizes

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self.labels_ is None:
            raise ValueError("No clustering results available. Run fit() or cluster() first.")

        return plot_cluster_sizes(
            labels=self.labels_,
            title=title,
            figsize=figsize,
            ax=ax
        )

    def plot_cluster_heatmap(
        self,
        title: Optional[str] = None,
        figsize: tuple = (12, 8),
        cmap: str = 'RdYlGn',
        normalize: bool = True,
        ax: Optional[Any] = None
    ):
        """
        Plot cluster profiles as a heatmap.

        Parameters
        ----------
        title : str, optional
            Plot title.

        figsize : tuple, default=(12, 8)
            Figure size.

        cmap : str, default='RdYlGn'
            Colormap name.

        normalize : bool, default=True
            Whether to normalize each feature to 0-1 scale.

        ax : matplotlib.axes.Axes, optional
            Axes to plot on.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_cluster_heatmap

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self.cluster_profiles_ is None:
            raise ValueError("No cluster profiles available. Run fit() or create_profiles() first.")

        # Use pre-normalized profiles if available and normalization is requested
        if normalize and hasattr(self, 'cluster_profiles_normalized_') and self.cluster_profiles_normalized_ is not None:
            profiles_to_plot = self.cluster_profiles_normalized_
            apply_normalization = False  # Already normalized
        else:
            profiles_to_plot = self.cluster_profiles_
            apply_normalization = normalize

        return plot_cluster_heatmap(
            profiles=profiles_to_plot,
            title=title,
            figsize=figsize,
            cmap=cmap,
            normalize=apply_normalization,
            ax=ax
        )

    def plot_cluster_radar(
        self,
        cluster_ids: Optional[List[int]] = None,
        title: Optional[str] = None,
        figsize: tuple = (10, 10),
        normalize: bool = True
    ):
        """
        Plot cluster profiles as radar charts.

        Parameters
        ----------
        cluster_ids : list of int, optional
            Specific cluster IDs to plot. If None, plots all clusters.

        title : str, optional
            Plot title.

        figsize : tuple, default=(10, 10)
            Figure size.

        normalize : bool, default=True
            Whether to normalize each feature to 0-1 scale.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_cluster_radar

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self.cluster_profiles_ is None:
            raise ValueError("No cluster profiles available. Run fit() or create_profiles() first.")

        # Use pre-normalized profiles if available and normalization is requested
        if normalize and hasattr(self, 'cluster_profiles_normalized_') and self.cluster_profiles_normalized_ is not None:
            profiles_to_plot = self.cluster_profiles_normalized_
            apply_normalization = False  # Already normalized
        else:
            profiles_to_plot = self.cluster_profiles_
            apply_normalization = normalize

        return plot_cluster_radar(
            profiles=profiles_to_plot,
            cluster_ids=cluster_ids,
            title=title,
            figsize=figsize,
            normalize=apply_normalization
        )

    def plot_feature_importance(
        self,
        cluster_id: int,
        n_features: int = 10,
        title: Optional[str] = None,
        figsize: tuple = (10, 6),
        ax: Optional[Any] = None
    ):
        """
        Plot top distinguishing features for a cluster.

        Parameters
        ----------
        cluster_id : int
            Cluster ID to visualize.

        n_features : int, default=10
            Number of top features to show.

        title : str, optional
            Plot title.

        figsize : tuple, default=(10, 6)
            Figure size.

        ax : matplotlib.axes.Axes, optional
            Axes to plot on.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_feature_importance

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self._profiler is None or not hasattr(self._profiler, 'top_features_'):
            raise ValueError(
                "No feature importance data available. "
                "Run fit() or create_profiles() first."
            )

        return plot_feature_importance(
            top_features=self._profiler.top_features_,
            cluster_id=cluster_id,
            n_features=n_features,
            title=title,
            figsize=figsize,
            ax=ax
        )

    def plot_pca_variance(
        self,
        threshold: Optional[float] = None,
        title: Optional[str] = None,
        figsize: tuple = (12, 6),
        ax: Optional[Any] = None
    ):
        """
        Plot PCA explained variance (scree plot).

        Parameters
        ----------
        threshold : float, optional
            Variance threshold line to show (e.g., 0.9 for 90%).

        title : str, optional
            Plot title.

        figsize : tuple, default=(12, 6)
            Figure size.

        ax : matplotlib.axes.Axes, optional
            Axes to plot on.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_pca_variance

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self._pca_reducer is None:
            raise ValueError("No PCA results available. Run fit() or reduce_dimensions() first.")

        return plot_pca_variance(
            explained_variance_ratio=self._pca_reducer.explained_variance_,
            cumulative_variance=self._pca_reducer.cumulative_variance_,
            threshold=threshold or self.pca_variance,
            title=title,
            figsize=figsize,
            ax=ax
        )

    def plot_pca_loadings(
        self,
        components: Optional[list] = None,
        n_features: int = 10,
        title: Optional[str] = None,
        figsize: tuple = (12, 8)
    ):
        """
        Plot PCA component loadings.

        Parameters
        ----------
        components : list, optional
            Which components to plot (e.g., [0, 1, 2] for first 3 PCs).
            If None, plots all components.

        n_features : int, default=10
            Number of top features to show per component.

        title : str, optional
            Plot title.

        figsize : tuple, default=(12, 8)
            Figure size.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_pca_loadings

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self._pca_reducer is None:
            raise ValueError("No PCA results available. Run fit() or reduce_dimensions() first.")

        # Get loadings as DataFrame
        loadings_df = pd.DataFrame(
            self._pca_reducer.loadings_,
            columns=[f'PC{i+1}' for i in range(self._pca_reducer.loadings_.shape[1])],
            index=self.selected_features_
        )

        return plot_pca_loadings(
            loadings=loadings_df,
            components=components,
            n_features=n_features,
            title=title,
            figsize=figsize
        )

    def plot_correlation_matrix(
        self,
        method: str = 'pearson',
        title: Optional[str] = None,
        figsize: tuple = (12, 10),
        cmap: str = 'coolwarm',
        annot: bool = False,
        threshold: Optional[float] = None,
        ax: Optional[Any] = None
    ):
        """
        Plot correlation matrix of features.

        Parameters
        ----------
        method : str, default='pearson'
            Correlation method: 'pearson', 'spearman', or 'kendall'.

        title : str, optional
            Plot title.

        figsize : tuple, default=(12, 10)
            Figure size.

        cmap : str, default='coolwarm'
            Colormap name.

        annot : bool, default=False
            Whether to annotate cells with correlation values.

        threshold : float, optional
            If provided, only shows correlations with |r| > threshold.

        ax : matplotlib.axes.Axes, optional
            Axes to plot on.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_correlation_matrix

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self.data_scaled_ is None:
            raise ValueError("No data available. Run fit() or preprocess() first.")

        return plot_correlation_matrix(
            data=self.data_scaled_[self.selected_features_] if self.selected_features_
                 else self.data_scaled_,
            method=method,
            title=title,
            figsize=figsize,
            cmap=cmap,
            annot=annot,
            threshold=threshold,
            ax=ax
        )

    def plot_correlation_network(
        self,
        threshold: float = 0.5,
        method: str = 'pearson',
        title: Optional[str] = None,
        figsize: tuple = (12, 12),
        node_size: int = 1000,
        font_size: int = 10
    ):
        """
        Plot correlation network graph.

        Parameters
        ----------
        threshold : float, default=0.5
            Minimum absolute correlation to show.

        method : str, default='pearson'
            Correlation method.

        title : str, optional
            Plot title.

        figsize : tuple, default=(12, 12)
            Figure size.

        node_size : int, default=1000
            Size of nodes.

        font_size : int, default=10
            Font size for labels.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_correlation_network

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self.data_scaled_ is None:
            raise ValueError("No data available. Run fit() or preprocess() first.")

        return plot_correlation_network(
            data=self.data_scaled_[self.selected_features_] if self.selected_features_
                 else self.data_scaled_,
            threshold=threshold,
            method=method,
            title=title,
            figsize=figsize,
            node_size=node_size,
            font_size=font_size
        )

    def plot_feature_distributions(
        self,
        features: Optional[List[str]] = None,
        n_cols: int = 3,
        figsize: Optional[tuple] = None,
        bins: int = 30
    ):
        """
        Plot distribution histograms for features.

        Parameters
        ----------
        features : list of str, optional
            Features to plot. If None, plots all selected features.

        n_cols : int, default=3
            Number of columns in subplot grid.

        figsize : tuple, optional
            Figure size. If None, auto-calculated.

        bins : int, default=30
            Number of histogram bins.

        Returns
        -------
        fig : matplotlib.figure.Figure
            The figure object.
        """
        from clustertk.visualization import check_viz_available, plot_feature_distributions

        if not check_viz_available():
            raise ImportError(
                "Visualization dependencies not installed. "
                "Install with: pip install clustertk[viz]"
            )

        if self.data_preprocessed_ is None:
            raise ValueError("No data available. Run fit() or preprocess() first.")

        # Use selected features if available, otherwise all features
        data_to_plot = (self.data_preprocessed_[self.selected_features_]
                       if self.selected_features_ else self.data_preprocessed_)

        return plot_feature_distributions(
            data=data_to_plot,
            features=features,
            n_cols=n_cols,
            figsize=figsize,
            bins=bins
        )

    def export_results(
        self,
        path: str,
        format: str = 'csv',
        include_original: bool = True
    ) -> None:
        """
        Export clustering results to file.

        Parameters
        ----------
        path : str
            Output file path.

        format : str, default='csv'
            Output format: 'csv', 'json', 'pickle'.

        include_original : bool, default=True
            Whether to include original data in export.
        """
        # TODO: Implement export logic
        if self.verbose:
            print(f"\nExporting results to {path}...")

        warnings.warn("Export functionality not yet implemented", UserWarning)

    def __repr__(self) -> str:
        """String representation of the pipeline."""
        return (
            f"ClusterAnalysisPipeline(\n"
            f"  clustering_algorithm={self.clustering_algorithm},\n"
            f"  n_clusters={self.n_clusters},\n"
            f"  scaling={self.scaling},\n"
            f"  pca_variance={self.pca_variance}\n"
            f")"
        )
