from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional


def _as_naive(dt: datetime) -> datetime:
    return dt.replace(tzinfo=None) if dt.tzinfo else dt

import numpy as np
import plotly.express as px
import plotly.graph_objects as go

from customer_retention.core.compat import (
    DataFrame,
    Series,
    batched_corr_matrix,
    safe_memory_usage_bytes,
    safe_to_datetime,
    safe_to_list,
    to_pandas,
)

from .number_formatter import NumberFormatter

if TYPE_CHECKING:
    from customer_retention.stages.profiling.segment_analyzer import SegmentationResult
    from customer_retention.stages.profiling.temporal_analyzer import TemporalAnalysis


class ChartBuilder:
    DOW_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

    def __init__(self, theme: str = "plotly_white"):
        self.theme = theme
        self.colors = {
            "primary": "#1f77b4",
            "secondary": "#ff7f0e",
            "success": "#2ca02c",
            "warning": "#ffbb00",
            "danger": "#d62728",
            "info": "#17becf"
        }

    def _get_quality_colors(self, values: List[float], high: float = 80, mid: float = 60) -> List[str]:
        return [
            self.colors["success"] if v > high else self.colors["warning"] if v > mid else self.colors["danger"]
            for v in values
        ]

    def _get_iv_colors(self, iv_values: List[float]) -> List[str]:
        return [
            self.colors["danger"] if iv > 0.5 else
            self.colors["success"] if iv > 0.3 else
            self.colors["warning"] if iv > 0.1 else
            self.colors["primary"]
            for iv in iv_values
        ]

    def _get_ks_colors(self, ks_values: List[float]) -> List[str]:
        return [
            self.colors["success"] if ks > 0.4 else
            self.colors["warning"] if ks > 0.2 else
            self.colors["primary"]
            for ks in ks_values
        ]

    def bar_chart(self, x: List[Any], y: List[Any], title: Optional[str] = None,
                  x_label: Optional[str] = None, y_label: Optional[str] = None,
                  horizontal: bool = False, color: Optional[str] = None) -> go.Figure:
        marker_color = color or self.colors["primary"]
        if horizontal:
            fig = go.Figure(go.Bar(y=x, x=y, orientation="h", marker_color=marker_color))
        else:
            fig = go.Figure(go.Bar(x=x, y=y, marker_color=marker_color))
        fig.update_layout(
            title=title,
            xaxis_title=x_label,
            yaxis_title=y_label,
            template=self.theme
        )
        return fig

    def column_type_distribution(self, type_counts: Dict[str, int]) -> go.Figure:
        if not type_counts:
            return go.Figure()
        fig = px.pie(
            values=list(type_counts.values()),
            names=list(type_counts.keys()),
            title="Column Type Distribution",
            hole=0.4
        )
        fig.update_layout(template=self.theme)
        return fig

    def data_quality_scorecard(self, quality_scores: Dict[str, float]) -> go.Figure:
        columns = list(quality_scores.keys())
        scores = list(quality_scores.values())
        fig = go.Figure(go.Bar(y=columns, x=scores, orientation="h", marker_color=self._get_quality_colors(scores)))
        fig.update_layout(
            title="Data Quality Scores by Column",
            xaxis_title="Quality Score (0-100)",
            template=self.theme,
            height=max(400, len(columns) * 25)
        )
        return fig

    def missing_value_bars(self, null_percentages: Dict[str, float]) -> go.Figure:
        columns = list(null_percentages.keys())
        pcts = list(null_percentages.values())
        colors = [self.colors["danger"] if p > 20 else self.colors["warning"] if p > 5 else self.colors["success"] for p in pcts]
        fig = go.Figure(go.Bar(x=columns, y=pcts, marker_color=colors))
        fig.update_layout(title="Missing Values by Column", yaxis_title="Missing %", template=self.theme)
        return fig

    def histogram_with_stats(self, series: Series, title: Optional[str] = None) -> go.Figure:

        clean = series.dropna()
        mean_val = clean.mean()
        median_val = clean.median()
        fig = go.Figure()
        fig.add_trace(go.Histogram(x=clean, nbinsx=30, name="Distribution"))
        fig.add_vline(x=mean_val, line_dash="dash", line_color=self.colors["primary"], annotation_text=f"Mean: {mean_val:.2f}")
        fig.add_vline(x=median_val, line_dash="dot", line_color=self.colors["secondary"], annotation_text=f"Median: {median_val:.2f}")
        fig.update_layout(
            title=title or f"Distribution of {series.name}",
            xaxis_title=series.name,
            yaxis_title="Count",
            template=self.theme
        )
        return fig

    def box_plot(self, series: Series, title: Optional[str] = None) -> go.Figure:

        fig = px.box(y=series.dropna(), title=title or f"Box Plot: {series.name}")
        fig.update_layout(template=self.theme)
        return fig

    def outlier_visualization(self, series: Series, method: str = "iqr") -> go.Figure:

        clean = series.dropna().reset_index(drop=True)
        q1, q3 = clean.quantile(0.25), clean.quantile(0.75)
        iqr = q3 - q1
        lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
        is_outlier = (clean < lower) | (clean > upper)
        fig = go.Figure()
        fig.add_trace(go.Scatter(x=clean[~is_outlier].index, y=clean[~is_outlier], mode="markers", name="Normal", marker_color=self.colors["primary"]))
        fig.add_trace(go.Scatter(x=clean[is_outlier].index, y=clean[is_outlier], mode="markers", name="Outliers", marker_color=self.colors["danger"]))
        fig.add_hline(y=upper, line_dash="dash", line_color="gray", annotation_text="Upper Bound")
        fig.add_hline(y=lower, line_dash="dash", line_color="gray", annotation_text="Lower Bound")
        fig.update_layout(title=f"Outlier Detection: {series.name}", template=self.theme)
        return fig

    def category_bar_chart(self, series: Series, top_n: int = 20) -> go.Figure:

        value_counts = series.value_counts().head(top_n)
        fig = go.Figure(go.Bar(x=value_counts.index.astype(str), y=value_counts.values, marker_color=self.colors["primary"]))
        fig.update_layout(
            title=f"Top {top_n} Categories: {series.name}",
            xaxis_title="Category",
            yaxis_title="Count",
            template=self.theme
        )
        return fig

    def correlation_heatmap(self, df: DataFrame, method: str = "pearson") -> go.Figure:
        numeric_cols = list(df.select_dtypes(include=["number"]).columns)
        corr = batched_corr_matrix(df, numeric_cols)
        fig = go.Figure(go.Heatmap(z=corr.to_numpy(), x=list(corr.columns), y=list(corr.columns), colorscale="RdBu", zmid=0))
        fig.update_layout(
            title=f"Correlation Matrix ({method})",
            template=self.theme,
            height=max(400, len(corr.columns) * 25)
        )
        return fig

    def target_correlation_bars(self, correlations: Dict[str, float], target_name: str) -> go.Figure:
        cols = list(correlations.keys())
        vals = list(correlations.values())
        colors = [self.colors["success"] if v > 0 else self.colors["danger"] for v in vals]
        fig = go.Figure(go.Bar(y=cols, x=vals, orientation="h", marker_color=colors))
        fig.update_layout(
            title=f"Correlation with Target: {target_name}",
            xaxis_title="Correlation",
            template=self.theme,
            height=max(400, len(cols) * 25)
        )
        return fig

    def roc_curve(self, fpr, tpr, auc_score: float) -> go.Figure:
        fig = go.Figure()
        fig.add_trace(go.Scatter(x=fpr, y=tpr, mode="lines", name=f"ROC (AUC={auc_score:.3f})"))
        fig.add_trace(go.Scatter(x=[0, 1], y=[0, 1], mode="lines", line_dash="dash", name="Random"))
        fig.update_layout(
            title="ROC Curve",
            xaxis_title="False Positive Rate",
            yaxis_title="True Positive Rate",
            template=self.theme
        )
        return fig

    def precision_recall_curve(
        self,
        precision,
        recall,
        pr_auc: float,
        baseline: Optional[float] = None,
        title: Optional[str] = None,
    ) -> go.Figure:
        fig = go.Figure()
        fig.add_trace(go.Scatter(
            x=recall, y=precision,
            mode="lines",
            name=f"PR (AUC={pr_auc:.3f})",
            line={"color": self.colors["primary"], "width": 2}
        ))

        if baseline is not None:
            fig.add_hline(
                y=baseline,
                line_dash="dash",
                line_color="gray",
                annotation_text=f"Baseline: {baseline:.2f}",
                annotation_position="right"
            )

        fig.update_layout(
            title=title or "Precision-Recall Curve",
            xaxis_title="Recall",
            yaxis_title="Precision",
            xaxis_range=[0, 1],
            yaxis_range=[0, 1.05],
            template=self.theme
        )
        return fig

    def model_comparison_grid(self, model_results: Dict[str, Dict[str, Any]], y_test: Any,
                              class_labels: Optional[List[str]] = None, title: Optional[str] = None) -> go.Figure:
        from plotly.subplots import make_subplots
        model_names, n_models = list(model_results.keys()), len(model_results)
        class_labels = class_labels or ["0", "1"]
        subplot_titles = [f"{name[:15]}<br>{row}" for row in ["Confusion Matrix", "ROC Curve", "Precision-Recall"] for name in model_names]
        fig = make_subplots(rows=3, cols=n_models, subplot_titles=subplot_titles, vertical_spacing=0.12, horizontal_spacing=0.08,
                            specs=[[{"type": "heatmap"} for _ in range(n_models)], [{"type": "xy"} for _ in range(n_models)], [{"type": "xy"} for _ in range(n_models)]])
        model_colors = [self.colors["primary"], self.colors["secondary"], self.colors["success"], self.colors["info"], self.colors["warning"]]
        baseline = np.mean(y_test)
        for i, model_name in enumerate(model_names):
            col, color = i + 1, model_colors[i % len(model_colors)]
            y_pred, y_pred_proba = model_results[model_name]["y_pred"], model_results[model_name]["y_pred_proba"]
            self._add_confusion_matrix_to_grid(fig, y_test, y_pred, class_labels, col)
            self._add_roc_curve_to_grid(fig, y_test, y_pred_proba, color, col, n_models)
            self._add_pr_curve_to_grid(fig, y_test, y_pred_proba, color, col, n_models, baseline)
        self._update_comparison_grid_axes(fig, n_models)
        fig.update_layout(title=title or "Model Comparison", height=300 * 3 + 100, width=350 * n_models + 50, template=self.theme, showlegend=False)
        return fig

    def _add_confusion_matrix_to_grid(self, fig: go.Figure, y_test: Any, y_pred: Any, class_labels: List[str], col: int) -> None:
        from sklearn.metrics import confusion_matrix
        cm = confusion_matrix(y_test, y_pred)
        cm_normalized = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]
        cm_text = [[f"{cm[i][j]}<br>({cm_normalized[i][j]:.0%})" for j in range(len(class_labels))] for i in range(len(class_labels))]
        fig.add_trace(go.Heatmap(z=cm, x=class_labels, y=class_labels, colorscale="Blues", text=cm_text, texttemplate="%{text}",
                                  textfont={"size": 11}, showscale=False, hovertemplate="Actual: %{y}<br>Predicted: %{x}<br>Count: %{z}<extra></extra>"), row=1, col=col)

    def _add_roc_curve_to_grid(self, fig: go.Figure, y_test: Any, y_pred_proba: Any, color: str, col: int, n_models: int) -> None:
        from sklearn.metrics import roc_auc_score, roc_curve
        fpr, tpr, _ = roc_curve(y_test, y_pred_proba)
        auc = roc_auc_score(y_test, y_pred_proba)
        fig.add_trace(go.Scatter(x=fpr, y=tpr, mode="lines", line={"color": color, "width": 2}, name=f"AUC={auc:.3f}", showlegend=False,
                                  hovertemplate="FPR: %{x:.2f}<br>TPR: %{y:.2f}<extra></extra>"), row=2, col=col)
        fig.add_trace(go.Scatter(x=[0, 1], y=[0, 1], mode="lines", line={"color": "gray", "width": 1, "dash": "dash"}, showlegend=False, hoverinfo="skip"), row=2, col=col)
        xref = f"x{col + n_models}" if col > 1 else "x" + str(n_models + 1) if n_models > 1 else "x2"
        yref = f"y{col + n_models}" if col > 1 else "y" + str(n_models + 1) if n_models > 1 else "y2"
        fig.add_annotation(x=0.95, y=0.05, xref=xref, yref=yref, text=f"AUC={auc:.3f}", showarrow=False, font={"size": 11, "color": color}, bgcolor="rgba(255,255,255,0.8)", xanchor="right")

    def _add_pr_curve_to_grid(self, fig: go.Figure, y_test: Any, y_pred_proba: Any, color: str, col: int, n_models: int, baseline: float) -> None:
        from sklearn.metrics import average_precision_score, precision_recall_curve
        precision, recall, _ = precision_recall_curve(y_test, y_pred_proba)
        pr_auc = average_precision_score(y_test, y_pred_proba)
        fig.add_trace(go.Scatter(x=recall, y=precision, mode="lines", line={"color": color, "width": 2}, name=f"PR-AUC={pr_auc:.3f}", showlegend=False,
                                  hovertemplate="Recall: %{x:.2f}<br>Precision: %{y:.2f}<extra></extra>"), row=3, col=col)
        fig.add_trace(go.Scatter(x=[0, 1], y=[baseline, baseline], mode="lines", line={"color": "gray", "width": 1, "dash": "dash"}, showlegend=False, hoverinfo="skip"), row=3, col=col)
        pr_row_offset = 2 * n_models
        xref = f"x{col + pr_row_offset}" if col + pr_row_offset > 1 else "x"
        yref = f"y{col + pr_row_offset}" if col + pr_row_offset > 1 else "y"
        fig.add_annotation(x=0.05, y=0.05, xref=xref, yref=yref, text=f"PR-AUC={pr_auc:.3f}", showarrow=False, font={"size": 11, "color": color}, bgcolor="rgba(255,255,255,0.8)", xanchor="left")

    def _update_comparison_grid_axes(self, fig: go.Figure, n_models: int) -> None:
        for i in range(n_models):
            col = i + 1
            fig.update_xaxes(title_text="Predicted", row=1, col=col)
            fig.update_yaxes(title_text="Actual", row=1, col=col)
            fig.update_xaxes(title_text="FPR", row=2, col=col, range=[0, 1])
            fig.update_yaxes(title_text="TPR", row=2, col=col, range=[0, 1.02])
            fig.update_xaxes(title_text="Recall", row=3, col=col, range=[0, 1])
            fig.update_yaxes(title_text="Precision", row=3, col=col, range=[0, 1.05])

    def confusion_matrix_heatmap(self, cm, labels: Optional[List[str]] = None) -> go.Figure:
        cm_array = np.array(cm)
        if labels is None:
            labels = [str(i) for i in range(len(cm_array))]
        fig = go.Figure(go.Heatmap(
            z=cm_array,
            x=labels,
            y=labels,
            colorscale="Blues",
            text=cm_array,
            texttemplate="%{text}"
        ))
        fig.update_layout(
            title="Confusion Matrix",
            xaxis_title="Predicted",
            yaxis_title="Actual",
            template=self.theme
        )
        return fig

    def feature_importance_plot(self, importance_df: DataFrame) -> go.Figure:
        fig = go.Figure(go.Bar(
            y=importance_df["feature"],
            x=importance_df["importance"],
            orientation="h",
            marker_color=self.colors["primary"]
        ))
        fig.update_layout(
            title="Feature Importance",
            xaxis_title="Importance",
            template=self.theme,
            height=max(400, len(importance_df) * 25)
        )
        return fig

    def lift_curve(self, percentiles, lift_values) -> go.Figure:
        fig = go.Figure()
        fig.add_trace(go.Scatter(x=percentiles, y=lift_values, mode="lines+markers", name="Model Lift"))
        fig.add_hline(y=1, line_dash="dash", line_color="gray", annotation_text="Baseline")
        fig.update_layout(
            title="Lift Curve",
            xaxis_title="Percentile",
            yaxis_title="Lift",
            template=self.theme
        )
        return fig

    def time_series_plot(self, df: DataFrame, date_col: str, value_col: str) -> go.Figure:
        fig = px.line(to_pandas(df), x=date_col, y=value_col)
        fig.update_layout(title=f"{value_col} over Time", template=self.theme)
        return fig

    def cohort_retention_heatmap(self, retention_matrix: DataFrame) -> go.Figure:
        fig = go.Figure(go.Heatmap(
            z=retention_matrix.to_numpy(),
            x=list(retention_matrix.columns),
            y=safe_to_list(retention_matrix.index),
            colorscale="Greens",
            text=np.round(retention_matrix.to_numpy(), 2),
            texttemplate="%{text:.0%}"
        ))
        fig.update_layout(
            title="Cohort Retention",
            xaxis_title="Months Since Start",
            yaxis_title="Cohort",
            template=self.theme
        )
        return fig

    def histogram(self, series: Series, title: Optional[str] = None, nbins: int = 30) -> go.Figure:

        fig = go.Figure(go.Histogram(x=series.dropna(), nbinsx=nbins, marker_color=self.colors["primary"]))
        fig.update_layout(
            title=title or f"Distribution of {series.name}",
            xaxis_title=series.name,
            yaxis_title="Count",
            template=self.theme
        )
        return fig

    def heatmap(self, z: Any, x_labels: List[str], y_labels: List[str],
                title: Optional[str] = None, colorscale: str = "RdBu") -> go.Figure:
        z_array = np.array(z) if not isinstance(z, np.ndarray) else z
        fig = go.Figure(go.Heatmap(
            z=z_array, x=x_labels, y=y_labels,
            colorscale=colorscale, zmid=0 if colorscale == "RdBu" else None
        ))
        fig.update_layout(
            title=title,
            template=self.theme,
            height=max(400, len(y_labels) * 25)
        )
        return fig

    def scatter_matrix(
        self,
        df: DataFrame,
        title: Optional[str] = None,
        height: Optional[int] = None,
        width: Optional[int] = None,
        color_column: Optional[Series] = None,
        color_map: Optional[Dict[str, str]] = None,
    ) -> go.Figure:
        n_cols = len(df.columns)
        auto_height = max(500, n_cols * 150)

        if color_column is not None:
            plot_df = df.copy()
            plot_df["_color_"] = color_column.to_numpy()
            default_colors = {"Retained": "#2ECC71", "Churned": "#E74C3C"}
            colors = color_map or default_colors
            fig = px.scatter_matrix(
                to_pandas(plot_df), dimensions=list(df.columns), color="_color_",
                title=title, color_discrete_map=colors
            )
            fig.update_traces(marker=dict(opacity=0.6, size=5))
        else:
            fig = px.scatter_matrix(to_pandas(df), title=title)

        fig.update_layout(template=self.theme, height=height or auto_height, autosize=True)
        if width:
            fig.update_layout(width=width)
        fig.update_traces(diagonal_visible=False, showupperhalf=False)
        return fig

    def multi_line_chart(self, data: List[Dict[str, Any]], x_key: str, y_key: str,
                         name_key: str, title: Optional[str] = None,
                         x_title: Optional[str] = None, y_title: Optional[str] = None) -> go.Figure:
        fig = go.Figure()
        for series in data:
            fig.add_trace(go.Scatter(
                x=series[x_key], y=series[y_key],
                mode="lines", name=series[name_key]
            ))
        fig.add_trace(go.Scatter(x=[0, 1], y=[0, 1], mode="lines", line_dash="dash",
                                  line_color="gray", name="Random"))
        fig.update_layout(title=title, xaxis_title=x_title, yaxis_title=y_title, template=self.theme)
        return fig

    def temporal_distribution(
        self,
        analysis: "TemporalAnalysis",
        title: Optional[str] = None,
        chart_type: str = "bar",
    ) -> go.Figure:
        period_counts = analysis.period_counts
        if period_counts.empty:
            fig = go.Figure()
            fig.add_annotation(text="No data available", x=0.5, y=0.5, showarrow=False)
            return fig

        x_values = period_counts["period"].astype(str)
        y_values = period_counts["count"]

        fig = go.Figure()
        if chart_type == "line":
            fig.add_trace(go.Scatter(
                x=x_values, y=y_values,
                mode="lines+markers",
                line={"color": self.colors["primary"], "width": 2},
                marker={"size": 6},
                name="Record Count"
            ))
        else:
            fig.add_trace(go.Bar(
                x=x_values, y=y_values,
                marker_color=self.colors["primary"],
                name="Record Count"
            ))

        mean_count = y_values.mean()
        fig.add_hline(
            y=mean_count,
            line_dash="dash",
            line_color=self.colors["secondary"],
            annotation_text=f"Avg: {mean_count:.0f}",
            annotation_position="top right"
        )

        granularity_label = analysis.granularity.value.capitalize()
        default_title = f"Records by {granularity_label}"
        fig.update_layout(
            title=title or default_title,
            xaxis_title=granularity_label,
            yaxis_title="Count",
            template=self.theme,
            xaxis_tickangle=-45 if len(x_values) > 12 else 0
        )
        return fig

    def temporal_trend(
        self,
        analysis: "TemporalAnalysis",
        title: Optional[str] = None,
        show_trend: bool = True,
    ) -> go.Figure:
        period_counts = analysis.period_counts
        if period_counts.empty:
            fig = go.Figure()
            fig.add_annotation(text="No data available", x=0.5, y=0.5, showarrow=False)
            return fig

        x_values = list(range(len(period_counts)))
        x_labels = period_counts["period"].astype(str)
        y_values = period_counts["count"].values

        fig = go.Figure()
        fig.add_trace(go.Scatter(
            x=x_labels, y=y_values,
            mode="lines+markers",
            line={"color": self.colors["primary"], "width": 2},
            marker={"size": 8},
            name="Actual"
        ))

        if show_trend and len(x_values) >= 2:
            z = np.polyfit(x_values, y_values, 1)
            trend_line = np.poly1d(z)(x_values)
            slope_pct = ((trend_line[-1] - trend_line[0]) / trend_line[0] * 100) if trend_line[0] != 0 else 0
            trend_direction = "increasing" if z[0] > 0 else "decreasing"
            trend_color = self.colors["success"] if z[0] > 0 else self.colors["danger"]

            fig.add_trace(go.Scatter(
                x=x_labels, y=trend_line,
                mode="lines",
                line={"color": trend_color, "width": 2, "dash": "dash"},
                name=f"Trend ({trend_direction}, {abs(slope_pct):.1f}%)"
            ))

        granularity_label = analysis.granularity.value.capitalize()
        default_title = f"Temporal Trend by {granularity_label}"
        fig.update_layout(
            title=title or default_title,
            xaxis_title=granularity_label,
            yaxis_title="Count",
            template=self.theme,
            xaxis_tickangle=-45 if len(x_labels) > 12 else 0,
            showlegend=True
        )
        return fig

    def temporal_heatmap(
        self,
        dates: Series,
        title: Optional[str] = None,
    ) -> go.Figure:

        parsed = safe_to_datetime(dates, errors="coerce").dropna()

        if len(parsed) == 0:
            fig = go.Figure()
            fig.add_annotation(text="No valid dates", x=0.5, y=0.5, showarrow=False)
            return fig

        counts = parsed.dt.dayofweek.value_counts().reindex(range(7), fill_value=0)

        fig = go.Figure(go.Bar(
            x=self.DOW_NAMES,
            y=counts.values,
            marker_color=[self.colors["info"] if i < 5 else self.colors["warning"] for i in range(7)]
        ))

        fig.update_layout(
            title=title or "Records by Day of Week",
            xaxis_title="Day of Week",
            yaxis_title="Count",
            template=self.theme
        )
        return fig

    def year_month_heatmap(
        self,
        pivot_df: "DataFrame",
        title: Optional[str] = None,
    ) -> go.Figure:
        if len(pivot_df) == 0:
            fig = go.Figure()
            fig.add_annotation(text="No data available", x=0.5, y=0.5, showarrow=False)
            return fig

        fig = go.Figure(go.Heatmap(
            z=pivot_df.to_numpy(),
            x=list(pivot_df.columns),
            y=[str(i) for i in pivot_df.index],
            colorscale="Blues",
            text=pivot_df.to_numpy(),
            texttemplate="%{text:,}",
            textfont={"size": 10},
            hovertemplate="Year: %{y}<br>Month: %{x}<br>Count: %{z:,}<extra></extra>"
        ))

        fig.update_layout(
            title=title or "Records by Year and Month",
            xaxis_title="Month",
            yaxis_title="Year",
            template=self.theme,
            height=max(300, len(pivot_df) * 40 + 100)
        )
        return fig

    def cumulative_growth_chart(
        self,
        cumulative_series: Series,
        title: Optional[str] = None,
    ) -> go.Figure:
        """Create a cumulative growth chart."""
        if len(cumulative_series) == 0:
            fig = go.Figure()
            fig.add_annotation(text="No data available", x=0.5, y=0.5, showarrow=False)
            return fig

        fig = go.Figure()
        fig.add_trace(go.Scatter(
            x=[str(p) for p in cumulative_series.index],
            y=cumulative_series.values,
            mode="lines+markers",
            fill="tozeroy",
            line={"color": self.colors["primary"], "width": 2},
            marker={"size": 6},
            name="Cumulative Count"
        ))

        fig.update_layout(
            title=title or "Cumulative Records Over Time",
            xaxis_title="Period",
            yaxis_title="Cumulative Count",
            template=self.theme,
            xaxis_tickangle=-45
        )
        return fig

    def year_over_year_lines(
        self,
        pivot_df: "DataFrame",
        title: Optional[str] = None,
    ) -> go.Figure:
        """Create year-over-year comparison line chart."""
        if len(pivot_df) == 0:
            fig = go.Figure()
            fig.add_annotation(text="No data available", x=0.5, y=0.5, showarrow=False)
            return fig

        colors = px.colors.qualitative.Set1
        fig = go.Figure()

        for i, year in enumerate(pivot_df.index):
            fig.add_trace(go.Scatter(
                x=list(pivot_df.columns),
                y=pivot_df.loc[year].to_numpy(),
                mode="lines+markers",
                name=str(year),
                line={"color": colors[i % len(colors)], "width": 2},
                marker={"size": 8}
            ))

        fig.update_layout(
            title=title or "Year-over-Year Comparison",
            xaxis_title="Month",
            yaxis_title="Count",
            template=self.theme,
            showlegend=True,
            legend={"title": "Year"}
        )
        return fig

    def growth_summary_indicators(
        self,
        growth_data: Dict[str, Any],
        title: Optional[str] = None,
    ) -> go.Figure:
        """Create growth summary with key indicators using compact number formatting."""
        if not growth_data.get("has_data"):
            fig = go.Figure()
            fig.add_annotation(text="Insufficient data", x=0.5, y=0.5, showarrow=False)
            return fig

        formatter = NumberFormatter()
        fig = go.Figure()

        # Define indicator positions (x_center, label)
        indicators = [
            (0.15, "Overall Growth", growth_data["overall_growth_pct"], "%"),
            (0.5, "Avg Monthly", growth_data["avg_monthly_growth"], "%/mo"),
            (0.85, f"Trend: {growth_data['trend_direction'].upper()}", growth_data["trend_slope"], "/mo"),
        ]

        for x_pos, label, value, suffix in indicators:
            color = self.colors["success"] if value >= 0 else self.colors["danger"]
            formatted_value = formatter.compact(abs(value))
            sign = "+" if value >= 0 else "-"
            display_text = f"{sign}{formatted_value}{suffix}"

            # Value annotation
            fig.add_annotation(
                x=x_pos, y=0.55,
                text=display_text,
                font={"size": 36, "color": color, "family": "Arial Black"},
                showarrow=False,
                xref="paper", yref="paper"
            )
            # Label annotation
            fig.add_annotation(
                x=x_pos, y=0.15,
                text=label,
                font={"size": 14, "color": "#666666"},
                showarrow=False,
                xref="paper", yref="paper"
            )

        fig.update_layout(
            title={"text": title or "Growth Summary", "font": {"size": 16}},
            template=self.theme,
            height=180,
            margin={"t": 60, "b": 20, "l": 20, "r": 20},
            xaxis={"visible": False},
            yaxis={"visible": False}
        )
        return fig

    def segment_overview(
        self,
        result: "SegmentationResult",
        title: Optional[str] = None,
    ) -> go.Figure:
        """Create overview of segments showing size and target rate."""
        from plotly.subplots import make_subplots

        profiles = result.profiles
        if not profiles:
            fig = go.Figure()
            fig.add_annotation(text="No segments found", x=0.5, y=0.5, showarrow=False)
            return fig

        segment_names = [f"Segment {p.segment_id}" for p in profiles]
        sizes = [p.size_pct for p in profiles]
        target_rates = [p.target_rate for p in profiles]
        has_target = any(tr is not None for tr in target_rates)

        fig = make_subplots(
            rows=1, cols=2 if has_target else 1,
            specs=[[{"type": "pie"}, {"type": "bar"}]] if has_target else [[{"type": "pie"}]],
            subplot_titles=["Segment Sizes", "Target Rate by Segment"] if has_target else ["Segment Sizes"],
        )

        colors = px.colors.qualitative.Set2[:len(profiles)]
        fig.add_trace(
            go.Pie(
                labels=segment_names,
                values=sizes,
                marker_colors=colors,
                textinfo="label+percent",
                hovertemplate="<b>%{label}</b><br>Size: %{value:.1f}%<extra></extra>",
            ),
            row=1, col=1
        )

        if has_target:
            target_rates_clean = [tr if tr is not None else 0 for tr in target_rates]
            fig.add_trace(
                go.Bar(
                    x=segment_names,
                    y=[tr * 100 for tr in target_rates_clean],
                    marker_color=colors,
                    text=[f"{tr*100:.1f}%" for tr in target_rates_clean],
                    textposition="outside",
                    hovertemplate="<b>%{x}</b><br>Target Rate: %{y:.1f}%<extra></extra>",
                ),
                row=1, col=2
            )
            max_rate = max(target_rates_clean) * 100
            y_max = max_rate * 1.3 if max_rate > 0 else 10
            fig.update_yaxes(title_text="Target Rate (%)", row=1, col=2, range=[0, y_max])

        fig.update_layout(
            title=title or f"Segment Overview ({result.n_segments} segments)",
            template=self.theme,
            height=400,
            showlegend=False,
        )
        return fig

    def segment_feature_comparison(
        self,
        result: "SegmentationResult",
        features: Optional[List[str]] = None,
        title: Optional[str] = None,
    ) -> go.Figure:
        """Compare feature distributions across segments using grouped bars."""
        profiles = result.profiles
        if not profiles:
            fig = go.Figure()
            fig.add_annotation(text="No segments found", x=0.5, y=0.5, showarrow=False)
            return fig

        all_features = set()
        for p in profiles:
            all_features.update(p.defining_features.keys())

        if features:
            all_features = [f for f in features if f in all_features]
        else:
            all_features = sorted(all_features)[:8]

        if not all_features:
            fig = go.Figure()
            fig.add_annotation(text="No features to compare", x=0.5, y=0.5, showarrow=False)
            return fig

        colors = px.colors.qualitative.Set2[:len(profiles)]
        fig = go.Figure()

        for i, profile in enumerate(profiles):
            means = []
            for feat in all_features:
                feat_data = profile.defining_features.get(feat, {})
                means.append(feat_data.get("mean", 0))

            fig.add_trace(go.Bar(
                name=f"Segment {profile.segment_id}",
                x=list(all_features),
                y=means,
                marker_color=colors[i],
            ))

        fig.update_layout(
            title=title or "Feature Comparison Across Segments",
            xaxis_title="Feature",
            yaxis_title="Mean Value",
            barmode="group",
            template=self.theme,
            height=400,
            legend={"title": "Segment"},
        )
        return fig

    def segment_recommendation_card(
        self,
        result: "SegmentationResult",
        title: Optional[str] = None,
    ) -> go.Figure:
        """Display segmentation recommendation with rationale."""
        recommendation_colors = {
            "single_model": self.colors["success"],
            "consider_segmentation": self.colors["warning"],
            "strong_segmentation": self.colors["danger"],
        }
        recommendation_labels = {
            "single_model": "Single Model Recommended",
            "consider_segmentation": "Consider Segmentation",
            "strong_segmentation": "Segmentation Strongly Recommended",
        }

        rec_color = recommendation_colors.get(result.recommendation, self.colors["info"])
        rec_label = recommendation_labels.get(result.recommendation, result.recommendation)

        fig = go.Figure()

        # Recommendation header
        fig.add_annotation(
            x=0.5, y=0.85,
            text=rec_label,
            font={"size": 24, "color": rec_color, "family": "Arial Black"},
            showarrow=False,
            xref="paper", yref="paper"
        )

        # Confidence indicator
        fig.add_annotation(
            x=0.5, y=0.65,
            text=f"Confidence: {result.confidence*100:.0f}%",
            font={"size": 16, "color": "#666666"},
            showarrow=False,
            xref="paper", yref="paper"
        )

        # Key metrics
        metrics_text = (
            f"Segments: {result.n_segments} | "
            f"Quality: {result.quality_score:.2f} | "
            f"Target Variance: {result.target_variance_ratio:.2f}"
            if result.target_variance_ratio is not None
            else f"Segments: {result.n_segments} | Quality: {result.quality_score:.2f}"
        )
        fig.add_annotation(
            x=0.5, y=0.48,
            text=metrics_text,
            font={"size": 14, "color": "#888888"},
            showarrow=False,
            xref="paper", yref="paper"
        )

        # Rationale
        rationale_text = "<br>".join(f"• {r}" for r in result.rationale[:4])
        fig.add_annotation(
            x=0.5, y=0.2,
            text=rationale_text,
            font={"size": 12, "color": "#666666"},
            showarrow=False,
            xref="paper", yref="paper",
            align="center"
        )

        fig.update_layout(
            title=title or "Segmentation Recommendation",
            template=self.theme,
            height=280,
            margin={"t": 50, "b": 20, "l": 20, "r": 20},
            xaxis={"visible": False, "range": [0, 1]},
            yaxis={"visible": False, "range": [0, 1]},
        )
        return fig

    # =========================================================================
    # Advanced Time Series Visualizations
    # =========================================================================

    def sparkline(
        self,
        values: List[float],
        title: Optional[str] = None,
        show_endpoints: bool = True,
        show_min_max: bool = True,
        height: int = 60,
        width: int = 200,
    ) -> go.Figure:
        """Create a compact sparkline for inline time series display.

        Sparklines are small, word-sized graphics that show trends at a glance.
        Ideal for dashboards and tables where space is limited.
        """
        x = list(range(len(values)))

        fig = go.Figure()
        fig.add_trace(go.Scatter(
            x=x, y=values,
            mode="lines",
            line={"color": self.colors["primary"], "width": 1.5},
            hoverinfo="y"
        ))

        if show_endpoints and len(values) >= 2:
            fig.add_trace(go.Scatter(
                x=[0, len(values) - 1],
                y=[values[0], values[-1]],
                mode="markers",
                marker={"color": self.colors["primary"], "size": 6},
                hoverinfo="y"
            ))

        if show_min_max and len(values) >= 2:
            min_idx, max_idx = int(np.argmin(values)), int(np.argmax(values))
            fig.add_trace(go.Scatter(
                x=[min_idx], y=[values[min_idx]],
                mode="markers",
                marker={"color": self.colors["danger"], "size": 5},
                hovertemplate=f"Min: {values[min_idx]:.2f}<extra></extra>"
            ))
            fig.add_trace(go.Scatter(
                x=[max_idx], y=[values[max_idx]],
                mode="markers",
                marker={"color": self.colors["success"], "size": 5},
                hovertemplate=f"Max: {values[max_idx]:.2f}<extra></extra>"
            ))

        fig.update_layout(
            title={"text": title, "font": {"size": 10}} if title else None,
            height=height,
            width=width,
            margin={"t": 20 if title else 5, "b": 5, "l": 5, "r": 5},
            xaxis={"visible": False},
            yaxis={"visible": False},
            showlegend=False,
            template=self.theme,
        )
        return fig

    def sparkline_grid(
        self,
        data: Dict[str, List[float]],
        columns: int = 4,
        sparkline_height: int = 60,
        sparkline_width: int = 180,
    ) -> go.Figure:
        """Create a grid of sparklines for multiple time series comparison."""
        from plotly.subplots import make_subplots

        names = list(data.keys())
        n_rows = (len(names) + columns - 1) // columns

        fig = make_subplots(
            rows=n_rows, cols=columns,
            subplot_titles=names,
            vertical_spacing=0.15,
            horizontal_spacing=0.08,
        )

        for i, (name, values) in enumerate(data.items()):
            row, col = (i // columns) + 1, (i % columns) + 1
            x = list(range(len(values)))

            fig.add_trace(
                go.Scatter(x=x, y=values, mode="lines",
                          line={"color": self.colors["primary"], "width": 1.5},
                          showlegend=False),
                row=row, col=col
            )

            if len(values) >= 2:
                trend = values[-1] - values[0]
                color = self.colors["success"] if trend >= 0 else self.colors["danger"]
                fig.add_trace(
                    go.Scatter(x=[len(values) - 1], y=[values[-1]], mode="markers",
                              marker={"color": color, "size": 6}, showlegend=False),
                    row=row, col=col
                )

        fig.update_xaxes(visible=False)
        fig.update_yaxes(visible=False)
        fig.update_layout(
            height=n_rows * sparkline_height + 50,
            template=self.theme,
            margin={"t": 40, "b": 20},
        )
        return fig

    def calendar_heatmap(
        self,
        dates: Series,
        values: Optional[Series] = None,
        title: Optional[str] = None,
        colorscale: str = "Blues",
    ) -> go.Figure:
        """Create a calendar heatmap showing patterns by day-of-week and week-of-year.

        Similar to GitHub contribution graphs. Shows temporal patterns at a glance.
        If values not provided, shows count of occurrences per day.
        """
        import pandas as pd

        parsed = safe_to_datetime(dates, errors="coerce")

        if values is not None:

            df_cal = pd.DataFrame({"date": parsed, "value": values}).dropna()
            daily = df_cal.groupby(df_cal["date"].dt.date)["value"].sum()
        else:
            daily = parsed.dropna().dt.date.value_counts().sort_index()

        if len(daily) == 0:
            fig = go.Figure()
            fig.add_annotation(text="No valid dates", x=0.5, y=0.5, showarrow=False)
            return fig

        df_daily = pd.DataFrame({"date": pd.to_datetime(daily.index), "value": daily.values})
        df_daily["week"] = df_daily["date"].dt.isocalendar().week
        df_daily["year"] = df_daily["date"].dt.year
        df_daily["dow"] = df_daily["date"].dt.dayofweek
        df_daily["year_week"] = df_daily["year"].astype(str) + "-W" + df_daily["week"].astype(str).str.zfill(2)

        pivot = df_daily.pivot_table(index="dow", columns="year_week", values="value", aggfunc="sum")

        fig = go.Figure(go.Heatmap(
            z=pivot.values,
            x=pivot.columns.tolist(),
            y=[self.DOW_NAMES[i] for i in pivot.index],
            colorscale=colorscale,
            hovertemplate="Week: %{x}<br>Day: %{y}<br>Value: %{z:,.0f}<extra></extra>",
        ))

        fig.update_layout(
            title=title or "Calendar Heatmap",
            xaxis_title="Week",
            yaxis_title="Day of Week",
            template=self.theme,
            height=250,
            xaxis={"tickangle": -45, "dtick": 4},
        )
        return fig

    def monthly_calendar_heatmap(
        self,
        dates: Series,
        values: Optional[Series] = None,
        title: Optional[str] = None,
    ) -> go.Figure:
        """Create a month x day-of-week heatmap for pattern discovery."""
        import pandas as pd

        parsed = safe_to_datetime(dates, errors="coerce").dropna()

        if values is not None:

            df_cal = pd.DataFrame({"date": parsed, "value": values}).dropna()
            df_cal["month"] = df_cal["date"].dt.month
            df_cal["dow"] = df_cal["date"].dt.dayofweek
            pivot = df_cal.pivot_table(index="dow", columns="month", values="value", aggfunc="mean")
        else:
            df_cal = pd.DataFrame({"date": parsed})
            df_cal["month"] = df_cal["date"].dt.month
            df_cal["dow"] = df_cal["date"].dt.dayofweek
            pivot = df_cal.groupby(["dow", "month"]).size().unstack(fill_value=0)

        month_labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
                       "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

        fig = go.Figure(go.Heatmap(
            z=pivot.values,
            x=[month_labels[i-1] for i in pivot.columns],
            y=[self.DOW_NAMES[i] for i in pivot.index],
            colorscale="YlOrRd",
            hovertemplate="Month: %{x}<br>Day: %{y}<br>Value: %{z:,.1f}<extra></extra>",
        ))

        fig.update_layout(
            title=title or "Activity by Month and Day of Week",
            template=self.theme,
            height=280,
        )
        return fig

    def time_series_with_anomalies(
        self,
        dates: Series,
        values: Series,
        window: int = 7,
        n_std: float = 2.0,
        title: Optional[str] = None,
    ) -> go.Figure:
        """Create time series plot with anomaly detection bands.

        Uses rolling mean ± n_std * rolling_std to define normal bounds.
        Points outside bounds are highlighted as anomalies.
        """
        import pandas as pd

        df = pd.DataFrame({"date": safe_to_datetime(dates), "value": values}).dropna()
        df = df.sort_values("date")

        df["rolling_mean"] = df["value"].rolling(window=window, center=True, min_periods=1).mean()
        df["rolling_std"] = df["value"].rolling(window=window, center=True, min_periods=1).std()
        df["upper"] = df["rolling_mean"] + n_std * df["rolling_std"]
        df["lower"] = df["rolling_mean"] - n_std * df["rolling_std"]
        df["is_anomaly"] = (df["value"] > df["upper"]) | (df["value"] < df["lower"])

        anomaly_count = df["is_anomaly"].sum()
        anomaly_pct = anomaly_count / len(df) * 100

        fig = go.Figure()

        # Confidence band
        fig.add_trace(go.Scatter(
            x=pd.concat([df["date"], df["date"][::-1]]),
            y=pd.concat([df["upper"], df["lower"][::-1]]),
            fill="toself",
            fillcolor="rgba(31, 119, 180, 0.2)",
            line={"color": "rgba(255,255,255,0)"},
            name=f"Normal Range (±{n_std}σ)",
            hoverinfo="skip",
        ))

        # Rolling mean
        fig.add_trace(go.Scatter(
            x=df["date"], y=df["rolling_mean"],
            mode="lines",
            line={"color": self.colors["info"], "width": 1, "dash": "dash"},
            name="Rolling Mean",
        ))

        # Normal points
        normal = df[~df["is_anomaly"]]
        fig.add_trace(go.Scatter(
            x=normal["date"], y=normal["value"],
            mode="lines+markers",
            line={"color": self.colors["primary"], "width": 1.5},
            marker={"size": 4},
            name="Normal",
        ))

        # Anomaly points
        anomalies = df[df["is_anomaly"]]
        if len(anomalies) > 0:
            fig.add_trace(go.Scatter(
                x=anomalies["date"], y=anomalies["value"],
                mode="markers",
                marker={"color": self.colors["danger"], "size": 10, "symbol": "x"},
                name=f"Anomalies ({anomaly_count})",
            ))

        fig.update_layout(
            title=title or f"Time Series with Anomalies ({anomaly_pct:.1f}% anomalous)",
            xaxis_title="Date",
            yaxis_title="Value",
            template=self.theme,
            height=400,
            legend={"orientation": "h", "y": -0.15},
        )
        return fig

    def waterfall_chart(
        self,
        categories: List[str],
        values: List[float],
        title: Optional[str] = None,
        initial_label: str = "Start",
        final_label: str = "End",
    ) -> go.Figure:
        """Create a waterfall chart showing cumulative impact.

        Shows how sequential changes contribute to a final result.
        Useful for explaining score breakdowns or cumulative effects.
        """
        measures = ["absolute"] + ["relative"] * len(values) + ["total"]
        x_labels = [initial_label] + categories + [final_label]

        initial_value = 0
        cumulative = initial_value
        y_values = [initial_value]
        text_values = [f"{initial_value:,.0f}"]

        for v in values:
            y_values.append(v)
            cumulative += v
            sign = "+" if v >= 0 else ""
            text_values.append(f"{sign}{v:,.0f}")

        y_values.append(cumulative)
        text_values.append(f"{cumulative:,.0f}")

        colors = [self.colors["info"]]  # Initial
        for v in values:
            colors.append(self.colors["success"] if v >= 0 else self.colors["danger"])
        colors.append(self.colors["primary"])  # Total

        fig = go.Figure(go.Waterfall(
            x=x_labels,
            y=y_values,
            measure=measures,
            text=text_values,
            textposition="outside",
            connector={"line": {"color": "gray", "width": 1, "dash": "dot"}},
            increasing={"marker": {"color": self.colors["success"]}},
            decreasing={"marker": {"color": self.colors["danger"]}},
            totals={"marker": {"color": self.colors["primary"]}},
        ))

        fig.update_layout(
            title=title or "Waterfall Chart",
            template=self.theme,
            height=400,
            showlegend=False,
        )
        return fig

    def quality_waterfall(
        self,
        check_results: List[Dict[str, Any]],
        max_score: int = 100,
        title: Optional[str] = None,
    ) -> go.Figure:
        """Create a waterfall chart specifically for quality score breakdown.

        Shows how each check contributes to or detracts from the total score.

        Args:
            check_results: List of dicts with 'name', 'passed', 'weight' keys
            max_score: Maximum possible score (default 100)
            title: Chart title
        """
        categories = []
        values = []

        for check in check_results:
            categories.append(check["name"])
            if check["passed"]:
                values.append(0)  # No penalty
            else:
                penalty = -check["weight"] * (max_score / sum(c["weight"] for c in check_results))
                values.append(penalty)

        return self.waterfall_chart(
            categories=categories,
            values=values,
            title=title or "Quality Score Breakdown",
            initial_label="Max Score",
            final_label="Final Score",
        )

    def velocity_acceleration_chart(
        self,
        data: Dict[str, Dict[str, List[float]]],
        title: Optional[str] = None,
    ) -> go.Figure:
        """Create side-by-side Value/Velocity/Acceleration chart for cohort comparison.

        Args:
            data: Dict with structure {column: {"retained": [...], "churned": [...], "velocity_retained": [...], ...}}
            title: Chart title
        """
        from plotly.subplots import make_subplots

        columns = list(data.keys())
        n_cols = len(columns)

        fig = make_subplots(
            rows=n_cols, cols=3,
            subplot_titles=[f"{col[:12]} - Value" for col in columns] +
                          [f"{col[:12]} - Velocity" for col in columns] +
                          [f"{col[:12]} - Accel." for col in columns],
            vertical_spacing=0.08,
            horizontal_spacing=0.08,
        )

        for i, col in enumerate(columns):
            row = i + 1
            col_data = data[col]

            # Value
            if "retained" in col_data:
                fig.add_trace(go.Scatter(
                    y=col_data["retained"], mode="lines",
                    line={"color": self.colors["success"], "width": 1.5},
                    name="Retained", showlegend=(i == 0), legendgroup="retained"
                ), row=row, col=1)
            if "churned" in col_data:
                fig.add_trace(go.Scatter(
                    y=col_data["churned"], mode="lines",
                    line={"color": self.colors["danger"], "width": 1.5},
                    name="Churned", showlegend=(i == 0), legendgroup="churned"
                ), row=row, col=1)

            # Velocity
            if "velocity_retained" in col_data:
                fig.add_trace(go.Scatter(
                    y=col_data["velocity_retained"], mode="lines",
                    line={"color": self.colors["success"], "width": 1.5},
                    showlegend=False, legendgroup="retained"
                ), row=row, col=2)
            if "velocity_churned" in col_data:
                fig.add_trace(go.Scatter(
                    y=col_data["velocity_churned"], mode="lines",
                    line={"color": self.colors["danger"], "width": 1.5},
                    showlegend=False, legendgroup="churned"
                ), row=row, col=2)
            fig.add_hline(y=0, line_dash="dot", line_color="gray", row=row, col=2)

            # Acceleration
            if "accel_retained" in col_data:
                fig.add_trace(go.Scatter(
                    y=col_data["accel_retained"], mode="lines",
                    line={"color": self.colors["success"], "width": 1.5},
                    showlegend=False, legendgroup="retained"
                ), row=row, col=3)
            if "accel_churned" in col_data:
                fig.add_trace(go.Scatter(
                    y=col_data["accel_churned"], mode="lines",
                    line={"color": self.colors["danger"], "width": 1.5},
                    showlegend=False, legendgroup="churned"
                ), row=row, col=3)
            fig.add_hline(y=0, line_dash="dot", line_color="gray", row=row, col=3)

        fig.update_xaxes(showticklabels=False)
        fig.update_yaxes(showticklabels=False)
        fig.update_layout(
            height=150 * n_cols + 80,
            title=title or "Value → Velocity → Acceleration",
            template=self.theme,
            legend={"orientation": "h", "y": 1.02, "x": 0.5, "xanchor": "center"},
            margin={"t": 100},
        )
        return fig

    def _create_effect_heatmap_trace(self, metric_data: Dict, variables: List[str], windows: List[str], show_colorbar: bool) -> go.Heatmap:
        z_vals = [[metric_data.get(var, {}).get(w, 0) for w in windows] for var in variables]
        text_vals = [[f"{metric_data.get(var, {}).get(w, 0):.2f}" for w in windows] for var in variables]
        return go.Heatmap(
            z=z_vals, x=windows, y=[v[:15] for v in variables],
            colorscale="RdBu_r", zmid=0, zmin=-1, zmax=1,
            text=text_vals, texttemplate="%{text}", textfont={"size": 10},
            showscale=show_colorbar, colorbar={"title": "Cohen's d"} if show_colorbar else None
        )

    def velocity_signal_heatmap(self, data: Dict[str, Dict[str, Dict[str, float]]], title: Optional[str] = None) -> go.Figure:
        from plotly.subplots import make_subplots
        vel_data, accel_data = data.get("velocity", {}), data.get("acceleration", {})
        if not vel_data and not accel_data:
            fig = go.Figure()
            fig.update_layout(title=title or "No data", template=self.theme)
            return fig
        variables = list(vel_data.keys()) or list(accel_data.keys())
        windows = list(next(iter(vel_data.values())).keys()) if vel_data else []
        fig = make_subplots(
            rows=2, cols=1, subplot_titles=["Velocity Effect Size (d)", "Acceleration Effect Size (d)"],
            vertical_spacing=0.15
        )
        for row_idx, metric_data in enumerate([vel_data, accel_data], start=1):
            fig.add_trace(
                self._create_effect_heatmap_trace(metric_data, variables, windows, row_idx == 2),
                row=row_idx, col=1
            )
        fig.update_layout(
            title=title or "Velocity & Acceleration Signal Strength",
            height=max(400, len(variables) * 80 + 200), template=self.theme
        )
        return fig

    def cohort_velocity_sparklines(self, results: List[Any], feature_name: str, title: Optional[str] = None) -> go.Figure:
        from plotly.subplots import make_subplots
        if not results:
            fig = go.Figure()
            fig.update_layout(title=title or f"{feature_name} - No data", template=self.theme)
            return fig
        n_windows = len(results)
        col_titles = [getattr(r, "period_label", f"{r.window_days}d") for r in results]
        row_titles = ["Retained", "Churned", "Overall", "Retained", "Churned", "Overall"]
        fig = make_subplots(
            rows=6, cols=n_windows, row_titles=row_titles, column_titles=col_titles,
            vertical_spacing=0.06, horizontal_spacing=0.03,
            row_heights=[1, 1, 1, 1, 1, 1]
        )
        styles = {
            "retained": (self.colors["success"], "rgba(44, 160, 44, 0.2)"),
            "churned": (self.colors["danger"], "rgba(214, 39, 40, 0.2)"),
            "overall": (self.colors["info"], "rgba(23, 190, 207, 0.2)"),
        }
        for col_idx, r in enumerate(results, start=1):
            self._add_velocity_sparkline(fig, r.retained_velocity, styles["retained"], 1, col_idx)
            self._add_velocity_sparkline(fig, r.churned_velocity, styles["churned"], 2, col_idx)
            self._add_velocity_sparkline(fig, r.overall_velocity, styles["overall"], 3, col_idx)
            self._add_velocity_sparkline(fig, r.retained_accel, styles["retained"], 4, col_idx)
            self._add_velocity_sparkline(fig, r.churned_accel, styles["churned"], 5, col_idx)
            self._add_velocity_sparkline(fig, r.overall_accel, styles["overall"], 6, col_idx)
        fig.update_xaxes(showticklabels=False, showgrid=False)
        fig.update_yaxes(showticklabels=False, showgrid=False)
        fig.update_layout(
            title=title or f"<b>{feature_name}</b>",
            height=520, template=self.theme,
            margin={"t": 60, "b": 20, "l": 80, "r": 70}
        )
        fig.add_annotation(
            text="<b>Velocity</b>", textangle=-90, xref="paper", yref="paper",
            x=-0.06, y=0.77, showarrow=False, font={"size": 12}
        )
        fig.add_annotation(
            text="<b>Acceleration</b>", textangle=-90, xref="paper", yref="paper",
            x=-0.06, y=0.23, showarrow=False, font={"size": 12}
        )
        return fig

    def _add_velocity_sparkline(
        self, fig: go.Figure, data: List[float], style: tuple, row: int, col: int
    ) -> None:
        if not data:
            return
        color, fill = style
        fig.add_trace(go.Scatter(
            y=data, mode="lines", line={"color": color, "width": 1.5},
            fill="tozeroy", fillcolor=fill, showlegend=False
        ), row=row, col=col)

    def lag_correlation_heatmap(self, data: Dict[str, List[float]], max_lag: int = 14, title: Optional[str] = None) -> go.Figure:
        columns = list(data.keys())
        z_values = [data[col][:max_lag] for col in columns]
        lag_labels = [f"Lag {i}" for i in range(1, max_lag + 1)]

        fig = go.Figure(go.Heatmap(
            z=z_values,
            x=lag_labels,
            y=[col[:15] for col in columns],
            colorscale="RdBu_r",
            zmid=0,
            text=[[f"{v:.2f}" for v in row] for row in z_values],
            texttemplate="%{text}",
            textfont={"size": 9},
            colorbar={"title": "Correlation"},
        ))

        fig.update_layout(
            title=title or "Autocorrelation by Lag",
            xaxis_title="Lag (periods)",
            yaxis_title="Variable",
            template=self.theme,
            height=50 * len(columns) + 150,
        )
        return fig

    def predictive_power_chart(
        self,
        iv_values: Dict[str, float],
        ks_values: Dict[str, float],
        title: Optional[str] = None,
    ) -> go.Figure:
        """Create side-by-side IV and KS statistic bar charts.

        Args:
            iv_values: Dict with {column: iv_value}
            ks_values: Dict with {column: ks_value}
            title: Chart title
        """
        from plotly.subplots import make_subplots

        # Sort by IV
        sorted_cols = sorted(iv_values.keys(), key=lambda x: iv_values[x], reverse=True)
        ivs = [iv_values[c] for c in sorted_cols]
        kss = [ks_values.get(c, 0) for c in sorted_cols]
        col_labels = [c[:15] for c in sorted_cols]

        fig = make_subplots(
            rows=1, cols=2,
            subplot_titles=("Information Value (IV)", "KS Statistic"),
        )

        fig.add_trace(go.Bar(
            x=col_labels, y=ivs, marker_color=self._get_iv_colors(ivs), name="IV"
        ), row=1, col=1)
        fig.add_hline(y=0.3, line_dash="dash", line_color="green", row=1, col=1)
        fig.add_hline(y=0.1, line_dash="dash", line_color="orange", row=1, col=1)

        fig.add_trace(go.Bar(
            x=col_labels, y=kss, marker_color=self._get_ks_colors(kss), name="KS"
        ), row=1, col=2)
        fig.add_hline(y=0.4, line_dash="dash", line_color="green", row=1, col=2)
        fig.add_hline(y=0.2, line_dash="dash", line_color="orange", row=1, col=2)

        fig.update_layout(
            title=title or "Variable Predictive Power",
            template=self.theme,
            height=400,
            showlegend=False,
        )
        fig.update_xaxes(tickangle=45)
        return fig

    def momentum_comparison_chart(
        self,
        data: Dict[str, Dict[str, float]],
        title: Optional[str] = None,
        window_label: Optional[str] = None,
    ) -> go.Figure:
        columns = list(data.keys())
        first_col_data = data[columns[0]] if columns else {}
        uses_simple_keys = "retained" in first_col_data or "churned" in first_col_data

        if uses_simple_keys:
            return self._create_simple_momentum_chart(data, columns, title, window_label)
        return self._create_multi_window_momentum_chart(data, columns, title, window_label)

    def _create_simple_momentum_chart(
        self, data: Dict, columns: List[str], title: Optional[str], window_label: Optional[str]
    ) -> go.Figure:
        col_labels = [c[:15] for c in columns]
        fig = go.Figure()
        fig.add_trace(go.Bar(
            name="Retained", x=col_labels,
            y=[data[c].get("retained", 1) for c in columns],
            marker_color=self.colors["success"],
        ))
        fig.add_trace(go.Bar(
            name="Churned", x=col_labels,
            y=[data[c].get("churned", 1) for c in columns],
            marker_color=self.colors["danger"],
        ))
        fig.add_hline(y=1.0, line_dash="dash", line_color="gray",
                     annotation_text="baseline", annotation_position="right")
        chart_title = title or f"Momentum Comparison{f' ({window_label})' if window_label else ''}"
        fig.update_layout(
            title=chart_title, template=self.theme, height=450, barmode="group",
            legend={"orientation": "h", "y": -0.15, "x": 0.5, "xanchor": "center"},
            xaxis_title="Feature", yaxis_title="Momentum (>1 = increasing, <1 = decreasing)",
            margin={"b": 100},
        )
        return fig

    def _create_multi_window_momentum_chart(
        self, data: Dict, columns: List[str], title: Optional[str], window_label: Optional[str]
    ) -> go.Figure:
        from plotly.subplots import make_subplots

        col_labels = [c[:15] for c in columns]
        fig = make_subplots(
            rows=1, cols=2,
            subplot_titles=(window_label or "Short/Medium", "Medium/Long"),
        )
        self._add_momentum_cohort_bars(
            fig, col_labels, columns, data,
            retained_key="retained_7_30", churned_key="churned_7_30",
            col=1, show_legend=True,
        )
        self._add_momentum_cohort_bars(
            fig, col_labels, columns, data,
            retained_key="retained_30_90", churned_key="churned_30_90",
            col=2, show_legend=False,
        )
        fig.update_layout(
            title=title or "Momentum by Retention Status",
            template=self.theme, height=450, barmode="group",
            legend={"orientation": "h", "y": -0.15, "x": 0.5, "xanchor": "center"},
            margin={"b": 100},
        )
        return fig

    def _add_momentum_cohort_bars(
        self, fig: go.Figure, col_labels: List[str], columns: List[str],
        data: Dict, retained_key: str, churned_key: str, col: int, show_legend: bool
    ) -> None:
        fig.add_trace(go.Bar(
            name="Retained", x=col_labels,
            y=[data[c].get(retained_key, data[c].get("retained", 1)) for c in columns],
            marker_color=self.colors["success"], showlegend=show_legend,
        ), row=1, col=col)
        fig.add_trace(go.Bar(
            name="Churned", x=col_labels,
            y=[data[c].get(churned_key, data[c].get("churned", 1)) for c in columns],
            marker_color=self.colors["danger"], showlegend=show_legend,
        ), row=1, col=col)
        fig.add_hline(y=1.0, line_dash="dash", line_color="gray", row=1, col=col)

    def cohort_sparklines(
        self,
        data: Dict[str, Dict[str, List[float]]],
        feature_name: str,
        period_effects: Optional[Dict[str, float]] = None,
    ) -> go.Figure:
        """Create 3x3 sparkline grid: cohorts (rows) × time periods (cols) for one feature."""
        from plotly.subplots import make_subplots

        cohorts = ["retained", "churned", "overall"]
        periods = ["weekly", "monthly", "yearly"]
        row_titles = ["Retained", "Churned", "Overall"]
        col_titles = self._build_period_titles(periods, period_effects)

        fig = make_subplots(
            rows=3, cols=3,
            row_titles=row_titles,
            column_titles=col_titles,
            vertical_spacing=0.08,
            horizontal_spacing=0.06,
        )

        styles = {
            "retained": (self.colors["success"], "rgba(44, 160, 44, 0.2)"),
            "churned": (self.colors["danger"], "rgba(214, 39, 40, 0.2)"),
            "overall": (self.colors["info"], "rgba(23, 190, 207, 0.2)"),
        }

        for row_idx, cohort in enumerate(cohorts):
            if cohort not in data:
                continue
            color, fill = styles[cohort]
            for col_idx, period in enumerate(periods):
                if period in data[cohort]:
                    fig.add_trace(go.Scatter(
                        y=data[cohort][period], mode="lines",
                        line={"color": color, "width": 1.5},
                        fill="tozeroy", fillcolor=fill, showlegend=False,
                    ), row=row_idx + 1, col=col_idx + 1)

        fig.update_xaxes(showticklabels=False, showgrid=False)
        fig.update_yaxes(showticklabels=False, showgrid=False)
        fig.update_layout(
            title=f"<b>{feature_name}</b>",
            height=280,
            template=self.theme,
            margin={"t": 50, "b": 20, "l": 70, "r": 20},
        )
        return fig

    def _build_period_titles(self, periods: List[str], effects: Optional[Dict[str, float]]) -> List[str]:
        labels = {"weekly": "Weekly", "monthly": "Monthly", "yearly": "Yearly"}
        if not effects:
            return [labels[p] for p in periods]
        return [f"{labels[p]} (d={effects.get(p, 0):.2f})" for p in periods]

    def analyze_cohort_trends(
        self,
        data: Dict[str, Dict[str, List[float]]],
        feature_name: str,
    ) -> Dict[str, Any]:
        """Analyze separation between retained and churned trends across time periods."""
        periods_analysis = {}
        for period in ["weekly", "monthly", "yearly"]:
            if self._has_cohort_period_data(data, period):
                periods_analysis[period] = self._analyze_period(data, period)

        best_period = self._find_best_period(periods_analysis)
        recommendation = self._generate_trend_recommendation(feature_name, periods_analysis, best_period)
        actions = self._generate_actions(feature_name, periods_analysis, best_period)
        overall_d = self._compute_overall_effect_size(data)

        return {
            "feature": feature_name,
            "periods": periods_analysis,
            "best_period": best_period,
            "overall_effect_size": overall_d,
            "recommendation": recommendation,
            "actions": actions,
        }

    def _compute_overall_effect_size(self, data: Dict[str, Dict[str, List[float]]]) -> float:
        if "retained" not in data or "churned" not in data:
            return 0.0
        all_retained = [v for period_data in data["retained"].values() for v in period_data]
        all_churned = [v for period_data in data["churned"].values() for v in period_data]
        if len(all_retained) < 2 or len(all_churned) < 2:
            return 0.0
        return self._compute_cohens_d(np.array(all_retained), np.array(all_churned))

    def _has_cohort_period_data(self, data: Dict, period: str) -> bool:
        return ("retained" in data and period in data["retained"] and
                "churned" in data and period in data["churned"])

    @staticmethod
    def _classify_slope(slope: float) -> str:
        if slope > 0.01:
            return "up"
        return "down" if slope < -0.01 else "flat"

    def _compute_period_trends(self, retained: np.ndarray, churned: np.ndarray) -> Dict[str, Any]:
        ret_trend = self._compute_trend_slope(retained)
        churn_trend = self._compute_trend_slope(churned)
        return {
            "retained_trend": self._classify_slope(ret_trend),
            "churned_trend": self._classify_slope(churn_trend),
            "opposite_trends": (ret_trend > 0 and churn_trend < 0) or (ret_trend < 0 and churn_trend > 0),
        }

    @staticmethod
    def _compute_period_variance(retained: np.ndarray, churned: np.ndarray) -> Dict[str, Any]:
        ret_var, churn_var = float(np.var(retained)), float(np.var(churned))
        variance_ratio = ret_var / churn_var if churn_var > 0.001 else (10.0 if ret_var > 0.001 else 1.0)
        return {
            "variance_ratio": float(variance_ratio),
            "high_variance": bool(ret_var > 1.0 or churn_var > 1.0),
        }

    def _analyze_period(self, data: Dict, period: str) -> Dict[str, Any]:
        retained = np.array(data["retained"][period])
        churned = np.array(data["churned"][period])
        result = {
            "divergence": self._compute_divergence(retained, churned),
            "effect_size": self._compute_cohens_d(retained, churned),
            "seasonality_detected": self._detect_seasonality(retained) or self._detect_seasonality(churned),
        }
        result.update(self._compute_period_trends(retained, churned))
        result.update(self._compute_period_variance(retained, churned))
        return result

    def _compute_trend_slope(self, values: np.ndarray) -> float:
        if len(values) < 2:
            return 0.0
        x = np.arange(len(values))
        return float(np.polyfit(x, values, 1)[0])

    def _compute_divergence(self, retained: np.ndarray, churned: np.ndarray) -> float:
        if len(retained) == 0 or len(churned) == 0:
            return 0.0
        combined_std = max(np.std(np.concatenate([retained, churned])), 0.001)
        return float(abs(np.mean(retained) - np.mean(churned)) / combined_std)

    def _compute_cohens_d(self, retained: np.ndarray, churned: np.ndarray) -> float:
        if len(retained) < 2 or len(churned) < 2:
            return 0.0
        pooled_std = np.sqrt((np.var(retained) + np.var(churned)) / 2)
        if pooled_std < 0.001:
            return 0.0
        return float((np.mean(retained) - np.mean(churned)) / pooled_std)

    def _find_best_period(self, periods: Dict[str, Dict]) -> Optional[str]:
        if not periods:
            return None
        return max(periods.keys(), key=lambda p: abs(periods[p].get("divergence", 0)))

    def _generate_trend_recommendation(self, feature: str, periods: Dict, best: Optional[str]) -> str:
        if not best or best not in periods:
            return f"Insufficient data for {feature} trend analysis"

        analysis = periods[best]
        div, eff = analysis["divergence"], abs(analysis["effect_size"])
        opposite = analysis["opposite_trends"]

        if div > 1.5 or eff > 0.8:
            strength = "Strong"
            action = "high-priority feature for churn prediction"
        elif div > 0.8 or eff > 0.5:
            strength = "Moderate"
            action = "useful discriminator between cohorts"
        elif div > 0.3 or eff > 0.2:
            strength = "Weak"
            action = "consider combining with other features"
        else:
            return f"{feature}: No significant separation between retained and churned"

        trend_note = " with opposite trend directions" if opposite else ""
        period_label = {"weekly": "Weekly", "monthly": "Monthly", "yearly": "Yearly"}[best]
        return f"{feature}: {strength} separation (d={eff:.2f}) at {period_label} scale{trend_note} - {action}"

    def _detect_seasonality(self, values: np.ndarray) -> bool:
        if len(values) < 6:
            return False
        detrended = values - np.linspace(values[0], values[-1], len(values))
        autocorr = np.correlate(detrended, detrended, mode='full')
        autocorr = autocorr[len(autocorr) // 2:]
        if len(autocorr) < 3 or autocorr[0] < 0.001:
            return False
        normalized = autocorr / autocorr[0]
        peaks = [i for i in range(2, len(normalized) - 1)
                 if normalized[i] > normalized[i-1] and normalized[i] > normalized[i+1]]
        return any(normalized[p] > 0.3 for p in peaks[:3]) if peaks else False

    def _generate_actions(self, feature: str, periods: Dict, best: Optional[str]) -> List[Dict[str, Any]]:
        actions = []
        if not periods:
            return actions

        any_seasonality = any(p.get("seasonality_detected") for p in periods.values())
        any_high_variance = any(p.get("high_variance") for p in periods.values())

        if best and periods.get(best, {}).get("opposite_trends"):
            actions.append({
                "action_type": "add_trend_feature",
                "feature": feature,
                "reason": f"Opposite trends detected at {best} scale",
                "params": {"period": best, "method": "slope"},
            })

        if any_seasonality:
            period_with_season = next((k for k, v in periods.items() if v.get("seasonality_detected")), None)
            actions.append({
                "action_type": "add_time_indicator",
                "feature": feature,
                "reason": f"Seasonality detected at {period_with_season} scale",
                "params": {"period": period_with_season, "indicators": ["cyclical_encoding"]},
            })

        if any_high_variance:
            max_var_period = max(periods.keys(), key=lambda k: periods[k].get("variance_ratio", 1.0))
            var_ratio = periods[max_var_period].get("variance_ratio", 1.0)
            if var_ratio > 2.0:
                actions.append({
                    "action_type": "robust_scale",
                    "feature": feature,
                    "reason": f"High variance ratio ({var_ratio:.1f}x) between cohorts",
                    "params": {"method": "robust_scaler"},
                })
            elif any_high_variance:
                actions.append({
                    "action_type": "normalize",
                    "feature": feature,
                    "reason": "High variance in temporal trends",
                    "params": {"method": "standard_scaler"},
                })

        return actions

    def descriptive_stats_tiles(
        self,
        df: DataFrame,
        findings: Any,
        max_columns: int = 12,
        columns_per_row: int = 4,
    ) -> go.Figure:
        """Create a grid of mini chart tiles showing descriptive statistics for each column.

        Each tile shows a type-appropriate visualization:
        - Numeric: histogram with mean/median markers and key stats
        - Categorical: top categories bar chart with cardinality
        - Binary: pie chart with class balance
        - Datetime: date range indicator
        - Identifier: uniqueness gauge

        Args:
            df: DataFrame to visualize
            findings: ExplorationFindings object with column metadata
            max_columns: Maximum number of columns to display
            columns_per_row: Number of tiles per row
        """
        from plotly.subplots import make_subplots

        formatter = NumberFormatter()

        # Exclude temporal metadata columns from visualization
        temporal_metadata_cols = {"feature_timestamp", "label_timestamp", "label_available_flag"}
        available_cols = {k: v for k, v in findings.columns.items() if k not in temporal_metadata_cols}

        # Select columns to display (prioritize by type)
        type_priority = ['target', 'binary', 'numeric_continuous', 'numeric_discrete',
                         'categorical_nominal', 'categorical_ordinal', 'datetime', 'identifier']
        sorted_cols = []
        for col_type in type_priority:
            for name, col in available_cols.items():
                if col.inferred_type.value == col_type and name not in sorted_cols:
                    sorted_cols.append(name)
        for name in available_cols.keys():
            if name not in sorted_cols:
                sorted_cols.append(name)
        display_cols = sorted_cols[:max_columns]

        n_cols = min(columns_per_row, len(display_cols))
        n_rows = (len(display_cols) + n_cols - 1) // n_cols

        fig = make_subplots(
            rows=n_rows, cols=n_cols,
            subplot_titles=[f"<b>{c[:20]}</b>" for c in display_cols],
            vertical_spacing=0.12,
            horizontal_spacing=0.08,
            specs=[[{"type": "xy"} for _ in range(n_cols)] for _ in range(n_rows)]
        )

        for i, col_name in enumerate(display_cols):
            row, col = (i // n_cols) + 1, (i % n_cols) + 1
            col_finding = findings.columns.get(col_name)
            col_type = col_finding.inferred_type.value if col_finding else "unknown"
            series = df[col_name] if col_name in df.columns else None

            if series is None:
                continue

            self._add_column_tile(fig, series, col_finding, col_type, row, col, formatter, n_cols)

        fig.update_layout(
            height=250 * n_rows,
            template=self.theme,
            showlegend=False,
            margin={"t": 40, "b": 20, "l": 40, "r": 20},
        )

        return fig

    def dataset_at_a_glance(
        self,
        df: DataFrame,
        findings: Any,
        source_path: str = "",
        granularity: str = "entity",
        max_columns: int = 12,
        columns_per_row: int = 4,
    ) -> go.Figure:
        """Create a unified dataset overview with key metrics and column distribution tiles.

        Combines dataset-level stats (rows, columns, format, granularity) with
        small multiples of column distributions for a complete first look.

        Args:
            df: DataFrame to visualize
            findings: ExplorationFindings object with column metadata
            source_path: Path to data source (for format detection)
            granularity: Dataset granularity ("entity" or "event")
            max_columns: Maximum number of column tiles to display
            columns_per_row: Number of tiles per row
        """
        from pathlib import Path

        from plotly.subplots import make_subplots

        formatter = NumberFormatter()

        memory_mb = safe_memory_usage_bytes(df) / 1024**2

        # Detect format from path
        path = Path(source_path) if source_path else Path("data.csv")
        fmt = path.suffix.lstrip('.').upper() or "CSV"
        if fmt == "":
            fmt = "CSV"

        # Exclude temporal metadata columns from visualization
        temporal_metadata_cols = {"feature_timestamp", "label_timestamp", "label_available_flag"}
        available_cols = {k: v for k, v in findings.columns.items() if k not in temporal_metadata_cols}

        # Select columns to display (prioritize by type)
        type_priority = ['target', 'binary', 'numeric_continuous', 'numeric_discrete',
                         'categorical_nominal', 'categorical_ordinal', 'datetime', 'identifier']
        sorted_cols = []
        for col_type in type_priority:
            for name, col in available_cols.items():
                if col.inferred_type.value == col_type and name not in sorted_cols:
                    sorted_cols.append(name)
        for name in available_cols.keys():
            if name not in sorted_cols:
                sorted_cols.append(name)
        display_cols = sorted_cols[:max_columns]

        n_cols = min(columns_per_row, len(display_cols))
        n_tile_rows = (len(display_cols) + n_cols - 1) // n_cols

        # Build specs: 1 header row + tile rows
        header_specs = [{"type": "indicator"} for _ in range(n_cols)]
        tile_specs = [[{"type": "xy"} for _ in range(n_cols)] for _ in range(n_tile_rows)]

        # Subplot titles: empty for header, column names for tiles
        titles = [""] * n_cols + [f"<b>{c[:18]}</b>" for c in display_cols]

        fig = make_subplots(
            rows=1 + n_tile_rows,
            cols=n_cols,
            row_heights=[0.15] + [0.85 / n_tile_rows] * n_tile_rows,
            specs=[header_specs] + tile_specs,
            subplot_titles=titles,
            vertical_spacing=0.08,
            horizontal_spacing=0.06,
        )

        # Header row: Order is Rows, Columns, Structure, Format, Memory
        # Use annotations for all to ensure consistent appearance
        structure_label = "Event" if granularity.lower() == "event" else "Entity"
        memory_str = f"{memory_mb:.1f} MB"

        # Calculate header column positions for paper coordinates
        h_spacing = 0.06
        col_width = (1.0 - h_spacing * (n_cols - 1)) / n_cols

        def get_header_x(col_idx: int) -> float:
            """Get x center position for header column (1-indexed)."""
            return (col_idx - 1) * (col_width + h_spacing) + col_width / 2

        # Header data: (label, value)
        header_items = [
            ("Rows", f"{findings.row_count:,}"),
            ("Columns", str(findings.column_count)),
            ("Structure", structure_label),
            ("Format", fmt),
            ("Memory", memory_str),
        ]

        # Add placeholder indicators (needed for subplot structure)
        for i in range(min(n_cols, len(header_items))):
            fig.add_trace(go.Indicator(
                mode="number", value=0,
                number={"font": {"size": 1, "color": "rgba(0,0,0,0)"}}
            ), row=1, col=i+1)

        # Add labels (small, gray, top) and values (large, blue, below) as annotations
        label_y = 0.96
        value_y = 0.92

        for i, (label, value) in enumerate(header_items[:n_cols]):
            x_pos = get_header_x(i + 1)

            # Label
            fig.add_annotation(
                x=x_pos, y=label_y,
                xref="paper", yref="paper",
                text=label, showarrow=False,
                font={"size": 12, "color": "#666"},
                xanchor="center", yanchor="middle"
            )

            # Value
            fig.add_annotation(
                x=x_pos, y=value_y,
                xref="paper", yref="paper",
                text=value, showarrow=False,
                font={"size": 28, "color": self.colors["primary"]},
                xanchor="center", yanchor="middle"
            )

        # Column tiles (starting from row 2)
        for i, col_name in enumerate(display_cols):
            tile_row = (i // n_cols) + 2  # +2 because row 1 is header
            tile_col = (i % n_cols) + 1
            col_finding = findings.columns.get(col_name)
            col_type = col_finding.inferred_type.value if col_finding else "unknown"
            series = df[col_name] if col_name in df.columns else None

            if series is None:
                continue

            self._add_column_tile(fig, series, col_finding, col_type, tile_row, tile_col, formatter, n_cols)

        fig.update_layout(
            height=120 + 220 * n_tile_rows,
            template=self.theme,
            showlegend=False,
            margin={"t": 30, "b": 20, "l": 40, "r": 20},
        )

        return fig

    def dataset_comparison_at_a_glance(
        self, df_historic: DataFrame, df_recent: Optional[DataFrame], findings: Any,
        source_path: str = "", granularity: str = "entity",
        max_columns: int = 15, columns_per_row: int = 5, recent_days: int = 90,
    ) -> go.Figure:
        """Dataset overview comparing historic (full) and recent views side by side.

        Creates paired tile rows: full-color historic on top, lighter recent below.
        Falls back to standard dataset_at_a_glance when no recent data available.
        """
        from dataclasses import replace
        from pathlib import Path

        from plotly.subplots import make_subplots

        if df_recent is None or len(df_recent) == 0:
            return self.dataset_at_a_glance(
                df_historic, findings, source_path=source_path,
                granularity=granularity, max_columns=max_columns, columns_per_row=columns_per_row,
            )

        formatter = NumberFormatter()
        memory_mb = safe_memory_usage_bytes(df_historic) / 1024**2
        path = Path(source_path) if source_path else Path("data.csv")
        fmt = path.suffix.lstrip('.').upper() or "CSV"

        temporal_metadata_cols = {"feature_timestamp", "label_timestamp", "label_available_flag"}
        available_cols = {k: v for k, v in findings.columns.items() if k not in temporal_metadata_cols}

        type_priority = ['target', 'binary', 'numeric_continuous', 'numeric_discrete',
                         'categorical_nominal', 'categorical_ordinal', 'datetime', 'identifier']
        sorted_cols = []
        for col_type in type_priority:
            for name, col in available_cols.items():
                if col.inferred_type.value == col_type and name not in sorted_cols:
                    sorted_cols.append(name)
        for name in available_cols:
            if name not in sorted_cols:
                sorted_cols.append(name)
        display_cols = sorted_cols[:max_columns]

        n_cols = min(columns_per_row, len(display_cols))
        n_batches = (len(display_cols) + n_cols - 1) // n_cols
        total_tile_rows = n_batches * 2

        header_specs = [{"type": "indicator"} for _ in range(n_cols)]
        tile_specs = [[{"type": "xy"} for _ in range(n_cols)] for _ in range(total_tile_rows)]

        titles = [""] * n_cols
        for batch in range(n_batches):
            start = batch * n_cols
            batch_cols = display_cols[start:start + n_cols]
            pad = n_cols - len(batch_cols)
            titles += [f"<b>{c[:18]}</b>" for c in batch_cols] + [""] * pad
            titles += [f"\u21b3 last {recent_days}d" for _ in batch_cols] + [""] * pad

        tile_h = 0.88 / total_tile_rows
        fig = make_subplots(
            rows=1 + total_tile_rows, cols=n_cols,
            row_heights=[0.12] + [tile_h] * total_tile_rows,
            specs=[header_specs] + tile_specs,
            subplot_titles=titles,
            vertical_spacing=0.04,
            horizontal_spacing=0.06,
        )

        structure_label = "Event" if granularity.lower() == "event" else "Entity"
        memory_str = f"{memory_mb:.1f} MB"
        h_spacing = 0.06
        col_width = (1.0 - h_spacing * (n_cols - 1)) / n_cols

        header_items = [
            ("Rows", f"{findings.row_count:,}"),
            ("Columns", str(findings.column_count)),
            ("Structure", structure_label),
            ("Format", fmt),
            ("Memory", memory_str),
        ]

        for i in range(min(n_cols, len(header_items))):
            fig.add_trace(go.Indicator(
                mode="number", value=0,
                number={"font": {"size": 1, "color": "rgba(0,0,0,0)"}}
            ), row=1, col=i + 1)

        for i, (label, value) in enumerate(header_items[:n_cols]):
            x_pos = (i) * (col_width + h_spacing) + col_width / 2
            fig.add_annotation(
                x=x_pos, y=0.96, xref="paper", yref="paper",
                text=label, showarrow=False,
                font={"size": 12, "color": "#666"}, xanchor="center", yanchor="middle",
            )
            fig.add_annotation(
                x=x_pos, y=0.92, xref="paper", yref="paper",
                text=value, showarrow=False,
                font={"size": 28, "color": self.colors["primary"]},
                xanchor="center", yanchor="middle",
            )

        for i, col_name in enumerate(display_cols):
            batch_idx = i // n_cols
            col_in_batch = (i % n_cols) + 1
            historic_row = 2 + batch_idx * 2
            recent_row = 3 + batch_idx * 2
            col_finding = findings.columns.get(col_name)
            if not col_finding:
                continue
            col_type = col_finding.inferred_type.value

            if col_name in df_historic.columns:
                self._add_column_tile(
                    fig, df_historic[col_name], col_finding, col_type,
                    historic_row, col_in_batch, formatter, n_cols,
                )

            if col_name in df_recent.columns:
                trace_before = len(fig.data)
                recent_finding = replace(col_finding, universal_metrics={}, type_metrics={})
                self._add_column_tile(
                    fig, df_recent[col_name], recent_finding, col_type,
                    recent_row, col_in_batch, formatter, n_cols,
                )
                for trace in fig.data[trace_before:]:
                    trace.opacity = 0.45

        for ann in fig.layout.annotations:
            if hasattr(ann, 'text') and ann.text and ann.text.startswith("\u21b3"):
                ann.font = {"size": 10, "color": "#999"}

        fig.update_layout(
            height=120 + 180 * total_tile_rows,
            template=self.theme, showlegend=False,
            margin={"t": 30, "b": 20, "l": 40, "r": 20},
        )
        return fig

    def _add_column_tile(
        self,
        fig: go.Figure,
        series: Series,
        col_finding: Any,
        col_type: str,
        row: int,
        col: int,
        formatter: "NumberFormatter",
        n_cols: int = 4,
    ) -> None:
        """Add a single column tile to the subplot grid."""

        metrics = col_finding.universal_metrics if col_finding else {}
        type_metrics = col_finding.type_metrics if col_finding else {}

        if col_type in ('numeric_continuous', 'numeric_discrete'):
            self._add_numeric_tile(fig, series, metrics, type_metrics, row, col, n_cols, formatter)
        elif col_type in ('categorical_nominal', 'categorical_ordinal', 'categorical_cyclical'):
            self._add_categorical_tile(fig, series, metrics, row, col, n_cols, formatter)
        elif col_type == 'binary':
            self._add_binary_tile(fig, series, metrics, row, col, n_cols, formatter)
        elif col_type in ('datetime', 'date'):
            self._add_datetime_tile(fig, series, metrics, row, col, n_cols)
        elif col_type == 'identifier':
            self._add_identifier_tile(fig, series, metrics, row, col, n_cols, formatter)
        elif col_type == 'target':
            self._add_target_tile(fig, series, metrics, row, col, n_cols, formatter)
        else:
            self._add_generic_tile(fig, series, metrics, row, col, n_cols, formatter)

    def _get_axis_ref(self, row: int, col: int, n_cols: int, axis: str = "x") -> str:
        """Get the correct axis reference for subplot annotations."""
        # Calculate linear index (0-based)
        idx = (row - 1) * n_cols + col
        # First subplot uses 'x'/'y', others use 'x2', 'x3', etc.
        if idx == 1:
            return axis
        return f"{axis}{idx}"

    def _add_numeric_tile(
        self, fig: go.Figure, series: Series, metrics: Dict, type_metrics: Dict,
        row: int, col: int, n_cols: int, formatter: "NumberFormatter"
    ) -> None:
        """Add numeric column tile with histogram and stats."""
        clean = series.dropna()
        if len(clean) == 0:
            return

        mean_val = type_metrics.get('mean', clean.mean())
        median_val = type_metrics.get('median', clean.median())
        std_val = type_metrics.get('std', clean.std())
        null_pct = metrics.get('null_percentage', 0)

        fig.add_trace(go.Histogram(
            x=clean, nbinsx=20,
            marker_color=self.colors["primary"],
            opacity=0.7,
            hovertemplate="Range: %{x}<br>Count: %{y}<extra></extra>"
        ), row=row, col=col)

        xaxis_ref = self._get_axis_ref(row, col, n_cols, 'x')
        yaxis_ref = self._get_axis_ref(row, col, n_cols, 'y')
        fig.add_shape(type="line", x0=mean_val, x1=mean_val, y0=0, y1=1,
                     xref=xaxis_ref, yref=f"{yaxis_ref} domain",
                     line={"color": self.colors["secondary"], "width": 2, "dash": "dash"})
        fig.add_shape(type="line", x0=median_val, x1=median_val, y0=0, y1=1,
                     xref=xaxis_ref, yref=f"{yaxis_ref} domain",
                     line={"color": self.colors["success"], "width": 2, "dash": "dot"})

        stats_text = (f"μ={formatter.compact(mean_val)} | "
                     f"σ={formatter.compact(std_val)}" +
                     (f"<br>null={null_pct:.0f}%" if null_pct > 0 else ""))
        fig.add_annotation(
            x=0.98, y=0.98, xref=f"{xaxis_ref} domain", yref=f"{yaxis_ref} domain",
            text=stats_text, showarrow=False,
            font={"size": 9, "color": "#666"},
            bgcolor="rgba(255,255,255,0.8)",
            xanchor="right", yanchor="top"
        )

    def _add_categorical_tile(
        self, fig: go.Figure, series: Series, metrics: Dict,
        row: int, col: int, n_cols: int, formatter: "NumberFormatter"
    ) -> None:
        """Add categorical column tile with top categories bar."""
        value_counts = series.value_counts().head(5)

        # Gradient colors to show rank
        colors = [self.colors["info"]] + [self.colors["primary"]] * (len(value_counts) - 1)

        fig.add_trace(go.Bar(
            x=value_counts.values,
            y=[str(v)[:10] for v in value_counts.index],
            orientation='h',
            marker_color=colors[:len(value_counts)],
            hovertemplate="%{y}: %{x:,}<extra></extra>"
        ), row=row, col=col)

    def _add_binary_tile(
        self, fig: go.Figure, series: Series, metrics: Dict,
        row: int, col: int, n_cols: int, formatter: "NumberFormatter"
    ) -> None:
        """Add binary column tile with horizontal bars showing labels clearly."""
        value_counts = series.value_counts()
        if len(value_counts) == 0:
            return

        labels = [str(v) for v in value_counts.index]
        values = value_counts.to_numpy().tolist()
        total = sum(values)
        percentages = [v/total*100 for v in values]

        balance_ratio = max(values) / min(values) if min(values) > 0 else float('inf')
        balance_color = (self.colors["success"] if balance_ratio < 3
                        else self.colors["warning"] if balance_ratio < 10
                        else self.colors["danger"])

        # Horizontal bars with labels on y-axis
        colors = [self.colors["primary"], self.colors["secondary"]]
        fig.add_trace(go.Bar(
            y=labels[:2],
            x=percentages[:2],
            orientation='h',
            marker_color=colors[:len(labels)],
            text=[f"{p:.0f}%" for p in percentages[:2]],
            textposition="inside",
            textfont={"size": 11, "color": "white"},
            hovertemplate="%{y}: %{x:.1f}%<extra></extra>",
            showlegend=False
        ), row=row, col=col)

        ratio_text = f"{balance_ratio:.1f}:1"
        xref = f"{self._get_axis_ref(row, col, n_cols, 'x')} domain"
        yref = f"{self._get_axis_ref(row, col, n_cols, 'y')} domain"
        fig.add_annotation(
            x=0.98, y=0.98, xref=xref, yref=yref,
            text=ratio_text, showarrow=False,
            font={"size": 10, "color": balance_color, "family": "Arial Black"},
            xanchor="right", yanchor="top"
        )

    def _add_datetime_tile(
        self, fig: go.Figure, series: Series, metrics: Dict,
        row: int, col: int, n_cols: int
    ) -> None:
        """Add datetime column tile with date range visualization."""
        import warnings

        from customer_retention.core.compat import native_pd
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            dates = safe_to_datetime(native_pd.Series(series), errors='coerce').dropna()
        if len(dates) == 0:
            return

        # Monthly distribution as area chart for cleaner look
        tz_free = dates.dt.tz_localize(None) if dates.dt.tz is not None else dates
        counts = tz_free.dt.strftime('%Y-%m').value_counts().sort_index()
        x_labels = [str(p) for p in counts.index]
        fig.add_trace(go.Scatter(
            x=x_labels,
            y=counts.to_numpy(),
            mode='lines',
            fill='tozeroy',
            line={"color": self.colors["info"]},
            fillcolor="rgba(23, 190, 207, 0.3)",
            hovertemplate="%{x}: %{y:,}<extra></extra>"
        ), row=row, col=col)

        # Force categorical x-axis to prevent Plotly from interpreting as dates
        xaxis_name = f"xaxis{(row - 1) * n_cols + col}" if (row - 1) * n_cols + col > 1 else "xaxis"
        fig.update_layout(**{xaxis_name: {"type": "category", "tickangle": -45}})

    def _add_identifier_tile(
        self, fig: go.Figure, series: Series, metrics: Dict,
        row: int, col: int, n_cols: int, formatter: "NumberFormatter"
    ) -> None:
        """Add identifier column tile with uniqueness gauge."""
        total = len(series)
        unique = metrics.get('distinct_count', series.nunique())
        unique_pct = (unique / total * 100) if total > 0 else 0

        gauge_color = (self.colors["success"] if unique_pct >= 99
                      else self.colors["warning"] if unique_pct >= 95
                      else self.colors["danger"])

        # Progress bar style for uniqueness
        fig.add_trace(go.Bar(
            x=[unique_pct], y=[""],
            orientation='h',
            marker_color=gauge_color,
            text=f"{unique_pct:.1f}% unique",
            textposition="inside",
            textfont={"color": "white", "size": 11},
            hovertemplate=f"Unique: {unique:,} / {total:,}<extra></extra>",
            showlegend=False
        ), row=row, col=col)

        fig.add_trace(go.Bar(
            x=[100 - unique_pct], y=[""],
            orientation='h',
            marker_color="#ecf0f1",
            hoverinfo="skip",
            showlegend=False
        ), row=row, col=col)

    def _add_target_tile(
        self, fig: go.Figure, series: Series, metrics: Dict,
        row: int, col: int, n_cols: int, formatter: "NumberFormatter"
    ) -> None:
        """Add target column tile with horizontal bars showing class distribution."""
        value_counts = series.value_counts()
        total = len(series)

        colors_list = [self.colors["success"], self.colors["danger"]] + \
                     [self.colors["warning"], self.colors["info"]]

        labels = [str(v) for v in value_counts.head(4).index]
        percentages = [(c / total * 100) for c in value_counts.head(4).values]

        # Horizontal bars with labels on y-axis
        fig.add_trace(go.Bar(
            y=labels,
            x=percentages,
            orientation='h',
            marker_color=colors_list[:len(labels)],
            text=[f"{p:.0f}%" for p in percentages],
            textposition="inside",
            textfont={"size": 11, "color": "white"},
            hovertemplate="%{y}: %{x:.1f}%<extra></extra>",
            showlegend=False
        ), row=row, col=col)

        xref = f"{self._get_axis_ref(row, col, n_cols, 'x')} domain"
        yref = f"{self._get_axis_ref(row, col, n_cols, 'y')} domain"
        if len(value_counts) == 2:
            ratio = value_counts.max() / value_counts.min() if value_counts.min() > 0 else float('inf')
            ratio_color = (self.colors["success"] if ratio < 3
                          else self.colors["warning"] if ratio < 10
                          else self.colors["danger"])
            fig.add_annotation(
                x=0.98, y=0.98, xref=xref, yref=yref,
                text=f"{ratio:.1f}:1",
                showarrow=False, font={"size": 10, "color": ratio_color, "family": "Arial Black"},
                xanchor="right", yanchor="top"
            )

    def _add_generic_tile(
        self, fig: go.Figure, series: Series, metrics: Dict,
        row: int, col: int, n_cols: int, formatter: "NumberFormatter"
    ) -> None:
        """Add generic tile for unknown column types."""
        value_counts = series.value_counts().head(5)

        fig.add_trace(go.Bar(
            x=value_counts.values,
            y=[str(v)[:10] for v in value_counts.index],
            orientation='h',
            marker_color=self.colors["primary"],
            hovertemplate="%{y}: %{x:,}<extra></extra>"
        ), row=row, col=col)

    def recency_analysis_panel(
        self, retained_recency: np.ndarray, churned_recency: np.ndarray,
        bucket_stats: list, retained_median: float, churned_median: float,
        cap_value: Optional[float] = None
    ) -> go.Figure:
        from plotly.subplots import make_subplots
        from scipy.stats import gaussian_kde
        fig = make_subplots(
            rows=2, cols=2,
            subplot_titles=["Retained Distribution", "Target Rate by Recency",
                            "Churned Distribution", "Density Comparison"],
            row_heights=[0.5, 0.5], column_widths=[0.5, 0.5],
            horizontal_spacing=0.08, vertical_spacing=0.15,
            specs=[[{}, {"secondary_y": True}], [{}, {}]]
        )
        color_retained, color_churned = "rgba(46,204,113,0.7)", "rgba(231,76,60,0.7)"
        cap = cap_value or max(np.max(retained_recency), np.max(churned_recency))
        x_range = [0, cap * 1.05]
        fig.add_trace(go.Histogram(
            x=retained_recency, nbinsx=30, marker_color=color_retained, showlegend=False,
            hovertemplate="Days: %{x}<br>Count: %{y}<extra></extra>"
        ), row=1, col=1)
        fig.add_vline(x=retained_median, line_dash="solid", line_color="green",
                      annotation_text=f"Med: {retained_median:.0f}d", row=1, col=1)
        fig.add_trace(go.Histogram(
            x=churned_recency, nbinsx=30, marker_color=color_churned, showlegend=False,
            hovertemplate="Days: %{x}<br>Count: %{y}<extra></extra>"
        ), row=2, col=1)
        fig.add_vline(x=churned_median, line_dash="solid", line_color="red",
                      annotation_text=f"Med: {churned_median:.0f}d", row=2, col=1)
        if bucket_stats:
            labels = [b.bucket_label for b in bucket_stats]
            counts = [b.entity_count for b in bucket_stats]
            rates = [b.target_rate * 100 for b in bucket_stats]
            fig.add_trace(go.Bar(
                x=labels, y=counts, name="Entity Count", marker_color="lightsteelblue", opacity=0.7,
                hovertemplate="Bucket: %{x}<br>Count: %{y}<extra></extra>"
            ), row=1, col=2)
            fig.add_trace(go.Scatter(
                x=labels, y=rates, mode="lines+markers", name="Target Rate %",
                line={"color": "red", "width": 3}, marker={"size": 8},
                hovertemplate="Bucket: %{x}<br>Rate: %{y:.1f}%<extra></extra>"
            ), row=1, col=2, secondary_y=True)
        x_density = np.linspace(0, cap, 200)
        if len(retained_recency) > 5 and len(churned_recency) > 5:
            kde_retained = gaussian_kde(retained_recency, bw_method=0.3)
            kde_churned = gaussian_kde(churned_recency, bw_method=0.3)
            fig.add_trace(go.Scatter(
                x=x_density, y=kde_retained(x_density), mode="lines", name="Retained",
                line={"color": "green", "width": 2}, fill="tozeroy", fillcolor="rgba(46,204,113,0.3)",
                hovertemplate="Days: %{x:.0f}<br>Density: %{y:.4f}<extra></extra>"
            ), row=2, col=2)
            fig.add_trace(go.Scatter(
                x=x_density, y=kde_churned(x_density), mode="lines", name="Churned",
                line={"color": "red", "width": 2}, fill="tozeroy", fillcolor="rgba(231,76,60,0.3)",
                hovertemplate="Days: %{x:.0f}<br>Density: %{y:.4f}<extra></extra>"
            ), row=2, col=2)
            fig.add_vline(x=retained_median, line_dash="dash", line_color="green", line_width=1, row=2, col=2)
            fig.add_vline(x=churned_median, line_dash="dash", line_color="red", line_width=1, row=2, col=2)
            separation = self._compute_distribution_separation(kde_retained, kde_churned, x_density)
            fig.add_annotation(x=0.95, y=0.95, xref="x4 domain", yref="y4 domain",
                               text=f"Separation: {separation:.0%}", showarrow=False,
                               font={"size": 11}, bgcolor="rgba(255,255,255,0.8)", xanchor="right")
        fig.update_xaxes(range=x_range, row=1, col=1)
        fig.update_xaxes(range=x_range, row=2, col=1)
        fig.update_xaxes(range=x_range, row=2, col=2)
        fig.update_xaxes(title_text="Days Since Last Event", row=2, col=1)
        fig.update_xaxes(title_text="Recency Bucket", row=1, col=2)
        fig.update_xaxes(title_text="Days Since Last Event", row=2, col=2)
        fig.update_yaxes(title_text="Count", row=1, col=1)
        fig.update_yaxes(title_text="Count", row=2, col=1)
        fig.update_yaxes(title_text="Entity Count", row=1, col=2)
        fig.update_yaxes(title_text="Target Rate %", row=1, col=2, secondary_y=True)
        fig.update_yaxes(title_text="Density", row=2, col=2)
        fig.update_layout(
            title={"text": "Recency Analysis: Distribution Comparison & Target Rate", "x": 0.5},
            template=self.theme, height=550, showlegend=True, autosize=True,
            legend={"orientation": "h", "yanchor": "top", "y": -0.08, "xanchor": "center", "x": 0.5},
            margin={"l": 60, "r": 60, "t": 50, "b": 80}
        )
        return fig

    def _compute_distribution_separation(self, kde1, kde2, x_values: np.ndarray) -> float:
        y1, y2 = kde1(x_values), kde2(x_values)
        overlap = np.trapezoid(np.minimum(y1, y2), x_values)
        return 1.0 - overlap

    def categorical_analysis_panel(
        self, insights: list, overall_rate: float, max_features: int = 6
    ) -> go.Figure:
        from plotly.subplots import make_subplots
        if not insights:
            fig = go.Figure()
            fig.add_annotation(text="No categorical features to analyze", showarrow=False,
                               xref="paper", yref="paper", x=0.5, y=0.5, font={"size": 16})
            return fig
        insights = sorted(insights, key=lambda x: x.cramers_v, reverse=True)[:max_features]
        fig = make_subplots(
            rows=2, cols=2,
            subplot_titles=["Feature Association Strength (Cramér's V)", "Effect Strength Distribution",
                            "High/Low Risk Category Counts", "Top Feature: Category Target Rates"],
            row_heights=[0.5, 0.5], column_widths=[0.5, 0.5],
            horizontal_spacing=0.12, vertical_spacing=0.18
        )
        features = [i.feature_name for i in insights]
        cramers_values = [i.cramers_v for i in insights]
        # Top-Left: Strength gradient (red=strong, orange=moderate, light blue=weak)
        strength_colors = ["#c0392b" if v >= 0.3 else "#e67e22" if v >= 0.1 else "#85c1e9" for v in cramers_values]
        fig.add_trace(go.Bar(
            y=features, x=cramers_values, orientation="h", marker_color=strength_colors,
            hovertemplate="Feature: %{y}<br>Cramér's V: %{x:.3f}<extra></extra>", showlegend=False
        ), row=1, col=1)
        fig.add_vline(x=0.3, line_dash="dash", line_color="#c0392b", annotation_text="Strong",
                      annotation_position="top right", row=1, col=1)
        fig.add_vline(x=0.1, line_dash="dash", line_color="#e67e22", annotation_text="Moderate",
                      annotation_position="top left", row=1, col=1)
        # Top-Right: Count distribution (purple palette - distinct from strength colors)
        effect_counts = {"strong": 0, "moderate": 0, "weak": 0, "negligible": 0}
        for i in insights:
            effect_counts[i.effect_strength] = effect_counts.get(i.effect_strength, 0) + 1
        effect_labels = list(effect_counts.keys())
        effect_values = list(effect_counts.values())
        # Purple gradient for counts (darker = more significant category)
        count_colors = ["#6c3483", "#8e44ad", "#a569bd", "#d2b4de"]
        fig.add_trace(go.Bar(
            x=effect_labels, y=effect_values, marker_color=count_colors, showlegend=False,
            hovertemplate="Effect: %{x}<br>Count: %{y}<extra></extra>"
        ), row=1, col=2)
        high_risk = [len(i.high_risk_categories) for i in insights]
        low_risk = [len(i.low_risk_categories) for i in insights]
        fig.add_trace(go.Bar(
            y=features, x=high_risk, orientation="h", name="High Risk Categories",
            marker_color="rgba(231,76,60,0.7)", hovertemplate="%{y}: %{x} high-risk<extra></extra>"
        ), row=2, col=1)
        fig.add_trace(go.Bar(
            y=features, x=low_risk, orientation="h", name="Low Risk Categories",
            marker_color="rgba(46,204,113,0.7)", hovertemplate="%{y}: %{x} low-risk<extra></extra>"
        ), row=2, col=1)
        top_insight = insights[0]
        if not top_insight.category_stats.empty:
            stats = top_insight.category_stats.head(10)
            categories = stats["category"].astype(str).tolist()
            rates = (stats["retention_rate"] * 100).tolist()
            bar_colors = ["#e74c3c" if r < overall_rate * 100 * 0.9 else
                          "#2ecc71" if r > overall_rate * 100 * 1.1 else "#3498db" for r in rates]
            fig.add_trace(go.Bar(
                x=categories, y=rates, marker_color=bar_colors, showlegend=False,
                hovertemplate="Category: %{x}<br>Target Rate: %{y:.1f}%<extra></extra>"
            ), row=2, col=2)
            fig.add_hline(y=overall_rate * 100, line_dash="dash", line_color="gray",
                          annotation_text=f"Overall: {overall_rate*100:.1f}%", row=2, col=2)
        fig.update_xaxes(title_text="Cramér's V", row=1, col=1)
        fig.update_xaxes(title_text="Category Count", row=2, col=1)
        fig.update_xaxes(title_text="Category", row=2, col=2, tickangle=45)
        fig.update_yaxes(title_text="Feature", row=1, col=1)
        fig.update_yaxes(title_text="Feature", row=2, col=1)
        fig.update_yaxes(title_text="Target Rate %", row=2, col=2)
        fig.update_layout(
            title={"text": "Categorical Feature Analysis", "x": 0.5},
            template=self.theme, height=600, showlegend=True, autosize=True, barmode="group",
            legend={"orientation": "h", "yanchor": "top", "y": -0.1, "xanchor": "center", "x": 0.5},
            margin={"l": 120, "r": 60, "t": 60, "b": 100}
        )
        return fig
