#!/usr/bin/env python3
"""PA2b — trọng số ensemble tầng 2 PHỤ THUỘC HẠN DỰ BÁO.

Phát hiện từ PA2 (developing.txt 2026-08-31): ensemble 6 họ trung bình đều ở mọi
cửa sổ, nhưng từ W3 trở đi MỘT hồi quy logistic L2 đã ngang hoặc hơn cả ensemble
— các họ cây tụt nhanh theo hạn và kéo trung bình xuống.

Ở đây lưu lại vector xác suất của TỪNG HỌ rồi thử các tổ hợp định trước.

Kỷ luật phương pháp:
  * CHỌN tổ hợp bằng AUC NGOÀI MẪU trên tập HUẤN LUYỆN (khớp lại theo 4 fold),
    rồi mới XÁC NHẬN trên 4 năm kiểm định.  Chọn theo điểm kiểm định là chọn trên
    tập test — đúng cái bẫy đã mắc một lần trong dự án này.
  * Chấm bằng CHỈ SỐ NGHIỆP VỤ, không chỉ AUC: ngưỡng tầng 2 riêng từng ô suy từ
    xác suất ngoài mẫu đúng như operational_train.py, rồi áp lên tập kiểm định.
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import time

import numpy as np
from joblib import Parallel, delayed, load

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ehd_common as C
import ehd_metrics as M
import ai_data as D
from exp3_multi import apply_spread
from phase1_arch import WINDOWS, NFOLD, build_window, zoo_s2, p_of
from operational_train import R_BY_WINDOW

PACK = os.path.join(C.OUT, "operational")
RES = os.path.join(C.OUT, "AI_Results", "pa2b")

# tổ hợp định trước — KHÔNG được thêm sau khi đã nhìn kết quả kiểm định
BLENDS = {
    "all6":      ["LogReg_L2", "LogReg_EN", "LGBM_clf", "LGBM_clf_slow",
                  "HistGB_clf", "ANN_16"],
    "logreg":    ["LogReg_L2"],
    "logreg2":   ["LogReg_L2", "LogReg_EN"],
    "trees3":    ["LGBM_clf", "LGBM_clf_slow", "HistGB_clf"],
    "lr_lgbm":   ["LogReg_L2", "LGBM_clf"],
    "lr2_trees": ["LogReg_L2", "LogReg_EN", "LGBM_clf", "LGBM_clf_slow",
                  "HistGB_clf"],
}


def fit_one(nm, mk, Xtr, ytr, Xte):
    if mk == "ann":
        import exp3_offset as OFF
        m = OFF.OffsetNet(hidden=(16,), act="relu", epochs=30, batch=2048, seed=0)
        m.fit(Xtr, ytr, np.zeros(Xtr.shape[0]))
        return nm, np.clip(m.predict_proba(Xte, np.zeros(Xte.shape[0])), 0, 1)
    m = mk()
    m.fit(Xtr, ytr)
    return nm, np.clip(m.predict_proba(Xte)[:, 1], 0, 1)


def gate(pack, meta, A, P):
    zq = pack["zq"]
    v = np.zeros((len(pack["stage1"]), A.shape[0]), dtype="i1")
    for i, (m, e, sd) in enumerate(pack["stage1"]):
        mu = np.asarray(m.predict(A)).ravel()
        sg = apply_spread(mu, e, sd)
        v[i] = ((mu[:, None] + sg[:, None] * zq[None, :]) >= 0.0).sum(axis=1)
    pg = p_of(v)
    g1 = np.array([meta["gate_p1"][str(int(p))] for p in P])
    return pg >= g1


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--window", required=True, choices=list(WINDOWS))
    ap.add_argument("--fset", default="F3")
    ap.add_argument("--jobs", type=int, default=20)
    args = ap.parse_args()
    os.makedirs(RES, exist_ok=True)
    t0 = time.time()

    store = D.Store()
    pts = np.arange(store.grid.reg.size)
    lts = np.concatenate([C.WEEKS[i][1] for i in WINDOWS[args.window]])

    Atr, _, Ctr, Ytr_yr, Ptr, _ = build_window(store, pts, C.TRAIN_YEARS,
                                               args.fset, lts)
    Ate, _, Cte, _, Pte, _ = build_window(store, pts, C.VALID_YEARS,
                                          args.fset, lts)
    ok = np.isfinite(Atr).all(1) & np.isfinite(Ctr)
    Atr, Ctr, Ytr_yr, Ptr = Atr[ok], Ctr[ok], Ytr_yr[ok], Ptr[ok]
    ok = np.isfinite(Ate).all(1) & np.isfinite(Cte)
    Ate, Cte, Pte = Ate[ok], Cte[ok], Pte[ok]
    ytr, yte = (Ctr >= 1).astype(int), (Cte >= 1).astype(int)

    pack = load(os.path.join(PACK, "model_%s.joblib" % args.window))
    with open(os.path.join(PACK, "meta_%s.json" % args.window)) as fh:
        meta = json.load(fh)
    ktr, kte = gate(pack, meta, Atr, Ptr), gate(pack, meta, Ate, Pte)
    print("[pa2b] cổng: train %d/%d, valid %d/%d, %.0f phút"
          % (ktr.sum(), ktr.size, kte.sum(), kte.size, (time.time()-t0)/60),
          flush=True)

    Xtr, Ttr = Atr[ktr], ytr[ktr]
    Xte = Ate[kte]
    z2 = zoo_s2(Xtr.shape[0])
    par = Parallel(n_jobs=args.jobs)

    # ---- khớp đầy đủ -> xác suất trên tập kiểm định
    pv = dict(par(delayed(fit_one)(nm, mk, Xtr, Ttr, Xte) for nm, mk in z2))
    print("[pa2b] khớp đầy đủ xong, %.0f phút" % ((time.time()-t0)/60), flush=True)

    # ---- ngoài mẫu trên tập huấn luyện (4 fold theo NĂM) -> để CHỌN và lấy ngưỡng
    yr_tr = Ytr_yr[ktr]
    folds = [f for f in np.array_split(
        np.random.default_rng(0).permutation(np.unique(yr_tr)), NFOLD) if f.size]
    po = {nm: np.full(Xtr.shape[0], np.nan) for nm, _ in z2}
    for f in folds:
        m = np.isin(yr_tr, f)
        if m.all() or not m.any():
            continue
        for nm, p in par(delayed(fit_one)(nm, mk, Xtr[~m], Ttr[~m], Xtr[m])
                         for nm, mk in z2):
            po[nm][m] = p
    print("[pa2b] ngoài mẫu xong, %.0f phút" % ((time.time()-t0)/60), flush=True)

    # ---- chấm từng tổ hợp
    r = R_BY_WINDOW[args.window]
    res = {"window": args.window, "r": r, "n_valid": int(yte.size),
           "base_rate": float(yte.mean()), "blends": {}}
    ptr_pts, pte_pts = Ptr[ktr], Pte[kte]
    for bn, fam in BLENDS.items():
        so = np.mean([po[f] for f in fam], axis=0)
        sv = np.mean([pv[f] for f in fam], axis=0)
        good = np.isfinite(so)
        auc_oof = float(M.auc_score(so[good], Ttr[good]))   # DÙNG ĐỂ CHỌN
        auc_val = float(M.auc_score(sv, yte[kte]))          # dùng để XÁC NHẬN
        # ngưỡng riêng từng ô, suy từ xác suất ngoài mẫu — y như vận hành
        th = {}
        for p in np.unique(ptr_pts):
            m = (ptr_pts == p) & good
            if m.sum() < 10:
                continue
            b = float(Ttr[m].mean())
            th[int(p)] = float(np.quantile(so[m], 1.0 - np.clip(r*b, 1e-4, .999)))
        dflt = float(np.median(list(th.values()))) if th else 0.5
        thv = np.array([th.get(int(p), dflt) for p in pte_pts])
        pred = np.zeros(yte.size, bool)
        pred[kte] = sv > thv
        g = M.scores(*M.contingency(pred, yte.astype(bool)))
        res["blends"][bn] = {"auc_oof": auc_oof, "auc_valid": auc_val,
                             **{k: float(g[k]) for k in
                                ("POD", "POFD", "FAR", "FBI", "CSI", "SEDI")}}
        print("   %-10s AUCoof %.4f | AUCval %.4f | POD %.3f FAR %.3f CSI %.3f "
              "SEDI %.3f" % (bn, auc_oof, auc_val, g["POD"], g["FAR"],
                             g["CSI"], g["SEDI"]), flush=True)

    pick = max(res["blends"], key=lambda b: res["blends"][b]["auc_oof"])
    res["chosen_by_oof"] = pick
    print("[pa2b] %s -> CHỌN theo AUC ngoài mẫu: %s (AUCval %.4f, CSI %.3f)"
          % (args.window, pick, res["blends"][pick]["auc_valid"],
             res["blends"][pick]["CSI"]), flush=True)
    with open(os.path.join(RES, "pa2b_%s.json" % args.window), "w") as fh:
        json.dump(res, fh, indent=1)
    print("[pa2b] xong %.0f phút" % ((time.time()-t0)/60), flush=True)


if __name__ == "__main__":
    main()
