"""
RPFNet Public API — PyPI Package Interface
===========================================

Usage:
    from rpfnet import api

    # ── Batch analysis ──────────────────────────────────────────
    report = api.analyze('csv', 'data.csv')
    report = api.analyze('uci', 73)
    report = api.analyze('url', 'https://example.com/data.csv')

    # ── Get cleaned DataFrame ───────────────────────────────────
    clean_df = api.clean('csv', 'data.csv')
    clean_df = api.clean('uci', 73)

    # ── Streaming — same interface ──────────────────────────────
    # Initialize with a baseline (optional but recommended)
    report = api.analyze('stream', training_df)

    # Then feed rows one at a time
    result = api.analyze('stream', new_row)
    result = api.analyze('stream', {'feat1': 3.2, 'feat2': 0.1})

    # Get clean rows from what's been streamed so far
    clean_df = api.clean('stream')

    # Check stream status without feeding data
    status = api.analyze('stream')

    # Utilities
    api.stream_reset()     # clear stream buffer
    api.stream_retrain()   # force retrain on current buffer
"""

import os
import warnings
from typing import Literal, Optional, Union

import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler

# ─────────────────────────────────────────────────────────────────────────────
# Lazy model loading
# ─────────────────────────────────────────────────────────────────────────────

_meta = None
_hybrid = None
_model_loaded = False
_model_init_attempted = False

# Backend module references (set by _try_import_backend)
_RPFExtractor = None
_MetaPoisonDetector = None
_HybridEnsembleDetector = None
_RateEstimatorHead = None
_MetaPoisonNet = None
_META_MODEL_PATH = "./metapoison_v4_universal.pt"
_META_EPOCHS = 30
_META_BATCH_SIZE = 512
_META_LR = 1e-3
_RPF_K_SMALL = 5
_RPF_K_LARGE = 15
_RPF_CV_FOLDS = 3
_BACKEND_AVAILABLE = False


def _try_import_backend():
    global _RPFExtractor, _MetaPoisonDetector, _HybridEnsembleDetector
    global _RateEstimatorHead, _META_MODEL_PATH
    global _META_EPOCHS, _META_BATCH_SIZE, _META_LR
    global _MetaPoisonNet, _RPF_K_SMALL, _RPF_K_LARGE, _RPF_CV_FOLDS
    global _BACKEND_AVAILABLE
    RPF_K_SMALL, RPF_K_LARGE, RPF_CV_FOLDS = 5, 15, 5
    try:
        from RPFNet.RPFExtractor import RPFExtractor
        _RPFExtractor = RPFExtractor
        from RateEstimator import RateEstimatorHead
        _RateEstimatorHead = RateEstimatorHead

        try:
            from detection import (
                RPFNetPoisonDetector,
                HybridEnsembleDetector,
                RPFExtractor,
                RPFNet_MODEL_PATH,
                RPFNet_EPOCHS,
                RPFNet_BATCH_SIZE,
                RPFNet_LR,
                estimate_contamination_rate,
                score_distribution_features,
                RateEstimatorHead,
            )
            _MetaPoisonDetector = RPFNetPoisonDetector
            _HybridEnsembleDetector = HybridEnsembleDetector
            _MetaPoisonNet = RPFNetPoisonDetector
            _META_MODEL_PATH = RPFNet_MODEL_PATH
            _META_EPOCHS = RPFNet_EPOCHS
            _META_BATCH_SIZE = RPFNet_BATCH_SIZE
            _META_LR = RPFNet_LR
            _RPF_K_SMALL = RPF_K_SMALL
            _RPF_K_LARGE = RPF_K_LARGE
            _RPF_CV_FOLDS = RPF_CV_FOLDS
        except ImportError:
            from detectionold import (
                MetaPoisonDetector, HybridEnsembleDetector,
                META_MODEL_PATH, META_EPOCHS, META_BATCH_SIZE, META_LR,
            )
            _MetaPoisonDetector = MetaPoisonDetector
            _HybridEnsembleDetector = HybridEnsembleDetector
            _META_MODEL_PATH = META_MODEL_PATH
            _META_EPOCHS = META_EPOCHS
            _META_BATCH_SIZE = META_BATCH_SIZE
            _META_LR = META_LR

        _BACKEND_AVAILABLE = True
        return True
    except ImportError:
        return False


_try_import_backend()


def _ensure_model():
    """Lazy-load the trained model on first use."""
    global _meta, _hybrid, _model_loaded, _model_init_attempted

    if _model_loaded or _model_init_attempted:
        return
    _model_init_attempted = True

    if not _BACKEND_AVAILABLE:
        print("[rpfnet] Backend not found — using IsolationForest fallback")
        return

    try:
        import torch
        device = "cuda" if torch.cuda.is_available() else "cpu"

        meta = _MetaPoisonDetector(
            device=device, epochs=_META_EPOCHS,
            batch_size=_META_BATCH_SIZE, lr=_META_LR,
        )

        candidates = [
            _META_MODEL_PATH,
            "./RPFNet/RPFNet_universal.pt",
            "./metapoison_v4_universal.pt",
            "./metapoison_v3_universal.pt",
            "./metapoison_v2_universal.pt",
            "../RPFNet/RPFNet_universal.pt",
            "../metapoison_v4_universal.pt",
            "../metapoison_v3_universal.pt",
            "../metapoison_v2_universal.pt",
        ]

        model_path = None
        seen = set()
        for p in candidates:
            if p not in seen and os.path.exists(p):
                model_path = p
                break
            seen.add(p)

        if model_path is None:
            print("[rpfnet] No checkpoint found — using IsolationForest fallback")
            return

        try:
            meta.load(model_path)
        except ValueError:
            ckpt = torch.load(model_path, map_location=meta.device, weights_only=False)
            saved_dim = ckpt.get("rpf_dim", 47)
            _NetClass = _MetaPoisonNet if _MetaPoisonNet else type(meta.net)
            meta.net = _NetClass(input_dim=saved_dim).to(meta.device)
            meta.net.load_state_dict(ckpt["net"])
            meta._threshold = ckpt.get("threshold", 0.5)
            meta._fitted = True
            if "rate_head" in ckpt:
                meta.rate_head = _RateEstimatorHead().to(meta.device)
                meta.rate_head.load_state_dict(ckpt["rate_head"])
                meta.rate_head.eval()
            meta.net.eval()
            _orig = meta._score_rpf
            def _patched(rpf, _o=_orig, _d=saved_dim):
                return _o(rpf[:, :_d] if rpf.shape[1] > _d else rpf)
            meta._score_rpf = _patched

        hybrid = _HybridEnsembleDetector(meta)
        fusion_path = model_path.replace(".pt", "_fusion.pt")
        if os.path.exists(fusion_path):
            hybrid.load(fusion_path)
        else:
            hybrid.attach_rate_head(meta.rate_head)

        _meta = meta
        _hybrid = hybrid
        _model_loaded = True
        print(f"[rpfnet] Model loaded from {model_path}  device={device}")

    except Exception as e:
        print(f"[rpfnet] Model load failed: {e} — using IsolationForest fallback")


# ─────────────────────────────────────────────────────────────────────────────
# DataFrame preparation
# ─────────────────────────────────────────────────────────────────────────────

_TARGET_CANDIDATES = [
    "loan_status", "Loan_Status", "deposit", "default", "Default",
    "label", "target", "y", "class", "outcome", "Target",
    "is_fraud", "fraud", "Class",
]


def _infer_target(df: pd.DataFrame) -> str:
    for c in _TARGET_CANDIDATES:
        if c in df.columns:
            return c
    return df.columns[-1]


def _prepare(df: pd.DataFrame):
    target_col = _infer_target(df)

    ys = df[target_col].copy()
    if ys.dtype == object or str(ys.dtype) == "category":
        pos = {"yes", "y", "1", "true", "paid", "fully paid", "current", "no default"}
        y = ys.astype(str).str.strip().str.lower().isin(pos).astype(int).values
    else:
        u = np.unique(ys.dropna())
        y = (ys == u[-1]).astype(int).values if len(u) == 2 else \
            (ys > float(np.median(ys.dropna()))).astype(int).values

    Xdf = df.drop(columns=[target_col]).copy()

    drop = [c for c in Xdf.columns
            if (Xdf[c].dtype not in (object, "category") and Xdf[c].nunique() == len(Xdf))
            or (Xdf[c].dtype == object and Xdf[c].nunique() > 0.9 * len(Xdf))]
    if drop:
        Xdf = Xdf.drop(columns=drop)

    Xdf = Xdf.dropna(axis=1, how="all")
    for c in Xdf.select_dtypes(include="number").columns:
        Xdf[c] = Xdf[c].fillna(Xdf[c].median())
    for c in Xdf.select_dtypes(exclude="number").columns:
        Xdf[c] = Xdf[c].fillna("missing")
    Xdf = pd.get_dummies(Xdf)

    X = Xdf.values.astype(np.float32)
    sc = StandardScaler()
    X_scaled = sc.fit_transform(X).astype(np.float32)
    return X_scaled, y, sc, Xdf


# ─────────────────────────────────────────────────────────────────────────────
# Bimodality & threshold estimation
# ─────────────────────────────────────────────────────────────────────────────

def _is_bimodal(scores: np.ndarray) -> bool:
    if len(scores) < 30:
        return False
    s = np.sort(scores)
    n = len(s)
    score_range = float(s[-1] - s[0]) + 1e-10
    signals = 0

    upper = s[n // 2:]
    diffs = np.diff(upper)
    if len(diffs) > 0 and diffs.max() / score_range > 0.15:
        signals += 1

    mu, std = s.mean(), s.std() + 1e-10
    if float(np.mean(((s - mu) / std) ** 4)) - 3.0 < -1.0:
        signals += 1

    if float((s >= s[0] + 0.97 * score_range).mean()) > 0.20:
        signals += 1

    try:
        counts, _ = np.histogram(s, bins=min(50, max(10, n // 100)))
        mx = counts.max()
        for i in range(2, len(counts) - 2):
            if (counts[i] < 0.30 * mx
                and counts[i-1] > counts[i] and counts[i-2] > counts[i]
                and counts[i+1] > counts[i] and counts[i+2] > counts[i]):
                signals += 1
                break
    except Exception:
        pass

    return signals >= 2


def _compute_tau(scores: np.ndarray) -> float:
    if len(scores) < 10:
        return float(np.percentile(scores, 95))
    if _is_bimodal(scores):
        s = np.sort(scores)
        n = len(s)
        best_t, best_v = float(s[-1]), 0.0
        for pct in np.arange(0.50, 0.995, 0.005):
            t = np.percentile(s, pct * 100)
            lo, hi = s[s <= t], s[s > t]
            if len(lo) < 5 or len(hi) < 2:
                continue
            bv = (len(lo) / n) * (len(hi) / n) * (lo.mean() - hi.mean()) ** 2
            if bv > best_v:
                best_v, best_t = bv, float(t)
        return best_t
    else:
        med = float(np.median(scores))
        mad = float(np.median(np.abs(scores - med))) * 1.4826 + 1e-10
        return med + 4.0 * mad


def _estimate_rate(scores: np.ndarray) -> float:
    n = len(scores)
    if n < 30:
        return 0.01
    med = float(np.median(scores))
    mad = float(np.median(np.abs(scores - med))) * 1.4826 + 1e-10
    mad_rate = float(np.clip((scores > med + 4.0 * mad).mean(), 0.003, 0.05))

    if not _is_bimodal(scores):
        return mad_rate

    tau = _compute_tau(scores)
    otsu_rate = float((scores > tau).mean())
    lo, hi = scores[scores <= tau], scores[scores > tau]
    if len(lo) < 10 or len(hi) < 5:
        return mad_rate

    sep = abs(lo.mean() - hi.mean()) / (scores.std() + 1e-8)
    if sep < 0.5:
        return mad_rate
    if sep < 1.0:
        return float(np.clip(0.5 * mad_rate + 0.5 * otsu_rate, 0.01, 0.20))
    return float(np.clip(otsu_rate, 0.01, 0.40))


# ─────────────────────────────────────────────────────────────────────────────
# Core scoring
# ─────────────────────────────────────────────────────────────────────────────

def _score_dataframe(df: pd.DataFrame) -> dict:
    """Score every row for poisoning. Returns full report dict."""
    _ensure_model()
    X, y, sc, Xdf = _prepare(df)
    n = len(X)

    raw_scores = None
    mode = "isolation_forest"

    if _model_loaded and _hybrid is not None:
        try:
            combined, _, _ = _hybrid.score(X, y)
            raw_scores = np.asarray(combined, dtype=np.float64)
            mode = "rpfnet_hybrid"
        except Exception:
            pass

    if raw_scores is None:
        iso = IsolationForest(n_estimators=200, contamination="auto",
                               random_state=42, n_jobs=-1)
        raw_scores = -iso.fit(X).score_samples(X)
        raw_scores = np.asarray(raw_scores, dtype=np.float64)

    s_min = float(raw_scores.min())
    s_range = float(raw_scores.max()) - s_min + 1e-10
    display = (raw_scores - s_min) / s_range

    est_rate = _estimate_rate(raw_scores)
    tau_raw = float(np.percentile(raw_scores, (1.0 - est_rate) * 100))
    flags = (raw_scores >= tau_raw).astype(int)

    n_flagged = int(flags.sum())
    flagged_idx = np.where(flags == 1)[0].tolist()
    clean_idx = np.where(flags == 0)[0].tolist()

    annotated = df.copy()
    annotated["_poison_score"] = display
    annotated["_poison_flag"] = flags

    return {
        "n_rows":          n,
        "n_flagged":       n_flagged,
        "n_clean":         n - n_flagged,
        "pct_flagged":     round(n_flagged / n * 100, 2) if n > 0 else 0.0,
        "estimated_rate":  round(est_rate, 4),
        "mode":            mode,
        "bimodal":         _is_bimodal(raw_scores),
        "scores":          display.tolist(),
        "flags":           flags.tolist(),
        "flagged_indices": flagged_idx,
        "clean_indices":   clean_idx,
        "dataframe":       annotated,
        "score_stats": {
            "min": round(float(display.min()), 4),
            "p25": round(float(np.percentile(display, 25)), 4),
            "p50": round(float(np.median(display)), 4),
            "p75": round(float(np.percentile(display, 75)), 4),
            "p95": round(float(np.percentile(display, 95)), 4),
            "max": round(float(display.max()), 4),
        },
    }


# ─────────────────────────────────────────────────────────────────────────────
# Data loaders
# ─────────────────────────────────────────────────────────────────────────────

def _load_csv(path: str) -> pd.DataFrame:
    if not os.path.exists(path):
        raise FileNotFoundError(f"CSV file not found: {path}")
    return pd.read_csv(path)


def _load_uci(uci_id: int) -> pd.DataFrame:
    try:
        from ucimlrepo import fetch_ucirepo
    except ImportError:
        raise ImportError(
            "ucimlrepo required for UCI datasets. Install: pip install ucimlrepo"
        )
    ds = fetch_ucirepo(id=uci_id)
    if ds is None:
        raise ValueError(f"UCI dataset {uci_id} not found.")
    df = pd.DataFrame(ds.data.features)
    if ds.data.targets is not None:
        df = pd.concat([df, pd.DataFrame(ds.data.targets)], axis=1)
    return df


def _load_url(url: str) -> pd.DataFrame:
    from urllib.parse import urlparse
    if urlparse(url).scheme not in ("http", "https"):
        raise ValueError("URL must start with http:// or https://")
    try:
        import requests as _req
        import io
        resp = _req.get(url, timeout=60)
        resp.raise_for_status()
        return pd.read_csv(io.BytesIO(resp.content))
    except ImportError:
        return pd.read_csv(url)


# ─────────────────────────────────────────────────────────────────────────────
# Stream state — module-level singleton
# ─────────────────────────────────────────────────────────────────────────────

class _StreamState:
    """
    Internal stream session persisting between analyze('stream', ...) calls.

    Lifecycle:
        analyze('stream', DataFrame)  → initialize baseline, score all rows
        analyze('stream', row)        → score one new sample
        analyze('stream')             → return current status
        clean('stream')               → return clean rows from buffer
        stream_reset()                → wipe everything
    """

    def __init__(self):
        self.reset()

    def reset(self):
        self.buffer = []
        self.buffer_df = None
        self.scores = []
        self.flags = []
        self.scaler = StandardScaler()
        self.model = None
        self.initialized = False
        self.window_size = 500
        self.warmup = 20
        self.columns = None
        self.n_features = None


_stream = _StreamState()


def _stream_init_baseline(df: pd.DataFrame) -> dict:
    """Initialize stream with a full dataset as baseline."""
    _stream.reset()
    _stream.buffer_df = df.copy()
    _stream.columns = list(df.columns)

    X, y, sc, Xdf = _prepare(df)
    _stream.n_features = X.shape[1]
    _stream.scaler = StandardScaler()
    X_scaled = _stream.scaler.fit_transform(X)

    for i in range(len(X_scaled)):
        _stream.buffer.append(X_scaled[i])

    _stream.model = IsolationForest(
        n_estimators=200, random_state=42, n_jobs=-1
    )
    _stream.model.fit(X_scaled)
    _stream.initialized = True

    raw = -_stream.model.score_samples(X_scaled)
    tau = _compute_tau(raw)

    for i in range(len(raw)):
        _stream.scores.append(float(raw[i]))
        _stream.flags.append(bool(raw[i] >= tau))

    n_flagged = sum(_stream.flags)
    display = (raw - raw.min()) / (raw.max() - raw.min() + 1e-10)

    annotated = df.copy()
    annotated["_poison_score"] = display
    annotated["_poison_flag"] = [int(f) for f in _stream.flags]

    return {
        "status":          "initialized",
        "n_rows":          len(df),
        "n_flagged":       n_flagged,
        "n_clean":         len(df) - n_flagged,
        "pct_flagged":     round(n_flagged / len(df) * 100, 2),
        "mode":            "stream",
        "scores":          display.tolist(),
        "flags":           [int(f) for f in _stream.flags],
        "flagged_indices": [i for i, f in enumerate(_stream.flags) if f],
        "clean_indices":   [i for i, f in enumerate(_stream.flags) if not f],
        "dataframe":       annotated,
    }


def _stream_score_row(row) -> dict:
    """Score a single new sample against the stream baseline."""
    if isinstance(row, dict):
        row = list(row.values())
    if isinstance(row, pd.Series):
        row = row.values
    sample = np.asarray(row, dtype=np.float32).ravel()

    _stream.buffer.append(sample)

    # Sliding window
    if len(_stream.buffer) > _stream.window_size:
        _stream.buffer.pop(0)
        if _stream.scores:
            _stream.scores.pop(0)
        if _stream.flags:
            _stream.flags.pop(0)

    X = np.array(_stream.buffer, dtype=np.float32)

    # Warmup phase
    if len(X) < _stream.warmup:
        return {
            "status":      "warming_up",
            "n_samples":   len(X),
            "warmup":      _stream.warmup,
            "score":       None,
            "threshold":   None,
            "poison_flag": None,
        }

    X_scaled = _stream.scaler.fit_transform(X)

    # First time with enough data — train model + backfill
    if not _stream.initialized:
        _stream.model = IsolationForest(
            n_estimators=200, random_state=42, n_jobs=-1
        )
        _stream.model.fit(X_scaled)
        _stream.initialized = True

        raw = -_stream.model.score_samples(X_scaled)
        tau = _compute_tau(raw)
        _stream.scores = [float(s) for s in raw]
        _stream.flags = [bool(s >= tau) for s in raw]

        newest_score = float(raw[-1])
        flag = bool(raw[-1] >= tau)
    else:
        # Score current window, read the newest
        raw_all = -_stream.model.score_samples(X_scaled)
        newest_score = float(raw_all[-1])
        tau = _compute_tau(raw_all)
        flag = newest_score >= tau

        _stream.scores.append(newest_score)
        _stream.flags.append(bool(flag))

    n_flagged = sum(_stream.flags)
    n_total = len(_stream.buffer)

    return {
        "status":             "active",
        "score":              round(newest_score, 6),
        "threshold":          round(float(tau), 6),
        "poison_flag":        bool(flag),
        "n_samples":          n_total,
        "n_flagged":          n_flagged,
        "n_clean":            n_total - n_flagged,
        "buffer_pct_flagged": round(n_flagged / n_total * 100, 2),
    }


def _stream_status() -> dict:
    """Current stream state without feeding new data."""
    n = len(_stream.buffer)
    n_flagged = sum(_stream.flags) if _stream.flags else 0
    return {
        "status":      "active" if _stream.initialized else
                       ("warming_up" if n > 0 else "empty"),
        "n_samples":   n,
        "n_flagged":   n_flagged,
        "n_clean":     n - n_flagged,
        "initialized": _stream.initialized,
        "window_size": _stream.window_size,
    }


# ═════════════════════════════════════════════════════════════════════════════
# PUBLIC API
# ═════════════════════════════════════════════════════════════════════════════

def analyze(
    source: Literal["uci", "csv", "url", "stream"],
    dataset: Union[int, str, list, dict, np.ndarray, pd.DataFrame,
                   pd.Series, None] = None,
) -> dict:
    """
    Analyze data for poisoning.

    Parameters
    ----------
    source : 'uci', 'csv', 'url', or 'stream'
    dataset :
        'csv'    → file path (str)
        'uci'    → UCI dataset ID (int)
        'url'    → URL string (str)
        'stream' → DataFrame to initialize baseline,
                    list/dict/array/Series for a single row,
                    or None to check current status

    Returns
    -------
    dict
        Batch: full report with scores, flags, dataframe, stats.
        Stream + DataFrame: baseline report (same shape as batch).
        Stream + row: {score, threshold, poison_flag, n_samples, ...}
        Stream + None: {status, n_samples, n_flagged, ...}

    Examples
    --------
    >>> report = api.analyze('uci', 73)
    >>> print(report['pct_flagged'])

    >>> report = api.analyze('stream', training_df)
    >>> result = api.analyze('stream', [1.2, 3.4, 5.6])
    >>> print(result['poison_flag'])

    >>> status = api.analyze('stream')
    >>> print(status['n_samples'])
    """
    warnings.warn(
        "rpfnet is in ALPHA — APIs may change and results should be validated.",
        UserWarning, stacklevel=2,
    )

    # ── Batch sources ─────────────────────────────────────────────────
    if source == "csv":
        if not isinstance(dataset, str):
            raise TypeError("source='csv' requires dataset as file path (str)")
        df = _load_csv(dataset)
        if len(df) < 5:
            raise ValueError(f"Dataset too small ({len(df)} rows). Need >= 5.")
        return _score_dataframe(df)

    if source == "uci":
        if not isinstance(dataset, int):
            raise TypeError("source='uci' requires dataset as UCI ID (int)")
        df = _load_uci(dataset)
        if len(df) < 5:
            raise ValueError(f"Dataset too small ({len(df)} rows). Need >= 5.")
        return _score_dataframe(df)

    if source == "url":
        if not isinstance(dataset, str):
            raise TypeError("source='url' requires dataset as URL (str)")
        df = _load_url(dataset)
        if len(df) < 5:
            raise ValueError(f"Dataset too small ({len(df)} rows). Need >= 5.")
        return _score_dataframe(df)

    # ── Stream ────────────────────────────────────────────────────────
    if source == "stream":
        if dataset is None:
            return _stream_status()

        if isinstance(dataset, pd.DataFrame):
            if len(dataset) < 5:
                raise ValueError(f"Baseline too small ({len(dataset)} rows).")
            return _stream_init_baseline(dataset)

        if isinstance(dataset, (list, dict, np.ndarray, pd.Series)):
            return _stream_score_row(dataset)

        raise TypeError(
            f"source='stream' accepts DataFrame, list, dict, array, "
            f"Series, or None. Got {type(dataset).__name__}"
        )

    raise ValueError(
        f"Invalid source '{source}'. Use 'csv', 'uci', 'url', or 'stream'."
    )


def clean(
    source: Literal["uci", "csv", "url", "stream"],
    dataset: Union[int, str, pd.DataFrame, None] = None,
) -> pd.DataFrame:
    """
    Return only the clean (non-poisoned) rows.

    Parameters
    ----------
    source : 'uci', 'csv', 'url', or 'stream'
    dataset :
        'csv'    → file path (str)
        'uci'    → UCI dataset ID (int)
        'url'    → URL string (str)
        'stream' → None uses current buffer.
                    DataFrame initializes baseline first, then filters.

    Returns
    -------
    pd.DataFrame
        Original data with poisoned rows removed.

    Examples
    --------
    >>> clean_df = api.clean('uci', 73)
    >>> print(f"Kept {len(clean_df)} clean rows")

    >>> api.analyze('stream', training_df)
    >>> clean_df = api.clean('stream')
    """
    # ── Stream ────────────────────────────────────────────────────────
    if source == "stream":
        if isinstance(dataset, pd.DataFrame):
            _stream_init_baseline(dataset)

        if not _stream.flags:
            raise RuntimeError(
                "Stream has no data yet. Call analyze('stream', data) first."
            )

        # Return from original DataFrame if available
        if (_stream.buffer_df is not None
                and len(_stream.flags) == len(_stream.buffer_df)):
            clean_idx = [i for i, f in enumerate(_stream.flags) if not f]
            return _stream.buffer_df.iloc[clean_idx].reset_index(drop=True)

        # Fallback: return numeric buffer rows
        clean_rows = [
            _stream.buffer[i]
            for i in range(len(_stream.buffer))
            if i < len(_stream.flags) and not _stream.flags[i]
        ]
        return pd.DataFrame(clean_rows) if clean_rows else pd.DataFrame()

    # ── Batch sources ─────────────────────────────────────────────────
    if dataset is None:
        raise TypeError(f"dataset is required for source='{source}'")

    result = analyze(source, dataset)
    original = result["dataframe"].drop(
        columns=["_poison_score", "_poison_flag"], errors="ignore"
    )
    return original.iloc[result["clean_indices"]].reset_index(drop=True)


def stream_reset():
    """Reset the stream detector. Clears all buffered data and scores."""
    _stream.reset()


def stream_retrain():
    """
    Force retrain the stream model on the current buffer.
    Use after concept drift or when the baseline becomes stale.
    """
    if len(_stream.buffer) < _stream.warmup:
        raise RuntimeError(
            f"Need >= {_stream.warmup} samples to retrain. "
            f"Buffer has {len(_stream.buffer)}."
        )
    X = np.array(_stream.buffer, dtype=np.float32)
    X_scaled = _stream.scaler.fit_transform(X)
    _stream.model = IsolationForest(
        n_estimators=200, random_state=42, n_jobs=-1
    )
    _stream.model.fit(X_scaled)
    _stream.initialized = True

    raw = -_stream.model.score_samples(X_scaled)
    tau = _compute_tau(raw)
    _stream.scores = [float(s) for s in raw]
    _stream.flags = [bool(s >= tau) for s in raw]