"""Core analysis logic for dataset inspection."""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from typing import Any

import numpy as np
import pandas as pd

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Result containers
# ---------------------------------------------------------------------------

@dataclass
class ColumnStats:
    """Descriptive statistics for a single numeric column."""
    name: str
    mean: float
    std: float
    min: float
    max: float
    median: float
    q25: float
    q75: float


@dataclass
class MissingInfo:
    """Missing-value summary for a single column."""
    column: str
    count: int
    percentage: float


@dataclass
class AnomalyInfo:
    """Rows flagged as potential anomalies in a column."""
    column: str
    row_indices: list[int]
    method: str  # "iqr" or "zscore"


@dataclass
class AnalysisResult:
    """Aggregated result of a full dataset analysis."""
    shape: tuple[int, int]
    dtypes: dict[str, str]
    basic_stats: list[ColumnStats] = field(default_factory=list)
    missing: list[MissingInfo] = field(default_factory=list)
    duplicate_row_count: int = 0
    duplicate_pairs: list[tuple[int, int]] = field(default_factory=list)
    class_imbalance: dict[str, dict[str, Any]] | None = None
    anomalies: list[AnomalyInfo] = field(default_factory=list)

    # ----- serialisation helpers ----- #
    def to_dict(self) -> dict[str, Any]:
        """Return a plain-dict representation suitable for JSON export."""
        return {
            "shape": list(self.shape),
            "dtypes": self.dtypes,
            "basic_stats": [vars(s) for s in self.basic_stats],
            "missing": [vars(m) for m in self.missing],
            "duplicate_row_count": self.duplicate_row_count,
            "duplicate_pairs": self.duplicate_pairs,
            "class_imbalance": self.class_imbalance,
            "anomalies": [
                {**vars(a), "row_indices": a.row_indices[:20]}  # cap for readability
                for a in self.anomalies
            ],
        }


# ---------------------------------------------------------------------------
# Public analysis API
# ---------------------------------------------------------------------------

def load_csv(path: str) -> pd.DataFrame:
    """Read a CSV file into a DataFrame."""
    logger.info("Loading CSV from %s", path)
    df = pd.read_csv(path)
    logger.info("Loaded %d rows x %d columns", *df.shape)
    return df


def compute_basic_stats(df: pd.DataFrame) -> list[ColumnStats]:
    """Compute descriptive statistics for every numeric column."""
    stats: list[ColumnStats] = []
    numeric = df.select_dtypes(include="number")
    for col in numeric.columns:
        s = numeric[col]
        stats.append(ColumnStats(
            name=col,
            mean=float(s.mean()),
            std=float(s.std()),
            min=float(s.min()),
            max=float(s.max()),
            median=float(s.median()),
            q25=float(s.quantile(0.25)),
            q75=float(s.quantile(0.75)),
        ))
    return stats


def detect_missing(df: pd.DataFrame) -> list[MissingInfo]:
    """Return missing-value counts for columns that have any."""
    result: list[MissingInfo] = []
    for col in df.columns:
        n_missing = int(df[col].isna().sum())
        if n_missing > 0:
            result.append(MissingInfo(
                column=col,
                count=n_missing,
                percentage=round(n_missing / len(df) * 100, 2),
            ))
    return result


def detect_duplicates(df: pd.DataFrame) -> tuple[int, list[tuple[int, int]]]:
    """Return the count of duplicated rows and the (original, duplicate) index pairs."""
    pairs: list[tuple[int, int]] = []
    seen: dict[int, int] = {}  # hash -> first-seen positional index
    for pos, row_hash in enumerate(pd.util.hash_pandas_object(df, index=False)):
        h = int(row_hash)
        if h in seen:
            pairs.append((seen[h], pos))
        else:
            seen[h] = pos
    return len(pairs), pairs


def detect_class_imbalance(
    df: pd.DataFrame, target: str | None = None, threshold: float = 0.4
) -> dict[str, dict[str, Any]] | None:
    """Check for class imbalance on the *target* column.

    If *target* is ``None`` the function scans object / category columns with
    low cardinality (<= 20 unique values) and reports any that look imbalanced.

    *threshold* is the maximum acceptable ratio of the minority class count to
    the majority class count.  A ratio below this value is flagged.
    """
    candidates = [target] if target else [
        c for c in df.select_dtypes(include=["object", "category", "bool"]).columns
        if df[c].nunique() <= 20
    ]

    results: dict[str, dict[str, Any]] = {}
    for col in candidates:
        if col not in df.columns:
            logger.warning("Target column '%s' not found – skipping imbalance check.", col)
            continue
        counts = df[col].value_counts()
        if len(counts) < 2:
            continue
        ratio = float(counts.min() / counts.max())
        if ratio < threshold:
            results[col] = {
                "value_counts": counts.to_dict(),
                "minority_majority_ratio": round(ratio, 4),
                "imbalanced": True,
            }

    return results if results else None


def detect_anomalies(
    df: pd.DataFrame, method: str = "iqr", z_threshold: float = 3.0
) -> list[AnomalyInfo]:
    """Flag rows with potential anomalies in numeric columns.

    Supports two methods:
    - ``iqr``:  values outside [Q1 - 1.5*IQR, Q3 + 1.5*IQR]
    - ``zscore``: absolute z-score > *z_threshold*
    """
    anomalies: list[AnomalyInfo] = []
    numeric = df.select_dtypes(include="number")

    for col in numeric.columns:
        s = numeric[col].dropna()
        if s.empty:
            continue

        if method == "iqr":
            q1, q3 = float(s.quantile(0.25)), float(s.quantile(0.75))
            iqr = q3 - q1
            lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
            mask = (s < lower) | (s > upper)
        elif method == "zscore":
            z = np.abs((s - s.mean()) / s.std())
            mask = z > z_threshold
        else:
            raise ValueError(f"Unknown anomaly method: {method!r}")

        flagged = s[mask].index.tolist()
        if flagged:
            anomalies.append(AnomalyInfo(column=col, row_indices=flagged, method=method))

    return anomalies


def analyze(
    df: pd.DataFrame,
    target: str | None = None,
    anomaly_method: str = "iqr",
) -> AnalysisResult:
    """Run the full analysis pipeline on *df* and return an ``AnalysisResult``."""
    logger.info("Starting analysis …")
    dup_count, dup_pairs = detect_duplicates(df)
    return AnalysisResult(
        shape=df.shape,
        dtypes={c: str(d) for c, d in df.dtypes.items()},
        basic_stats=compute_basic_stats(df),
        missing=detect_missing(df),
        duplicate_row_count=dup_count,
        duplicate_pairs=dup_pairs,
        class_imbalance=detect_class_imbalance(df, target=target),
        anomalies=detect_anomalies(df, method=anomaly_method),
    )
