#!/usr/bin/env python3
"""Anomaly detection workflow with Python and scikit-learn.

This script provides a practical starting point for operational anomaly detection:
- load or generate numeric feature data
- validate inputs
- train an IsolationForest baseline
- score records for unusualness
- inspect top suspicious rows
- evaluate against known labels when available

The script is intentionally vendor-neutral and avoids any destructive actions.

Example usage:

  # Train on a CSV file and save the model
  python anomaly_detection_workflow.py --input data.csv --save-model anomaly_model.joblib

  # Generate a synthetic example dataset and run the workflow
  python anomaly_detection_workflow.py --demo

Expected CSV input:
  A table where each row is one observation and each column is a numeric feature.
  Optional label column can be provided for evaluation only.

Recommended feature types:
  counts, rates, latency, byte volume, ratios, rolling stats, time-since-event.
"""

from __future__ import annotations

import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional, Tuple

import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler


@dataclass
class DatasetBundle:
    features: pd.DataFrame
    labels: Optional[pd.Series] = None


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Train and use a baseline anomaly detection model with Python."
    )
    parser.add_argument(
        "--input",
        type=Path,
        help="Path to a CSV file containing numeric features.",
    )
    parser.add_argument(
        "--label-column",
        default=None,
        help="Optional label column name for evaluation only (e.g. 'is_anomaly').",
    )
    parser.add_argument(
        "--save-model",
        type=Path,
        default=None,
        help="Optional path to save the trained model pipeline as a joblib file.",
    )
    parser.add_argument(
        "--top-n",
        type=int,
        default=10,
        help="Number of most suspicious rows to print.",
    )
    parser.add_argument(
        "--contamination",
        type=float,
        default=0.025,
        help="Expected proportion of anomalies in the data, between 0 and 0.5.",
    )
    parser.add_argument(
        "--test-size",
        type=float,
        default=0.25,
        help="Test split size for evaluation when labels are available.",
    )
    parser.add_argument(
        "--random-state",
        type=int,
        default=42,
        help="Random seed for reproducibility.",
    )
    parser.add_argument(
        "--demo",
        action="store_true",
        help="Run the script with a synthetic demo dataset.",
    )
    return parser.parse_args()


def validate_parameters(contamination: float, test_size: float, top_n: int) -> None:
    if not (0.0 < contamination < 0.5):
        raise ValueError("--contamination must be between 0 and 0.5.")
    if not (0.05 <= test_size < 0.95):
        raise ValueError("--test-size must be between 0.05 and 0.95.")
    if top_n < 1:
        raise ValueError("--top-n must be at least 1.")


def load_csv_dataset(path: Path, label_column: Optional[str]) -> DatasetBundle:
    if not path.exists():
        raise FileNotFoundError(f"Input file not found: {path}")

    df = pd.read_csv(path)
    if df.empty:
        raise ValueError("Input CSV is empty.")

    labels = None
    features = df.copy()
    if label_column is not None:
        if label_column not in features.columns:
            raise ValueError(f"Label column '{label_column}' was not found.")
        labels = features[label_column]
        features = features.drop(columns=[label_column])

    return DatasetBundle(features=features, labels=labels)


def make_demo_dataset(random_state: int = 42) -> DatasetBundle:
    rng = np.random.default_rng(random_state)
    n_normal = 1000
    n_anomaly = 25

    normal = pd.DataFrame(
        {
            "requests_per_min": rng.normal(120, 15, n_normal),
            "error_rate": rng.normal(0.02, 0.01, n_normal),
            "avg_latency_ms": rng.normal(180, 25, n_normal),
            "bytes_out": rng.normal(1_500_000, 200_000, n_normal),
        }
    )

    anomalies = pd.DataFrame(
        {
            "requests_per_min": rng.normal(240, 20, n_anomaly),
            "error_rate": rng.normal(0.15, 0.03, n_anomaly),
            "avg_latency_ms": rng.normal(420, 50, n_anomaly),
            "bytes_out": rng.normal(3_000_000, 300_000, n_anomaly),
        }
    )

    X = pd.concat([normal, anomalies], ignore_index=True)
    y = pd.Series([0] * n_normal + [1] * n_anomaly, name="is_anomaly")

    for col in X.columns:
        X[col] = pd.to_numeric(X[col], errors="coerce")
        X[col] = X[col].clip(lower=0)

    return DatasetBundle(features=X, labels=y)


def validate_features(df: pd.DataFrame) -> None:
    if df.empty:
        raise ValueError("Feature table is empty.")

    non_numeric = [col for col in df.columns if not pd.api.types.is_numeric_dtype(df[col])]
    if non_numeric:
        raise ValueError(
            "All feature columns must be numeric. Non-numeric columns found: "
            + ", ".join(non_numeric)
        )

    if df.isna().any().any():
        missing = df.columns[df.isna().any()].tolist()
        raise ValueError(
            "Missing values detected in feature columns: " + ", ".join(missing)
        )

    if not np.isfinite(df.to_numpy()).all():
        raise ValueError("Features contain infinite or non-finite values.")


def summarize_dataset(df: pd.DataFrame) -> None:
    print("\nDataset summary:")
    print(df.describe().T)
    print("\nMissing values per column:")
    print(df.isna().sum())


def build_model(contamination: float, random_state: int) -> Pipeline:
    return Pipeline(
        [
            ("scaler", StandardScaler()),
            (
                "iso",
                IsolationForest(
                    n_estimators=200,
                    contamination=contamination,
                    random_state=random_state,
                ),
            ),
        ]
    )


def fit_and_score(
    model: Pipeline,
    X_train: pd.DataFrame,
    X_eval: pd.DataFrame,
) -> pd.DataFrame:
    model.fit(X_train)

    scores = model.decision_function(X_eval)
    predictions = model.predict(X_eval)  # -1 = anomaly, 1 = normal

    result = X_eval.copy()
    result["anomaly_score"] = scores
    result["predicted_label"] = predictions
    result["is_flagged_anomaly"] = (predictions == -1).astype(int)
    return result.sort_values("anomaly_score", ascending=True)


def evaluate_with_labels(y_true: pd.Series, flagged: Iterable[int]) -> None:
    y_true = pd.Series(y_true).astype(int)
    y_pred = pd.Series(flagged).astype(int)

    print("\nConfusion matrix (rows=true, cols=pred):")
    print(confusion_matrix(y_true, y_pred))

    print("\nClassification report:")
    print(classification_report(y_true, y_pred, digits=4, zero_division=0))


def save_model(model: Pipeline, path: Path) -> None:
    try:
        import joblib
    except ImportError as exc:
        raise RuntimeError(
            "joblib is required to save the model. Install it with: pip install joblib"
        ) from exc

    path.parent.mkdir(parents=True, exist_ok=True)
    joblib.dump(model, path)
    print(f"\nModel saved to: {path}")


def main() -> int:
    args = parse_args()
    validate_parameters(args.contamination, args.test_size, args.top_n)

    if args.demo:
        bundle = make_demo_dataset(args.random_state)
    elif args.input is not None:
        bundle = load_csv_dataset(args.input, args.label_column)
    else:
        print("Provide --input <csv> or use --demo.", file=sys.stderr)
        return 2

    validate_features(bundle.features)
    summarize_dataset(bundle.features)

    if bundle.labels is not None:
        X_train, X_test, y_train, y_test = train_test_split(
            bundle.features,
            bundle.labels,
            test_size=args.test_size,
            random_state=args.random_state,
            stratify=bundle.labels,
        )
    else:
        X_train, X_test = train_test_split(
            bundle.features,
            test_size=args.test_size,
            random_state=args.random_state,
        )
        y_train = y_test = None

    model = build_model(args.contamination, args.random_state)
    scored = fit_and_score(model, X_train, X_test)

    print(f"\nTop {args.top_n} most suspicious rows:")
    print(scored.head(args.top_n).to_string())

    if y_test is not None:
        evaluate_with_labels(y_test.reset_index(drop=True), scored["is_flagged_anomaly"].reset_index(drop=True))
    else:
        print(
            "\nNo labels supplied. Use the anomaly scores for triage and review, "
            "not as ground truth."
        )

    if args.save_model is not None:
        save_model(model, args.save_model)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())