"""
Stats — Descriptive statistics, correlation analysis, and distribution fitting.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Literal

import numpy as np
import pandas as pd
from scipy import stats as scipy_stats

from bizanalyticsai.core.dataframe import BizFrame


@dataclass
class DescribeResult:
    """Rich descriptive statistics result."""

    summary:      pd.DataFrame
    dtypes:       pd.Series
    missing:      pd.Series
    numeric_cols: list[str]
    cat_cols:     list[str]
    skewed_cols:  list[str] = field(default_factory=list)
    insights:     list[str] = field(default_factory=list)

    def __repr__(self) -> str:
        lines = [
            f"Shape: {self.summary.shape[1]} cols",
            f"Numeric: {len(self.numeric_cols)}  |  Categorical: {len(self.cat_cols)}",
            f"Skewed columns (|skew| > 1): {self.skewed_cols}",
            "",
        ]
        lines.append(str(self.summary))
        if self.insights:
            lines += ["", "── Insights ──────────────────────────"] + self.insights
        return "\n".join(lines)


def describe(
    df: pd.DataFrame,
    percentiles: list[float] | None = None,
    include_insights: bool = True,
) -> DescribeResult:
    """
    Comprehensive descriptive statistics.

    Returns a DescribeResult with:
    - Per-column summary (count, mean, std, min, p25, median, p75, max, skew, kurt, nulls)
    - Automatic insights (high null %, constant columns, high skew, etc.)

    Args:
        df:               Input DataFrame.
        percentiles:      Custom percentile list (default: [.10, .25, .50, .75, .90]).
        include_insights: Generate text insights automatically.
    """
    pcts = percentiles or [0.10, 0.25, 0.50, 0.75, 0.90]
    num  = df.select_dtypes(include="number")
    cat  = df.select_dtypes(exclude="number")

    rows: dict[str, dict] = {}
    for col in num.columns:
        s = num[col].dropna()
        q = s.quantile(pcts) if len(s) else pd.Series([np.nan] * len(pcts))
        rows[col] = {
            "count":   int(len(s)),
            "nulls":   int(df[col].isna().sum()),
            "null%":   round(df[col].isna().mean() * 100, 1),
            "mean":    round(float(s.mean()), 4) if len(s) else np.nan,
            "std":     round(float(s.std()), 4) if len(s) > 1 else np.nan,
            "min":     float(s.min()) if len(s) else np.nan,
            **{f"p{int(p*100)}": float(s.quantile(p)) if len(s) else np.nan for p in pcts},
            "max":     float(s.max()) if len(s) else np.nan,
            "skew":    round(float(s.skew()), 4) if len(s) > 2 else np.nan,
            "kurt":    round(float(s.kurtosis()), 4) if len(s) > 3 else np.nan,
        }

    summary = pd.DataFrame(rows).T
    skewed  = [c for c in num.columns if abs(df[c].dropna().skew()) > 1] if len(num) else []

    insights: list[str] = []
    if include_insights:
        for col in df.columns:
            null_pct = df[col].isna().mean() * 100
            if null_pct > 50:
                insights.append(f"⚠ '{col}' has {null_pct:.0f}% missing values — consider dropping.")
            if df[col].nunique() == 1:
                insights.append(f"⚠ '{col}' is constant — no analytical value.")
        for col in skewed:
            sk = df[col].dropna().skew()
            insights.append(f"📊 '{col}' is {'right' if sk > 0 else 'left'}-skewed (skew={sk:.2f}) — consider log transform.")

    return DescribeResult(
        summary=BizFrame(summary),
        dtypes=df.dtypes,
        missing=df.isna().sum(),
        numeric_cols=list(num.columns),
        cat_cols=list(cat.columns),
        skewed_cols=skewed,
        insights=insights,
    )


def correlation_matrix(
    df: pd.DataFrame,
    method: Literal["pearson", "spearman", "kendall"] = "pearson",
    threshold: float | None = None,
    cols: list[str] | None = None,
) -> BizFrame:
    """
    Compute a correlation matrix with optional filtering.

    Args:
        df:        Input DataFrame.
        method:    Correlation method (pearson, spearman, kendall).
        threshold: If set, only return pairs with |corr| >= threshold.
        cols:      Subset of columns.

    Returns:
        Correlation matrix as BizFrame, or filtered pairs DataFrame.
    """
    num = df[cols] if cols else df.select_dtypes(include="number")
    corr = num.corr(method=method)

    if threshold is not None:
        pairs = (
            corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool))
            .stack()
            .reset_index()
            .rename(columns={"level_0": "col_a", "level_1": "col_b", 0: "correlation"})
        )
        pairs = pairs[pairs["correlation"].abs() >= threshold].sort_values("correlation", key=abs, ascending=False)
        return BizFrame(pairs)

    return BizFrame(corr)


def distribution(
    series: pd.Series,
    bins: int = 30,
    fit: bool = True,
) -> dict:
    """
    Analyze the distribution of a numeric series.

    Returns:
        Dict with histogram bins, descriptive stats, normality test,
        and best-fitting distribution name + params when fit=True.
    """
    s = series.dropna()
    hist, edges = np.histogram(s, bins=bins)
    centers = (edges[:-1] + edges[1:]) / 2

    result: dict = {
        "bins":    [{"center": float(c), "count": int(n), "pct": round(n / len(s) * 100, 2)}
                    for c, n in zip(centers, hist)],
        "n":       len(s),
        "mean":    float(s.mean()),
        "median":  float(s.median()),
        "std":     float(s.std()),
        "skew":    float(s.skew()),
        "kurtosis": float(s.kurtosis()),
    }

    # Shapiro-Wilk normality test (n ≤ 5000)
    if len(s) >= 3:
        sample = s.sample(min(5000, len(s)), random_state=42)
        stat, p = scipy_stats.shapiro(sample)
        result["normality_test"] = {
            "test":   "shapiro-wilk",
            "stat":   round(float(stat), 4),
            "p_value": round(float(p), 6),
            "is_normal": bool(p > 0.05),
        }

    # Distribution fitting
    if fit:
        candidates = ["norm", "lognorm", "expon", "gamma", "beta", "uniform"]
        best_dist, best_params, best_sse = None, None, np.inf
        for name in candidates:
            try:
                dist_obj = getattr(scipy_stats, name)
                params   = dist_obj.fit(s)
                pdf      = dist_obj.pdf(centers, *params)
                sse      = float(np.sum((hist / len(s) - pdf) ** 2))
                if sse < best_sse:
                    best_sse   = sse
                    best_dist  = name
                    best_params = params
            except Exception:
                pass
        if best_dist:
            result["best_fit"] = {"distribution": best_dist, "params": best_params, "sse": best_sse}

    return result


def percentiles(
    df: pd.DataFrame,
    cols: list[str] | None = None,
    q: list[float] | None = None,
) -> BizFrame:
    """Return a percentile table for numeric columns."""
    q    = q or [0.01, 0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95, 0.99]
    num  = df[cols] if cols else df.select_dtypes(include="number")
    pct  = num.quantile(q)
    pct.index = [f"p{int(v*100)}" for v in pct.index]
    return BizFrame(pct)


def group_stats(
    df: pd.DataFrame,
    group_col: str,
    value_col: str,
    agg: list[str] | None = None,
) -> BizFrame:
    """
    Group by a column and compute aggregate statistics.

    Args:
        df:        Input DataFrame.
        group_col: Column to group by.
        value_col: Numeric column to aggregate.
        agg:       Aggregation functions (default: count, mean, median, std, sum, min, max).

    Returns:
        BizFrame with one row per group.
    """
    agg = agg or ["count", "mean", "median", "std", "sum", "min", "max"]
    result = df.groupby(group_col)[value_col].agg(agg).reset_index()
    return BizFrame(result)


def compare_groups(
    df: pd.DataFrame,
    group_col: str,
    value_col: str,
    test: Literal["ttest", "anova", "kruskal"] = "anova",
) -> dict:
    """
    Statistical comparison between groups.

    Performs t-test (2 groups), ANOVA, or Kruskal-Wallis test.
    Returns test statistic, p-value, and interpretation.
    """
    groups = [g[value_col].dropna().values for _, g in df.groupby(group_col)]

    if test == "ttest" and len(groups) == 2:
        stat, p = scipy_stats.ttest_ind(*groups)
    elif test == "anova":
        stat, p = scipy_stats.f_oneway(*groups)
    elif test == "kruskal":
        stat, p = scipy_stats.kruskal(*groups)
    else:
        raise ValueError(f"Unknown test '{test}'. Choose from ttest, anova, kruskal.")

    return {
        "test":         test,
        "statistic":    round(float(stat), 4),
        "p_value":      round(float(p), 6),
        "significant":  bool(p < 0.05),
        "interpretation": (
            f"Significant difference between groups (p={p:.4f})"
            if p < 0.05
            else f"No significant difference between groups (p={p:.4f})"
        ),
    }
