from __future__ import annotations

from typing import Any

import pandas as pd

from featureleak.report import LeakIssue
from featureleak.utils.hashing import duplicate_row_mask, stable_row_hashes


def _top_duplicate_columns(
    df: pd.DataFrame,
    duplicate_mask: pd.Series,
    top_n: int = 5,
) -> list[str]:
    """
    Heuristic: identify columns that are especially repetitive among duplicate rows.
    Lower uniqueness among duplicate rows suggests the column participates strongly
    in repeated records.
    """
    if duplicate_mask.sum() == 0:
        return []

    dup_df = df.loc[duplicate_mask]
    if dup_df.empty:
        return []

    scores: list[tuple[str, float]] = []
    for col in dup_df.columns:
        try:
            nunique = dup_df[col].nunique(dropna=False)
            ratio = nunique / max(len(dup_df), 1)
            scores.append((col, ratio))
        except Exception:
            continue

    scores.sort(key=lambda x: x[1])
    return [col for col, _ in scores[:top_n]]


def _safe_sample_df(
    df: pd.DataFrame,
    max_rows: int,
    random_state: int = 42,
) -> pd.DataFrame:
    if len(df) <= max_rows:
        return df
    return df.sample(n=max_rows, random_state=random_state)


def _severity_from_rate(
    n_rows_flagged: int,
    total_rows: int,
    *,
    low_cutoff: float = 0.01,
    high_cutoff: float = 0.05,
) -> str:
    rate = n_rows_flagged / max(total_rows, 1)
    if rate >= high_cutoff:
        return "high"
    if rate >= low_cutoff:
        return "medium"
    return "low"


def detect_duplicate_rows(
    df: pd.DataFrame,
    target: str | None = None,
    exclude_columns: list[str] | None = None,
    near_duplicate_numeric_round: int = 3,
    max_rows_for_near_duplicates: int = 50_000,
    random_state: int = 42,
) -> list[LeakIssue]:
    """
    Detect exact duplicates and coarse near-duplicates within a dataset.

    Performance-aware behavior:
    - exact duplicates run on the full dataset using stable row hashing
    - near-duplicate detection is capped to a sample for large datasets
    """
    issues: list[LeakIssue] = []

    if df.empty:
        return issues

    exclude_cols = list(exclude_columns or [])
    exclude_cols_with_target = list(exclude_cols)
    if target is not None and target in df.columns and target not in exclude_cols_with_target:
        exclude_cols_with_target.append(target)

    # ------------------------------------------------------------------
    # Exact duplicates including target
    # ------------------------------------------------------------------
    exact_dup_mask = duplicate_row_mask(df, exclude_cols=exclude_cols, keep=False)
    n_exact_dup_rows = int(exact_dup_mask.sum())

    if n_exact_dup_rows > 0:
        hashes = stable_row_hashes(df, exclude_cols=exclude_cols)
        counts = hashes.value_counts()
        n_exact_dup_groups = int((counts > 1).sum())

        issues.append(
            LeakIssue(
                severity=_severity_from_rate(n_exact_dup_rows, len(df), low_cutoff=0.01, high_cutoff=0.05),
                check="duplicate_leakage",
                title="Exact duplicate rows detected",
                summary=(
                    f"{n_exact_dup_rows} rows belong to {n_exact_dup_groups} duplicate groups "
                    f"within the dataset."
                ),
                affected_columns=_top_duplicate_columns(df, exact_dup_mask),
                details={
                    "n_duplicate_rows": n_exact_dup_rows,
                    "n_duplicate_groups": n_exact_dup_groups,
                    "duplicate_rate": round(n_exact_dup_rows / max(len(df), 1), 6),
                    "mode": "exact",
                    "excluded_columns": exclude_cols,
                },
                suggested_fix=(
                    "Inspect duplicated records and deduplicate before training or splitting "
                    "if they do not represent meaningful repeated events."
                ),
            )
        )

    # ------------------------------------------------------------------
    # Exact duplicates excluding target
    # Useful when features repeat but targets may differ.
    # ------------------------------------------------------------------
    n_exact_feature_dup_rows = 0

    if target is not None and target in df.columns:
        feature_dup_mask = duplicate_row_mask(
            df,
            exclude_cols=exclude_cols_with_target,
            keep=False,
        )
        n_exact_feature_dup_rows = int(feature_dup_mask.sum())

        if n_exact_feature_dup_rows > 0:
            feature_hashes = stable_row_hashes(df, exclude_cols=exclude_cols_with_target)
            feature_counts = feature_hashes.value_counts()
            n_exact_feature_dup_groups = int((feature_counts > 1).sum())

            conflicting_target_groups = 0
            try:
                tmp = pd.DataFrame(
                    {
                        "_feature_hash": feature_hashes,
                        "_target": df[target].values,
                    }
                )
                target_nunique = tmp.groupby("_feature_hash")["_target"].nunique(dropna=False)
                conflicting_target_groups = int((target_nunique > 1).sum())
            except Exception:
                conflicting_target_groups = 0

            severity = "high" if conflicting_target_groups > 0 else _severity_from_rate(
                n_exact_feature_dup_rows,
                len(df),
                low_cutoff=0.01,
                high_cutoff=0.05,
            )

            summary = (
                f"{n_exact_feature_dup_rows} rows share identical feature values "
                f"(excluding target), across {n_exact_feature_dup_groups} groups."
            )
            if conflicting_target_groups > 0:
                summary += f" {conflicting_target_groups} groups have conflicting target values."

            feature_only_df = df.drop(columns=[target], errors="ignore")

            issues.append(
                LeakIssue(
                    severity=severity,
                    check="duplicate_leakage",
                    title="Duplicate feature rows detected",
                    summary=summary,
                    affected_columns=_top_duplicate_columns(feature_only_df, feature_dup_mask),
                    details={
                        "n_duplicate_feature_rows": n_exact_feature_dup_rows,
                        "n_duplicate_feature_groups": n_exact_feature_dup_groups,
                        "duplicate_feature_rate": round(n_exact_feature_dup_rows / max(len(df), 1), 6),
                        "conflicting_target_groups": conflicting_target_groups,
                        "mode": "exact_excluding_target",
                        "excluded_columns": exclude_cols_with_target,
                    },
                    suggested_fix=(
                        "Check whether repeated feature rows are expected. If not, deduplicate or "
                        "revisit joins / aggregation logic that produced repeated examples."
                    ),
                )
            )

    # ------------------------------------------------------------------
    # Coarse near-duplicates
    # Performance-aware:
    # - sample only for large datasets
    # - round numeric columns
    # - stringify non-numeric columns
    # ------------------------------------------------------------------
    if len(df) > 1:
        feature_df = df.drop(columns=[target], errors="ignore").copy()

        if not feature_df.empty:
            sampled_feature_df = _safe_sample_df(
                feature_df,
                max_rows=max_rows_for_near_duplicates,
                random_state=random_state,
            )

            try:
                numeric_cols = sampled_feature_df.select_dtypes(include=["number"]).columns.tolist()
                non_numeric_cols = [c for c in sampled_feature_df.columns if c not in numeric_cols]

                rounded = sampled_feature_df.copy()

                for col in numeric_cols:
                    rounded[col] = pd.to_numeric(rounded[col], errors="coerce").round(
                        near_duplicate_numeric_round
                    )

                for col in non_numeric_cols:
                    rounded[col] = (
                        rounded[col]
                        .astype("object")
                        .where(rounded[col].notna(), "__NaN__")
                        .astype(str)
                    )

                near_dup_mask = duplicate_row_mask(rounded, exclude_cols=exclude_cols, keep=False)
                n_near_dup_rows = int(near_dup_mask.sum())

                # Avoid duplicate issue duplication when the near-duplicate count is just
                # reproducing the exact duplicate signal already found.
                if n_near_dup_rows > 0:
                    rounded_hashes = stable_row_hashes(rounded, exclude_cols=exclude_cols)
                    rounded_counts = rounded_hashes.value_counts()
                    n_near_dup_groups = int((rounded_counts > 1).sum())

                    details: dict[str, Any] = {
                        "n_near_duplicate_rows": n_near_dup_rows,
                        "n_near_duplicate_groups": n_near_dup_groups,
                        "near_duplicate_rate": round(n_near_dup_rows / max(len(sampled_feature_df), 1), 6),
                        "numeric_round_decimals": near_duplicate_numeric_round,
                        "mode": "coarse_near_duplicate",
                        "sampled_rows": len(sampled_feature_df),
                        "sampled_from_total_rows": len(df),
                    }

                    if len(sampled_feature_df) < len(df):
                        details["sampling_applied"] = True
                        summary = (
                            f"{n_near_dup_rows} sampled rows fall into {n_near_dup_groups} coarse "
                            f"near-duplicate groups after rounding numeric features "
                            f"(checked on {len(sampled_feature_df):,} of {len(df):,} rows)."
                        )
                    else:
                        details["sampling_applied"] = False
                        summary = (
                            f"{n_near_dup_rows} rows fall into {n_near_dup_groups} coarse "
                            f"near-duplicate groups after rounding numeric features."
                        )

                    issues.append(
                        LeakIssue(
                            severity=_severity_from_rate(
                                n_near_dup_rows,
                                len(sampled_feature_df),
                                low_cutoff=0.02,
                                high_cutoff=0.10,
                            ),
                            check="duplicate_leakage",
                            title="Potential near-duplicate rows detected",
                            summary=summary,
                            affected_columns=_top_duplicate_columns(sampled_feature_df, near_dup_mask),
                            details=details,
                            suggested_fix=(
                                "Inspect repeated records, joins, or snapshot logic. Near-duplicate "
                                "rows can inflate validation performance if they appear across splits."
                            ),
                        )
                    )
            except Exception:
                # Keep rule robust; exact duplicate checks are still valuable.
                pass

    return issues