```python
#!/usr/bin/env python3
"""network_anomaly_detector.py

A practical, vendor-neutral baseline script for detecting unusual network traffic patterns.

What this script does:
- Loads flow or time-window aggregate data from CSV
- Validates required columns
- Builds simple, interpretable features
- Trains a baseline model on mostly normal traffic
- Scores new rows for anomaly likelihood
- Saves results to CSV for analyst review

Why this script exists:
- Raw packets are usually too granular for operational anomaly detection
- Time-based split is preferred over random row split for network data
- Lightweight, explainable baselines are often more useful than complex models in production

Input expectations:
- CSV with at least a timestamp column and a set of numeric traffic features
- Optional label column if you have known benign/anomalous examples

Example usage:
    python network_anomaly_detector.py \
        --input traffic.csv \
        --timestamp-col timestamp \
        --feature-cols bytes,packets,duration,unique_dests,failed_connections \
        --output scored_traffic.csv \
        --train-fraction 0.7

Notes:
- This script avoids hard-coded endpoints, secrets, and destructive operations
- It is intended as a starting point for analyst triage, not a full SOC pipeline
"""

from __future__ import annotations

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

import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler


@dataclass
class Config:
    input_path: Path
    output_path: Path
    timestamp_col: str
    feature_cols: List[str]
    label_col: Optional[str]
    train_fraction: float
    contamination: float
    random_state: int
    sort_ascending: bool


def parse_args() -> Config:
    parser = argparse.ArgumentParser(
        description="Score network traffic rows for anomalies using a simple baseline model."
    )
    parser.add_argument("--input", required=True, help="Path to input CSV file.")
    parser.add_argument("--output", required=True, help="Path to output CSV file.")
    parser.add_argument(
        "--timestamp-col",
        default="timestamp",
        help="Name of the timestamp column used for time-based ordering.",
    )
    parser.add_argument(
        "--feature-cols",
        required=True,
        help="Comma-separated list of numeric feature columns to use.",
    )
    parser.add_argument(
        "--label-col",
        default=None,
        help="Optional label column. If provided, rows with value 0 are treated as normal for training.",
    )
    parser.add_argument(
        "--train-fraction",
        type=float,
        default=0.7,
        help="Fraction of earliest rows used for baseline training when no label column is provided.",
    )
    parser.add_argument(
        "--contamination",
        type=float,
        default=0.03,
        help="Expected fraction of anomalies in training data for IsolationForest.",
    )
    parser.add_argument(
        "--random-state",
        type=int,
        default=42,
        help="Random seed for reproducibility.",
    )
    parser.add_argument(
        "--sort-ascending",
        action="store_true",
        help="Sort timestamps ascending before splitting and scoring. Default behavior is ascending.",
    )

    args = parser.parse_args()

    feature_cols = [c.strip() for c in args.feature_cols.split(",") if c.strip()]
    if not feature_cols:
        parser.error("At least one feature column must be provided with --feature-cols.")

    if not 0.0 < args.train_fraction < 1.0:
        parser.error("--train-fraction must be between 0 and 1.")

    if not 0.0 < args.contamination < 0.5:
        parser.error("--contamination must be greater than 0 and less than 0.5.")

    return Config(
        input_path=Path(args.input),
        output_path=Path(args.output),
        timestamp_col=args.timestamp_col,
        feature_cols=feature_cols,
        label_col=args.label_col,
        train_fraction=args.train_fraction,
        contamination=args.contamination,
        random_state=args.random_state,
        sort_ascending=True,
    )


def load_data(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise FileNotFoundError(f"Input file not found: {path}")
    if path.suffix.lower() != ".csv":
        raise ValueError("Input file must be a CSV.")

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


def validate_columns(df: pd.DataFrame, timestamp_col: str, feature_cols: List[str], label_col: Optional[str]) -> None:
    required = [timestamp_col] + feature_cols
    if label_col:
        required.append(label_col)

    missing = [c for c in required if c not in df.columns]
    if missing:
        raise ValueError(f"Missing required columns: {', '.join(missing)}")

    for col in feature_cols:
        if not pd.api.types.is_numeric_dtype(df[col]):
            # Allow coercion later, but warn the user via exception if the column is clearly non-numeric.
            try:
                pd.to_numeric(df[col].dropna().head(5))
            except Exception as exc:
                raise ValueError(f"Feature column '{col}' must be numeric or coercible to numeric.") from exc


def prepare_frame(df: pd.DataFrame, timestamp_col: str, feature_cols: List[str]) -> pd.DataFrame:
    out = df.copy()
    out[timestamp_col] = pd.to_datetime(out[timestamp_col], errors="coerce", utc=True)
    if out[timestamp_col].isna().all():
        raise ValueError(f"Timestamp column '{timestamp_col}' could not be parsed.")

    out = out.sort_values(timestamp_col, ascending=True).reset_index(drop=True)

    for col in feature_cols:
        out[col] = pd.to_numeric(out[col], errors="coerce")

    return out


def split_training_data(
    df: pd.DataFrame,
    feature_cols: List[str],
    label_col: Optional[str],
    train_fraction: float,
) -> Tuple[pd.DataFrame, pd.DataFrame]:
    if label_col:
        normal_mask = df[label_col].astype(str).str.lower().isin({"0", "normal", "benign", "false", "no"})
        train_df = df.loc[normal_mask].copy()
        if train_df.empty:
            raise ValueError(
                "No normal/benign rows found for training based on the label column. "
                "Use labels like 0, normal, benign, false, or no for baseline rows."
            )
        score_df = df.copy()
        return train_df, score_df

    split_idx = max(1, int(len(df) * train_fraction))
    train_df = df.iloc[:split_idx].copy()
    score_df = df.copy()
    return train_df, score_df


def build_model(contamination: float, random_state: int) -> Pipeline:
    return Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler()),
            (
                "model",
                IsolationForest(
                    n_estimators=200,
                    contamination=contamination,
                    random_state=random_state,
                    n_jobs=-1,
                ),
            ),
        ]
    )


def score_anomalies(
    model: Pipeline,
    train_df: pd.DataFrame,
    score_df: pd.DataFrame,
    feature_cols: List[str],
) -> pd.DataFrame:
    train_x = train_df[feature_cols]
    score_x = score_df[feature_cols]

    model.fit(train_x)

    # IsolationForest: higher score_samples is more normal; lower is more anomalous
    raw_scores = model.named_steps["model"].score_samples(
        model.named_steps["scaler"].transform(
            model.named_steps["imputer"].transform(score_x)
        )
    )
    predictions = model.predict(score_x)

    result = score_df.copy()
    result["anomaly_score"] = raw_scores
    result["anomaly_flag"] = np.where(predictions == -1, 1, 0)
    result["anomaly_label"] = np.where(result["anomaly_flag"] == 1, "anomalous", "normal")

    return result


def summarize_results(df: pd.DataFrame) -> None:
    total = len(df)
    anomalies = int(df["anomaly_flag"].sum())
    print(f"Processed rows: {total}")
    print(f"Flagged anomalies: {anomalies}")
    print(f"Anomaly rate: {anomalies / total:.2%}" if total else "Anomaly rate: n/a")


def main() -> int:
    try:
        config = parse_args()
        df = load_data(config.input_path)
        validate_columns(df, config.timestamp_col, config.feature_cols, config.label_col)
        prepared = prepare_frame(df, config.timestamp_col, config.feature_cols)
        train_df, score_df = split_training_data(
            prepared,
            config.feature_cols,
            config.label_col,
            config.train_fraction,
        )

        model = build_model(config.contamination, config.random_state)
        scored = score_anomalies(model, train_df, score_df, config.feature_cols)

        # Keep output operationally useful and easy to triage.
        ordered_columns = list(dict.fromkeys(
            [config.timestamp_col]
            + ([config.label_col] if config.label_col else [])
            + config.feature_cols
            + ["anomaly_score", "anomaly_flag", "anomaly_label"]
        ))
        remaining = [c for c in scored.columns if c not in ordered_columns]
        scored = scored[ordered_columns + remaining]

        config.output_path.parent.mkdir(parents=True, exist_ok=True)
        scored.to_csv(config.output_path, index=False)

        summarize_results(scored)
        print(f"Saved scored output to: {config.output_path}")
        return 0

    except Exception as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1


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