"""
RPFNet Public API — PyPI Package Interface
===========================================
Ported from the working Flask backend (app.py).

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 ──────────────────────────────
    report = api.analyze('stream', training_df)   # initialize baseline
    result = api.analyze('stream', new_row)        # score one row
    result = api.analyze('stream', {'f1': 1.2})   # score dict row
    clean_df = api.clean('stream')                 # get clean buffer rows
    status = api.analyze('stream')                 # check status

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

from __future__ import annotations

import importlib
import importlib.resources as _pkg_resources
import io
import os
import uuid
import warnings
from typing import Literal, Union

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

# ─────────────────────────────────────────────────────────────────────────────
# Package-relative base directory  (works both in dev tree and installed wheel)
# ─────────────────────────────────────────────────────────────────────────────

BASE_DIR = os.path.dirname(os.path.abspath(__file__))


def _resolve_bundled_model() -> str:
    """
    Locate RPFNet_universal.pt whether running from the source tree or as
    an installed PyPI package.  Returns an absolute path (may not exist yet
    if the package was installed without the weight file).
    """
    # 1. Co-located with api.py — works in both the dev tree and installed wheel
    local = os.path.join(BASE_DIR, "RPFNet_universal.pt")
    if os.path.exists(local):
        return local

    # 2. importlib.resources (Python ≥ 3.9, handles zip-safe wheels)
    try:
        ref = _pkg_resources.files("RPFNet").joinpath("RPFNet_universal.pt")
        with _pkg_resources.as_file(ref) as p:
            if os.path.exists(str(p)):
                return str(p)
    except Exception:
        pass

    # 3. Legacy pkg_resources (Python 3.8 / editable installs)
    try:
        import pkg_resources as _pr
        path = _pr.resource_filename("RPFNet", "RPFNet_universal.pt")
        if os.path.exists(path):
            return path
    except Exception:
        pass

    # Fallback — return expected location so the missing-file error is clear
    return local


# ─────────────────────────────────────────────────────────────────────────────
# Global model state
# ─────────────────────────────────────────────────────────────────────────────

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

# Populated by _try_import_backend()
_MetaPoisonDetector    = None
_HybridEnsembleDetector = None
_RateEstimatorHead     = None
_MetaPoisonNet         = None
_RPFExtractor_cls      = None

_META_MODEL_PATH = _resolve_bundled_model()   # robust absolute path — set BEFORE backend import
_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
_LOADED_RPF_DIM    = None   # actual dim of the loaded checkpoint


# ─────────────────────────────────────────────────────────────────────────────
# Backend import  (mirrors app.py's import block, supports relative + absolute)
# ─────────────────────────────────────────────────────────────────────────────

def _try_import_backend() -> bool:
    """
    Import the RPFNet detection backend.

    Tries relative imports first (installed package: `from rpfnet import api`)
    then absolute imports (direct run / dev tree: `from RPFNet.api import …`).

    CRITICAL: never lets detection.py's RPFNet_MODEL_PATH override
    _META_MODEL_PATH unless it is an *absolute* path that actually exists —
    detection.py exports a CWD-relative "./RPFNet_universal.pt" which breaks
    when the package is imported from a different working directory.
    """
    global _MetaPoisonDetector, _HybridEnsembleDetector
    global _RateEstimatorHead, _MetaPoisonNet, _RPFExtractor_cls
    global _META_MODEL_PATH, _META_EPOCHS, _META_BATCH_SIZE, _META_LR
    global _RPF_K_SMALL, _RPF_K_LARGE, _RPF_CV_FOLDS, _BACKEND_AVAILABLE

    def _import(mod_name: str):
        """Try relative then absolute import, return module or raise."""
        last_err = None
        for pkg, rel in (("RPFNet", True), (None, False)):
            try:
                if rel:
                    return importlib.import_module(f".{mod_name}", package="RPFNet")
                else:
                    return importlib.import_module(f"RPFNet.{mod_name}")
            except Exception as e:
                last_err = e
        raise ImportError(f"Cannot import {mod_name}: {last_err}") from last_err

    # ── Core submodules ──────────────────────────────────────────────────────
    try:
        rpf_mod  = _import("RPFExtractor")
        rate_mod = _import("RateEstimator")
        _RPFExtractor_cls  = rpf_mod.RPFExtractor
        _RateEstimatorHead = rate_mod.RateEstimatorHead
    except Exception as e:
        print(f"[rpfnet] Core submodule import failed: {e}")
        return False

    # ── detection.py ────────────────────────────────────────────────────────
    try:
        det = _import("detection")
    except Exception as e:
        print(f"[rpfnet] detection.py import failed: {e}")
        return False

    if not hasattr(det, "RPFNetPoisonDetector"):
        print("[rpfnet] detection.py missing RPFNetPoisonDetector")
        return False

    _MetaPoisonDetector     = det.RPFNetPoisonDetector
    _HybridEnsembleDetector = det.HybridEnsembleDetector
    _MetaPoisonNet          = det.RPFNetPoisonDetector

    # Only adopt detection.py's model path if it is absolute AND on disk.
    # Its default value ("./RPFNet_universal.pt") is CWD-relative and will
    # silently break when imported from a different directory.
    detected_path = getattr(det, "RPFNet_MODEL_PATH", None)
    if detected_path and os.path.isabs(detected_path) and os.path.exists(detected_path):
        _META_MODEL_PATH = detected_path

    _META_EPOCHS     = getattr(det, "RPFNet_EPOCHS",     _META_EPOCHS)
    _META_BATCH_SIZE = getattr(det, "RPFNet_BATCH_SIZE", _META_BATCH_SIZE)
    _META_LR         = getattr(det, "RPFNet_LR",         _META_LR)
    # k / cv values from detection.py if present, else keep defaults
    _RPF_K_SMALL  = getattr(det, "RPF_K_SMALL",  _RPF_K_SMALL)
    _RPF_K_LARGE  = getattr(det, "RPF_K_LARGE",  _RPF_K_LARGE)
    _RPF_CV_FOLDS = getattr(det, "RPF_CV_FOLDS", _RPF_CV_FOLDS)

    _BACKEND_AVAILABLE = True
    return True


_try_import_backend()


# ─────────────────────────────────────────────────────────────────────────────
# Lazy model loader  (ported from app.py's _load_model / _load_model_compat)
# ─────────────────────────────────────────────────────────────────────────────

def _load_model_compat(meta, path: str) -> bool:
    """
    Load checkpoint with backward-compatibility for dim mismatches
    (e.g. a v2 model with dim=47 loaded by v3 code expecting dim=55+).
    Mirrors app.py's _load_model_compat exactly.
    """
    global _LOADED_RPF_DIM

    import torch

    # ── Normal load ──────────────────────────────────────────────────────────
    try:
        meta.load(path)
        _LOADED_RPF_DIM = getattr(_RPFExtractor_cls, "DIM", None)
        return True
    except ValueError as e:
        if "RPF dim" not in str(e):
            raise

    # ── Dim-mismatch: backward-compat load ──────────────────────────────────
    print("[rpfnet] Dim mismatch — loading checkpoint in backward-compat mode")
    try:
        ckpt = torch.load(path, map_location=meta.device, weights_only=False)
    except TypeError:
        ckpt = torch.load(path, map_location=meta.device)

    saved_dim = ckpt.get("rpf_dim", 47)
    current_dim = getattr(_RPFExtractor_cls, "DIM", "?")
    print(f"[rpfnet]   saved_dim={saved_dim}  current_dim={current_dim}")

    _NetClass = _MetaPoisonNet if _MetaPoisonNet is not None 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    = ckpt.get("fitted", True)

    if hasattr(meta, "extractor"):
        meta.extractor.k_small  = ckpt.get("k_small",  _RPF_K_SMALL)
        meta.extractor.k_large  = ckpt.get("k_large",  _RPF_K_LARGE)
        meta.extractor.cv_folds = ckpt.get("cv_folds", _RPF_CV_FOLDS)

    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()
    _LOADED_RPF_DIM = saved_dim

    # Monkey-patch scorer to truncate RPF features to saved_dim
    _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

    v = ckpt.get("version", 2)
    print(f"[rpfnet]   ✓ Loaded v{v} checkpoint (dim={saved_dim}) in compat mode")
    return True


def _ensure_model():
    """Lazy-load the trained RPFNet 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 available — 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,
        )

        # Candidate paths — absolute resolved path first, then legacy fallbacks
        candidates = [
            _META_MODEL_PATH,                               # robustly resolved absolute path
            os.path.join(BASE_DIR, "RPFNet_universal.pt"), # same dir as api.py
            os.path.join(BASE_DIR, "..", "RPFNet_universal.pt"),
        ]

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

        if model_path is None:
            print("[rpfnet] No checkpoint found — using IsolationForest fallback")
            print(f"[rpfnet] Searched: {sorted(seen)}")
            return

        _load_model_compat(meta, model_path)

        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  (identical to app.py)
# ─────────────────────────────────────────────────────────────────────────────

_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 helpers  (ported from app.py — the FIXED versions)
# ─────────────────────────────────────────────────────────────────────────────

def _is_bimodal(scores: np.ndarray) -> bool:
    """
    Strict 2-of-4 bimodality check (ported from app.py ◆ FIX 4).
    Requires at least 2 of: gap test, kurtosis, tail mass, histogram valley.
    """
    if len(scores) < 30:
        return False

    s = np.sort(scores)
    n = len(s)
    score_range = float(s[-1] - s[0]) + 1e-10
    signals = 0

    # 1. Large gap in the upper half of scores
    upper_half = s[n // 2:]
    diffs = np.diff(upper_half)
    if len(diffs) > 0 and diffs.max() / score_range > 0.15:
        signals += 1

    # 2. Clearly platykurtic distribution
    mu  = s.mean()
    std = s.std() + 1e-10
    kurt = float(np.mean(((s - mu) / std) ** 4)) - 3.0
    if kurt < -1.0:
        signals += 1

    # 3. Concentrated tail mass in the top 3% of the score range
    top_3pct = s[0] + 0.97 * score_range
    if float((s >= top_3pct).mean()) > 0.20:
        signals += 1

    # 4. Histogram valley between two peaks
    try:
        n_bins = min(50, max(10, n // 100))
        counts, _ = np.histogram(s, bins=n_bins)
        max_count = counts.max()
        for i in range(2, len(counts) - 2):
            if (counts[i] < 0.30 * max_count
                    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:
    """
    Bimodality-aware threshold (ported from app.py's _compute_tau_local ◆ FIX 2).
    Bimodal  → Otsu optimal split.
    Unimodal → median + 4 × MAD  (conservative, flags ~0.5–2 %).
    """
    if len(scores) < 10:
        return float(np.percentile(scores, 95))

    if _is_bimodal(scores):
        s = np.sort(scores)
        n = len(s)
        best_thresh = float(s[-1])
        best_var    = 0.0
        for pct in np.arange(0.50, 0.995, 0.005):
            t     = np.percentile(s, pct * 100)
            below = s[s <= t]
            above = s[s > t]
            if len(below) < 5 or len(above) < 2:
                continue
            w0 = len(below) / n
            w1 = len(above) / n
            bv = w0 * w1 * (below.mean() - above.mean()) ** 2
            if bv > best_var:
                best_var    = bv
                best_thresh = float(t)
        return best_thresh
    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:
    """
    Principled contamination estimator (ported from app.py ◆ FIX 3).
    Unimodal       → MAD-based conservative estimate (≤ 5 %).
    Weak bimodal   → blend MAD + Otsu (≤ 20 %).
    Strong bimodal → trust Otsu (≤ 40 %).
    """
    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_otsu  = _compute_tau(scores)
    otsu_rate = float((scores > tau_otsu).mean())
    below     = scores[scores <= tau_otsu]
    above     = scores[scores > tau_otsu]

    if len(below) < 10 or len(above) < 5:
        return mad_rate

    sep = abs(below.mean() - above.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 pipeline  (ported from app.py's _score_dataframe ◆ FIX 1+5)
# ─────────────────────────────────────────────────────────────────────────────

def _score_dataframe(df: pd.DataFrame) -> dict:
    """
    Score every row for poisoning and return a full report dict.

    Return keys
    -----------
    n_rows, n_flagged, n_clean, pct_flagged, estimated_rate,
    mode, bimodal, scores, flags, flagged_indices, clean_indices,
    dataframe (annotated with _poison_score & _poison_flag), score_stats.
    """
    _ensure_model()
    X, y, sc, Xdf = _prepare(df)
    n = len(X)

    raw_scores: np.ndarray | None = None
    mode = "isolation_forest"

    # ── Try trained RPFNet hybrid scorer first ────────────────────────────────
    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 as exc:
            print(f"[rpfnet] Hybrid scoring failed ({exc}), falling back")

    # ── IsolationForest fallback (◆ FIX 5: 200 trees) ────────────────────────
    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)

    # ── Min-max normalise for display (◆ FIX 1: NOT rank-normalised) ─────────
    s_min   = float(raw_scores.min())
    s_range = float(raw_scores.max()) - s_min + 1e-10
    display = ((raw_scores - s_min) / s_range).astype(np.float64)

    # ── Threshold & flags ─────────────────────────────────────────────────────
    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 with: 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
        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  (mirrors app.py's STREAM_SESSIONS, but as a module singleton)
# ─────────────────────────────────────────────────────────────────────────────

class _StreamState:
    """
    Single persistent stream session.

    Lifecycle:
        analyze('stream', DataFrame)  → initialise 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
        stream_retrain()              → force retrain on current buffer
    """

    def __init__(self):
        self.reset()

    def reset(self):
        self.buffer:      list        = []
        self.buffer_df:   pd.DataFrame | None = None
        self.scores:      list        = []
        self.flags:       list        = []
        self.scaler:      StandardScaler = StandardScaler()
        self.model:       IsolationForest | None = None
        self.initialized: bool        = False
        self.window_size: int         = 500
        self.warmup:      int         = 20
        self.columns:     list | None = None


_stream = _StreamState()


def _stream_init_baseline(df: pd.DataFrame) -> dict:
    """Initialise 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.scaler    = StandardScaler()
    X_scaled          = _stream.scaler.fit_transform(X)
    _stream.n_features = X_scaled.shape[1]

    _stream.buffer.extend([row.copy() for row in X_scaled])

    _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]

    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 model."""
    if isinstance(row, dict):
        row = list(row.values())
    if isinstance(row, pd.Series):
        row = row.values
    sample = np.asarray(row, dtype=np.float32).ravel()
    if _stream.n_features is not None:
        if len(sample) > _stream.n_features:
            sample = sample[:_stream.n_features]          # trim extra cols
        elif len(sample) < _stream.n_features:
            sample = np.pad(sample, (0, _stream.n_features - len(sample)))
    _stream.buffer.append(sample)

    # Sliding window — drop oldest sample + corresponding score/flag
    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.vstack([np.asarray(r, dtype=np.float32).ravel() for r in _stream.buffer])

    # Warmup phase — not enough data yet
    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 we have enough data — train model and backfill scores
    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:
        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:
    """Return current stream status without consuming any 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' | 'stream'
    dataset :
        'csv'    → file path (str)
        'uci'    → UCI dataset ID (int)
        'url'    → URL string (str)
        'stream' → DataFrame  — initialise/re-initialise baseline
                   list / dict / ndarray / Series — score one new row
                   None — return current stream status

    Returns
    -------
    dict
        Batch mode  : n_rows, n_flagged, n_clean, pct_flagged,
                      estimated_rate, mode, bimodal, scores, flags,
                      flagged_indices, clean_indices, dataframe, score_stats
        Stream init : same shape as batch + status='initialized'
        Stream row  : score, threshold, poison_flag, n_samples, …
        Stream status: status, n_samples, n_flagged, initialized, …

    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 a 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 a 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 a 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). Need >= 5.")
            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, ndarray, "
            f"Series, or None — got {type(dataset).__name__}"
        )

    raise ValueError(
        f"Invalid source '{source}'. Choose from: 'csv', 'uci', 'url', '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' | 'stream'
    dataset :
        'csv'    → file path (str)
        'uci'    → UCI dataset ID (int)
        'url'    → URL string (str)
        'stream' → None  — filter current buffer (call analyze first)
                   DataFrame — initialise baseline then filter

    Returns
    -------
    pd.DataFrame
        Original data with all flagged rows removed.

    Examples
    --------
    >>> clean_df = api.clean('uci', 73)
    >>> print(f"Kept {len(clean_df)} 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."
            )

        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: reconstruct from numeric buffer
        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() -> None:
    """Reset the stream detector, clearing all buffered data and scores."""
    _stream.reset()


def stream_retrain() -> None:
    """
    Force retrain the stream model on the current buffer.

    Use this after concept drift or when the baseline becomes stale.
    Raises RuntimeError if the buffer is smaller than the warmup threshold.
    """
    if len(_stream.buffer) < _stream.warmup:
        raise RuntimeError(
            f"Need >= {_stream.warmup} samples to retrain.  "
            f"Buffer currently has {len(_stream.buffer)}."
        )
    X = np.vstack([np.asarray(r, dtype=np.float32).ravel() for r in _stream.buffer])
    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]