#!/usr/bin/env python3
"""Generate a fraud model training script: point-in-time features, out-of-time split, LightGBM + anomaly score.

The script it writes:
  - sorts transactions by time and builds per-entity velocity features with pandas rolling windows that
    exclude the current transaction (closed="left"): count and amount over --windows, time since the
    previous transaction, amount relative to the entity's past mean, first-time category/country flags
  - splits OUT OF TIME: train on the earliest part, leave a --gap-days gap (labels such as chargebacks mature
    weeks after the transaction; the most recent training labels are incomplete), test on the latest part
  - adds an IsolationForest anomaly score fitted on the training period only (unsupervised signal for new
    fraud patterns)
  - trains LightGBM (no resampling; scale_pos_weight optional) with early stopping on average precision of
    a time-ordered validation tail of the training period
  - writes test_scored.csv (label, score, amount, timestamp, id) for fraud_eval.py and prints test ROC AUC,
    average precision and gain importance
Needs pandas, numpy, scikit-learn, lightgbm, joblib.

Usage:
    python gen_fraud_model_script.py --data transactions.csv --entity card_id --label-col is_fraud -o fraud_model.py
    python gen_fraud_model_script.py --data tx.csv --entity account_id --categorical merchant_category country \\
        --test-frac 0.3 --gap-days 14 --windows 1h 24h 7d 30d

Exit codes: 0 written, 2 usage error.
"""

import argparse
import os
import sys

TEMPLATE = '''"""Fraud model for {data!r}. Generated by gen_fraud_model_script.py.

Run:  python {script}
Then: python fraud_eval.py test_scored.csv --label-col {label} --score-col score --amount-col {amount} --time-col {time}
"""
import joblib
import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
from sklearn.metrics import average_precision_score, roc_auc_score

DATA, ENTITY, TIME, AMOUNT, LABEL, ID = {data!r}, {entity!r}, {time!r}, {amount!r}, {label!r}, {id_col!r}
CATEGORICAL, WINDOWS, TEST_FRAC, GAP_DAYS, POS_WEIGHT, SEED = {categorical!r}, {windows!r}, {test_frac}, {gap_days}, {pos_weight}, 0

df = pd.read_csv(DATA, parse_dates=[TIME]).sort_values(TIME, kind="stable").reset_index(drop=True)
df["_row"] = np.arange(len(df))

# ---- point-in-time features: every window excludes the current transaction (closed="left")
g = df.set_index(TIME).groupby(ENTITY)[AMOUNT]
for w in WINDOWS:
    roll = g.rolling(w, closed="left").agg(["count", "sum"]).reset_index()
    roll["_row"] = df.sort_values([ENTITY, TIME], kind="stable")["_row"].to_numpy()
    roll = roll.set_index("_row").sort_index()
    df[f"cnt_{{w}}"] = roll["count"].fillna(0).to_numpy()
    df[f"sum_{{w}}"] = roll["sum"].fillna(0).to_numpy()
prev_t = df.groupby(ENTITY)[TIME].shift(1)
df["secs_since_prev"] = (df[TIME] - prev_t).dt.total_seconds()
past_mean = df.groupby(ENTITY)[AMOUNT].transform(lambda s: s.shift(1).expanding().mean())
df["amt_vs_mean"] = df[AMOUNT] / past_mean
df["n_prev"] = df.groupby(ENTITY).cumcount()
for c in CATEGORICAL:
    first = ~df.duplicated([ENTITY, c])
    df[f"new_{{c}}"] = first.astype(int)
    df[c] = df[c].astype("category")
features = ([AMOUNT] + [f"cnt_{{w}}" for w in WINDOWS] + [f"sum_{{w}}" for w in WINDOWS]
            + ["secs_since_prev", "amt_vs_mean", "n_prev"] + [f"new_{{c}}" for c in CATEGORICAL] + CATEGORICAL)
df["hour"] = df[TIME].dt.hour
features.append("hour")

# ---- out-of-time split with a label-maturity gap
t_test = df[TIME].quantile(1 - TEST_FRAC)
t_train_end = t_test - pd.to_timedelta(GAP_DAYS, unit="D")
train = df[df[TIME] < t_train_end].copy()
test = df[df[TIME] >= t_test].copy()
print(f"train {{len(train)}} rows to {{t_train_end:%Y-%m-%d}} ({{train[LABEL].mean():.2%}} fraud), gap {{GAP_DAYS}} days, "
      f"test {{len(test)}} rows from {{t_test:%Y-%m-%d}} ({{test[LABEL].mean():.2%}} fraud)")
if train[LABEL].sum() < 10 or test[LABEL].sum() < 5:
    print("warning: very few frauds in train or test; metrics will be noisy")

# ---- unsupervised anomaly score (fit on the training period only)
num_feats = [f for f in features if f not in CATEGORICAL]
iso = IsolationForest(n_estimators=300, random_state=SEED).fit(train[num_feats].fillna(-1))
for part in (train, test):
    part.loc[:, "iso_score"] = -iso.score_samples(part[num_feats].fillna(-1))
features.append("iso_score")

# ---- LightGBM with a time-ordered validation tail for early stopping
cut = int(len(train) * 0.8)
tr, va = train.iloc[:cut], train.iloc[cut:]
model = lgb.LGBMClassifier(n_estimators=3000, learning_rate=0.03, num_leaves=15, min_child_samples=20,
                           subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0,
                           scale_pos_weight=POS_WEIGHT, random_state=SEED, verbose=-1)
model.fit(tr[features], tr[LABEL], eval_set=[(va[features], va[LABEL])], eval_metric="average_precision",
          callbacks=[lgb.early_stopping(200, verbose=False)])
best = model.best_iteration_ or 200
final = lgb.LGBMClassifier(**{{**model.get_params(), "n_estimators": max(50, int(best * 1.1))}})
final.fit(train[features], train[LABEL])
score = final.predict_proba(test[features])[:, 1]
print(f"test ROC AUC {{roc_auc_score(test[LABEL], score):.4f}}   average precision {{average_precision_score(test[LABEL], score):.4f}} "
      f"(random {{test[LABEL].mean():.4f}}); IsolationForest alone AP {{average_precision_score(test[LABEL], test['iso_score']):.4f}}")
imp = dict(zip(features, final.booster_.feature_importance(importance_type="gain")))
tot = sum(imp.values()) or 1.0
print("gain importance: " + ", ".join(f"{{k}} {{v / tot:.0%}}" for k, v in sorted(imp.items(), key=lambda kv: -kv[1])[:8]))
out = test[[c for c in [ID, ENTITY, TIME, AMOUNT, LABEL] if c]].copy()
out["score"] = score
out.to_csv("test_scored.csv", index=False)
joblib.dump({{"model": final, "iso": iso, "features": features}}, "fraud_model.joblib")
print("wrote test_scored.csv and fraud_model.joblib")
'''


def main():
    ap = argparse.ArgumentParser(description="Generate a fraud model training script.", epilog=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--data", required=True)
    ap.add_argument("--entity", required=True)
    ap.add_argument("--time-col", default="timestamp")
    ap.add_argument("--amount-col", default="amount")
    ap.add_argument("--label-col", default="is_fraud")
    ap.add_argument("--id-col", default="txn_id")
    ap.add_argument("--categorical", nargs="*", default=[])
    ap.add_argument("--windows", nargs="+", default=["1h", "24h", "7d"])
    ap.add_argument("--test-frac", type=float, default=0.3)
    ap.add_argument("--gap-days", type=float, default=7)
    ap.add_argument("--pos-weight", type=float, default=1.0, help="LightGBM scale_pos_weight (1 = none)")
    ap.add_argument("-o", "--output")
    a = ap.parse_args()
    if not 0.1 <= a.test_frac <= 0.5 or a.gap_days < 0:
        print("error: --test-frac must be in [0.1, 0.5] and --gap-days >= 0", file=sys.stderr)
        return 2
    text = TEMPLATE.format(data=a.data, entity=a.entity, time=a.time_col, amount=a.amount_col, label=a.label_col,
                           id_col=a.id_col, categorical=a.categorical, windows=a.windows, test_frac=a.test_frac,
                           gap_days=a.gap_days, pos_weight=a.pos_weight,
                           script=os.path.basename(a.output or "fraud_model.py"))
    if a.output:
        with open(a.output, "w", encoding="utf-8") as f:
            f.write(text)
        print(f"wrote {a.output}: entity {a.entity}, windows {' '.join(a.windows)}, out-of-time test {a.test_frac:.0%} "
              f"after a {a.gap_days:g}-day gap", file=sys.stderr)
    else:
        sys.stdout.write(text)
    return 0


if __name__ == "__main__":
    sys.exit(main())
