"""
Clustering Module
================
Handles K-means clustering operations and evaluation.
"""

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
import logging
from typing import Tuple, Optional, List
from pathlib import Path

logger = logging.getLogger(__name__)


def prepare_features_for_clustering(
    feature_df: pd.DataFrame, sample_size: Optional[int] = None
) -> Tuple[pd.DataFrame, np.ndarray, StandardScaler]:
    """
    Scale features and optionally sample data for clustering.

    Args:
        feature_df: DataFrame with user features
        sample_size: Optional sample size for memory efficiency

    Returns:
        Tuple of (sampled_df, scaled_features, scaler)
    """
    logger.info("Preparing features for clustering")

    # Sample data if requested
    if sample_size and len(feature_df) > sample_size:
        logger.info(f"Sampling {sample_size} users from {len(feature_df)} total users")

        # Stratified sampling based on activity level
        # Create activity bins
        feature_df["activity_bin"] = pd.qcut(
            feature_df["total_clips_created"],
            q=5,
            labels=["very_low", "low", "medium", "high", "very_high"],
        )

        # Sample proportionally from each bin
        sampled_df = feature_df.groupby("activity_bin", group_keys=False).apply(
            lambda x: x.sample(
                n=int(len(x) * sample_size / len(feature_df)), random_state=42
            )
        )

        # Drop the temporary column
        sampled_df = sampled_df.drop("activity_bin", axis=1)
        feature_df = feature_df.drop("activity_bin", axis=1)

        logger.info(f"Sampled {len(sampled_df)} users")
    else:
        sampled_df = feature_df.copy()

    # Identify numeric columns for scaling
    numeric_columns = sampled_df.select_dtypes(include=[np.number]).columns.tolist()
    if "user_id" in numeric_columns:
        numeric_columns.remove("user_id")  # Don't scale user_id

    # Handle highly skewed features with log transformation
    skewed_features = []
    for col in numeric_columns:
        skewness = float(sampled_df[col].skew())
        if abs(skewness) > 2:  # High skewness
            skewed_features.append(col)
            # Log transform (add 1 to handle zeros)
            sampled_df[f"{col}_log"] = np.log1p(sampled_df[col])
            numeric_columns.append(f"{col}_log")
            numeric_columns.remove(col)

    if skewed_features:
        logger.info(f"Applied log transformation to skewed features: {skewed_features}")

    # Remove highly correlated features
    correlation_matrix = sampled_df[numeric_columns].corr().abs()
    upper_triangle = correlation_matrix.where(
        np.triu(np.ones(correlation_matrix.shape), k=1).astype(bool)
    )

    # Find features with correlation > 0.95
    high_corr_features = [
        column
        for column in upper_triangle.columns
        if any(upper_triangle[column] > 0.95)
    ]

    if high_corr_features:
        logger.info(f"Removing highly correlated features: {high_corr_features}")
        numeric_columns = [
            col for col in numeric_columns if col not in high_corr_features
        ]

    # Scale features
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(sampled_df[numeric_columns])

    logger.info(f"Scaled {X_scaled.shape[1]} features for {X_scaled.shape[0]} users")

    # Store feature names for later use
    sampled_df._numeric_columns = numeric_columns

    return sampled_df, X_scaled, scaler


def find_optimal_clusters(
    X_scaled: np.ndarray,
    k_range: Tuple[int, int] = (4, 6),
    output_dir: Optional[str] = None,
    min_cluster_size_ratio: float = 0.01,
) -> int:
    """
    Determine optimal number of clusters using elbow and silhouette methods.

    Args:
        X_scaled: Scaled feature matrix
        k_range: Range of k values to test
        output_dir: Directory to save the optimization plot
        min_cluster_size_ratio: Minimum cluster size as ratio of total population

    Returns:
        Optimal number of clusters
    """
    logger.info(f"Finding optimal clusters in range {k_range}")
    logger.info(
        f"Minimum cluster size: {min_cluster_size_ratio:.1%} of total population"
    )

    k_values = range(k_range[0], k_range[1] + 1)
    inertias = []
    silhouette_scores = []
    valid_k_values = []

    min_cluster_size = int(len(X_scaled) * min_cluster_size_ratio)

    for k in k_values:
        logger.info(f"Testing k={k}")

        # Fit k-means
        kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
        kmeans.fit(X_scaled)

        # Check cluster sizes
        unique, counts = np.unique(kmeans.labels_, return_counts=True)
        min_size = min(counts)

        if min_size < min_cluster_size:
            logger.info(
                f"k={k}: Smallest cluster has {min_size} users (below minimum {min_cluster_size}), skipping"
            )
            continue

        # Calculate metrics
        inertias.append(kmeans.inertia_)
        silhouette = silhouette_score(X_scaled, kmeans.labels_)
        silhouette_scores.append(silhouette)
        valid_k_values.append(k)

        logger.info(
            f"k={k}: inertia={kmeans.inertia_:.2f}, silhouette={silhouette:.3f}, min_cluster_size={min_size}"
        )

    if not valid_k_values:
        logger.warning("No valid k values found with minimum cluster size constraint!")
        # Fallback to the middle of the range
        return (k_range[0] + k_range[1]) // 2

    # Plot results
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))

    # Elbow plot
    ax1.plot(valid_k_values, inertias, "bo-")
    ax1.set_xlabel("Number of Clusters (k)")
    ax1.set_ylabel("Inertia")
    ax1.set_title("Elbow Method")
    ax1.grid(True)

    # Silhouette plot
    ax2.plot(valid_k_values, silhouette_scores, "ro-")
    ax2.set_xlabel("Number of Clusters (k)")
    ax2.set_ylabel("Silhouette Score")
    ax2.set_title("Silhouette Analysis")
    ax2.grid(True)

    plt.tight_layout()

    # Save to output directory if provided
    if output_dir:
        output_path = Path(output_dir) / "cluster_optimization.png"
    else:
        output_path = "cluster_optimization.png"

    plt.savefig(output_path)
    plt.close()
    logger.info(f"Saved optimization plot to {output_path}")

    # Find optimal k (highest silhouette score among valid k values)
    optimal_idx = np.argmax(silhouette_scores)
    optimal_k = valid_k_values[optimal_idx]
    logger.info(
        f"Optimal k={optimal_k} with silhouette score={silhouette_scores[optimal_idx]:.3f}"
    )

    return optimal_k


def merge_small_clusters(
    X_scaled: np.ndarray,
    cluster_labels: np.ndarray,
    kmeans_model: KMeans,
    min_cluster_size: int,
) -> Tuple[np.ndarray, KMeans]:
    """
    Merge clusters that are smaller than the minimum size with their nearest neighbors.

    Args:
        X_scaled: Scaled feature matrix
        cluster_labels: Initial cluster assignments
        kmeans_model: Fitted KMeans model
        min_cluster_size: Minimum allowed cluster size

    Returns:
        Tuple of (updated_labels, updated_model)
    """
    labels = cluster_labels.copy()
    centers = kmeans_model.cluster_centers_.copy()

    # Identify small clusters
    unique_labels, counts = np.unique(labels, return_counts=True)
    cluster_sizes = dict(zip(unique_labels, counts))
    small_clusters = [
        label for label, count in cluster_sizes.items() if count < min_cluster_size
    ]

    if not small_clusters:
        logger.info("No small clusters to merge")
        return labels, kmeans_model

    logger.info(
        f"Found {len(small_clusters)} small clusters to merge: {small_clusters}"
    )

    # Sort clusters by size (smallest first) to merge smallest ones first
    small_clusters.sort(key=lambda x: cluster_sizes[x])

    # Create mapping of old labels to new labels
    label_mapping = {i: i for i in range(len(unique_labels))}

    for small_cluster in small_clusters:
        if cluster_sizes[small_cluster] >= min_cluster_size:
            # This cluster has grown due to previous merges
            continue

        # Find nearest cluster based on centroid distance
        small_center = centers[small_cluster]

        # Calculate distances to all other clusters
        distances = []
        for other_cluster in unique_labels:
            if (
                other_cluster == small_cluster
                or cluster_sizes.get(other_cluster, 0) == 0
            ):
                continue
            other_center = centers[other_cluster]
            dist = np.linalg.norm(small_center - other_center)
            distances.append((other_cluster, dist))

        if not distances:
            logger.warning(f"No valid clusters to merge with cluster {small_cluster}")
            continue

        # Sort by distance and find nearest cluster
        distances.sort(key=lambda x: x[1])
        nearest_cluster = distances[0][0]

        logger.info(
            f"Merging cluster {small_cluster} ({cluster_sizes[small_cluster]} users) "
            f"into cluster {nearest_cluster} ({cluster_sizes[nearest_cluster]} users)"
        )

        # Update labels
        labels[labels == small_cluster] = nearest_cluster

        # Update cluster sizes
        cluster_sizes[nearest_cluster] += cluster_sizes[small_cluster]
        cluster_sizes[small_cluster] = 0

        # Update label mapping
        label_mapping[small_cluster] = nearest_cluster

    # Renumber clusters to be consecutive
    unique_remaining = np.unique(labels)
    new_label_mapping = {old: new for new, old in enumerate(sorted(unique_remaining))}
    labels = np.array([new_label_mapping[label] for label in labels])

    # Create new KMeans model with updated centers
    new_n_clusters = len(unique_remaining)
    new_kmeans = KMeans(n_clusters=new_n_clusters, random_state=42)
    new_kmeans.fit(X_scaled)
    new_kmeans.labels_ = labels

    # Log final cluster sizes
    unique_final, counts_final = np.unique(labels, return_counts=True)
    for cluster, count in zip(unique_final, counts_final):
        logger.info(
            f"Final cluster {cluster}: {count} users ({count/len(labels)*100:.1f}%)"
        )

    return labels, new_kmeans


def perform_clustering(
    X_scaled: np.ndarray, k: int, min_cluster_size_ratio: float = 0.01
) -> Tuple[KMeans, np.ndarray]:
    """
    Execute k-means clustering with minimum cluster size enforcement.

    Args:
        X_scaled: Scaled feature matrix
        k: Number of clusters
        min_cluster_size_ratio: Minimum cluster size as ratio of total population

    Returns:
        Tuple of (kmeans_model, cluster_labels)
    """
    logger.info(f"Performing k-means clustering with k={k}")

    min_cluster_size = int(len(X_scaled) * min_cluster_size_ratio)
    logger.info(
        f"Minimum cluster size: {min_cluster_size} users ({min_cluster_size_ratio:.1%})"
    )

    # Initialize and fit k-means
    kmeans = KMeans(
        n_clusters=k,
        random_state=42,
        n_init=10,
        max_iter=300,
        algorithm="elkan",  # More efficient for dense data
    )

    cluster_labels = kmeans.fit_predict(X_scaled)

    # Log initial cluster sizes
    unique, counts = np.unique(cluster_labels, return_counts=True)
    logger.info("Initial cluster sizes:")
    for cluster, count in zip(unique, counts):
        logger.info(
            f"Cluster {cluster}: {count} users ({count/len(cluster_labels)*100:.1f}%)"
        )

    # Merge small clusters
    cluster_labels, kmeans = merge_small_clusters(
        X_scaled, cluster_labels, kmeans, min_cluster_size
    )

    # Calculate final silhouette score
    final_silhouette = float(silhouette_score(X_scaled, cluster_labels))
    logger.info(f"Final silhouette score: {final_silhouette:.3f}")

    return kmeans, cluster_labels


def evaluate_cluster_stability(X_scaled: np.ndarray, k: int, n_runs: int = 5) -> float:
    """
    Evaluate cluster stability by running clustering multiple times.

    Args:
        X_scaled: Scaled feature matrix
        k: Number of clusters
        n_runs: Number of runs to perform

    Returns:
        Average stability score (0-1, higher is more stable)
    """
    logger.info(f"Evaluating cluster stability with {n_runs} runs")

    # Store labels from each run
    all_labels = []

    for i in range(n_runs):
        kmeans = KMeans(n_clusters=k, random_state=i * 42, n_init=10)
        labels = kmeans.fit_predict(X_scaled)
        all_labels.append(labels)

    # Calculate pairwise agreement between runs
    stability_scores = []

    for i in range(n_runs):
        for j in range(i + 1, n_runs):
            # Calculate agreement between run i and run j
            # Use adjusted Rand index for this
            from sklearn.metrics import adjusted_rand_score

            score = adjusted_rand_score(all_labels[i], all_labels[j])
            stability_scores.append(score)

    avg_stability = np.mean(stability_scores)
    std_stability = np.std(stability_scores)

    logger.info(f"Cluster stability: {avg_stability:.3f} ± {std_stability:.3f}")

    return avg_stability


def plot_cluster_visualization(
    X_scaled: np.ndarray,
    cluster_labels: np.ndarray,
    output_dir: Optional[str] = None,
    output_filename: str = "cluster_visualization.png",
):
    """
    Create visualization of clusters using PCA reduction.

    Args:
        X_scaled: Scaled feature matrix
        cluster_labels: Cluster assignments
        output_dir: Directory to save visualization
        output_filename: Name of the output file
    """
    from sklearn.decomposition import PCA

    logger.info("Creating cluster visualization")

    # Reduce to 2D using PCA
    pca = PCA(n_components=2)
    X_pca = pca.fit_transform(X_scaled)

    # Create scatter plot
    plt.figure(figsize=(10, 8))

    # Plot each cluster
    unique_labels = np.unique(cluster_labels)
    colors = sns.color_palette("husl", len(unique_labels))

    for label, color in zip(unique_labels, colors):
        mask = cluster_labels == label
        plt.scatter(
            X_pca[mask, 0],
            X_pca[mask, 1],
            c=[color],
            label=f"Cluster {label}",
            alpha=0.6,
            edgecolors="black",
            linewidth=0.5,
        )

    plt.xlabel(f"PC1 ({pca.explained_variance_ratio_[0]:.1%} variance)")
    plt.ylabel(f"PC2 ({pca.explained_variance_ratio_[1]:.1%} variance)")
    plt.title("User Clusters Visualization (PCA)")
    plt.legend()
    plt.grid(True, alpha=0.3)

    plt.tight_layout()

    # Save to output directory if provided
    if output_dir:
        output_path = Path(output_dir) / output_filename
    else:
        output_path = output_filename

    plt.savefig(output_path)
    plt.close()

    logger.info(f"Visualization saved to {output_path}")
