from __future__ import annotations

from typing import Any

import pandas as pd
from pandas.api.types import is_numeric_dtype

from featureleak.report import LeakIssue


SUSPICIOUS_AGG_TOKENS = (
    "target",
    "label",
    "mean",
    "avg",
    "rate",
    "ratio",
    "ctr",
    "conversion",
    "response",
    "likelihood",
    "prop",
    "prob",
    "expected",
    "rolling",
    "expanding",
    "future",
    "next",
    "lead",
    "sales",
    "revenue",
    "outcome",
)


def _column_name_score(column_name: str) -> dict[str, Any]:
    name = str(column_name).lower()
    matched = [token for token in SUSPICIOUS_AGG_TOKENS if token in name]
    return {
        "matched_tokens": matched,
        "name_score": len(matched),
    }


def _safe_numeric_corr(feature: pd.Series, target: pd.Series) -> float | None:
    try:
        x = pd.to_numeric(feature, errors="coerce")
        y = pd.to_numeric(target, errors="coerce")
        corr = x.corr(y)
        if pd.isna(corr):
            return None
        return float(abs(corr))
    except Exception:
        return None


def _group_target_spread(feature: pd.Series, target: pd.Series) -> dict[str, Any]:
    """
    For categorical-like features, estimate how strongly target varies by feature value.
    """
    out: dict[str, Any] = {
        "group_mean_spread": None,
        "n_groups": 0,
        "top_group_target_means": {},
    }

    try:
        safe_feature = feature.astype("object").where(feature.notna(), "__NaN__").astype(str)
        tmp = pd.DataFrame({"feature": safe_feature, "target": target}).dropna(subset=["target"])

        if tmp.empty:
            return out

        grouped = tmp.groupby("feature")["target"].mean().sort_values(ascending=False)
        if grouped.empty:
            return out

        spread = grouped.max() - grouped.min()
        out["group_mean_spread"] = None if pd.isna(spread) else float(spread)
        out["n_groups"] = int(len(grouped))
        out["top_group_target_means"] = {
            str(k): float(v) for k, v in grouped.head(5).to_dict().items()
        }
        return out
    except Exception:
        return out


def _low_within_group_variance(feature: pd.Series, round_decimals: int = 6) -> dict[str, Any]:
    """
    Heuristic for numeric columns that look like precomputed aggregates:
    many repeated values, relatively few unique values compared to rows.
    """
    out: dict[str, Any] = {
        "nunique": 0,
        "unique_ratio": 0.0,
        "most_common_fraction": 0.0,
    }

    try:
        numeric = pd.to_numeric(feature, errors="coerce")
        rounded = numeric.round(round_decimals)

        nunique = int(rounded.nunique(dropna=True))
        unique_ratio = nunique / max(len(feature), 1)

        vc = rounded.value_counts(dropna=True, normalize=True)
        most_common_fraction = float(vc.iloc[0]) if not vc.empty else 0.0

        out["nunique"] = nunique
        out["unique_ratio"] = float(unique_ratio)
        out["most_common_fraction"] = most_common_fraction
        return out
    except Exception:
        return out


def detect_aggregation_leakage(
    df: pd.DataFrame,
    target: str,
    corr_threshold: float = 0.90,
    group_spread_threshold: float = 0.30,
    repeated_value_threshold: float = 0.10,
) -> list[LeakIssue]:
    """
    Detect features that look like target-derived aggregates or target encodings.

    v1 philosophy:
    - heuristic and opinionated
    - useful warnings rather than perfect proof

    Signals:
    - suspicious feature names
    - unusually strong target association
    - repeated numeric values suggestive of group aggregates
    - large target mean spread across categorical feature groups
    """
    issues: list[LeakIssue] = []

    if df.empty or target not in df.columns:
        return issues
    
    df = df.sample(10000)

    target_series = df[target]
    target_is_numeric = is_numeric_dtype(target_series)

    for col in df.columns:
        if col == target:
            continue

        feature = df[col]
        details: dict[str, Any] = {}
        reasons: list[str] = []

        name_info = _column_name_score(col)
        matched_tokens = name_info["matched_tokens"]
        name_score = int(name_info["name_score"])
        details["matched_name_tokens"] = matched_tokens
        details["name_score"] = name_score

        severity: str | None = None

        if target_is_numeric and is_numeric_dtype(feature):
            corr = _safe_numeric_corr(feature, target_series)
            repeated_stats = _low_within_group_variance(feature)

            details["abs_target_corr"] = corr
            details.update(repeated_stats)

            if corr is not None and corr >= corr_threshold:
                reasons.append("strong_target_association")
                severity = "high" if name_score > 0 else "medium"

            if repeated_stats["most_common_fraction"] >= repeated_value_threshold and name_score > 0:
                reasons.append("repeated_aggregate_like_values")
                if severity is None:
                    severity = "medium"

            if name_score >= 2 and corr is not None and corr >= 0.75:
                reasons.append("suspicious_name_plus_target_signal")
                severity = "high"

        else:
            group_stats = _group_target_spread(feature, target_series)
            details.update(group_stats)

            spread = group_stats.get("group_mean_spread")
            if spread is not None and spread >= group_spread_threshold:
                reasons.append("large_group_target_spread")
                severity = "medium"

            if name_score >= 2 and spread is not None and spread > 0:
                reasons.append("suspicious_name_plus_group_signal")
                severity = "high"

        # Name-only warning for highly suspicious columns, even if metric is weak / unavailable.
        suspicious_name_only = (
            ("target" in matched_tokens or "label" in matched_tokens)
            and ("mean" in matched_tokens or "avg" in matched_tokens or "rate" in matched_tokens or "ctr" in matched_tokens)
        )
        if suspicious_name_only:
            reasons.append("target_aggregation_name_pattern")
            if severity is None:
                severity = "medium"

        if severity is None:
            continue

        details["reasons"] = sorted(set(reasons))

        issues.append(
            LeakIssue(
                severity=severity,
                check="aggregation_leakage",
                title="Potential target-derived aggregation feature detected",
                summary=(
                    f"Column `{col}` looks like it may encode target statistics or an aggregate "
                    f"computed with label access beyond the training fold."
                ),
                affected_columns=[col],
                details=details,
                suggested_fix=(
                    "Verify whether this feature was computed using the full dataset or future rows. "
                    "Recompute aggregation features using training folds only, or with strictly past-only data."
                ),
            )
        )

    return issues