# analysis_crossre/scripts/phase2_control_fit.py
"""Phase 2 v3: dimensionless + front-no-bias + quality-weighted SINDy fitting.

Usage::

    conda run -n pycuda_3_10 python phase2_control_fit.py \\
        --cross-re --out-dir output/analysis_crossre/sindy

    conda run -n pycuda_3_10 python phase2_control_fit.py \\
        --leave-one-out --out-dir output/analysis_crossre/sindy
"""
from __future__ import annotations

import argparse
import json
import os
import sys
from typing import Dict, List, Tuple

import numpy as np

from utils import (
    action_to_physical,
    compute_dimensionless,
    compute_v3_symbols,
    fit_channel,
    print_control_law,
)
from cfg import (
    OUTPUT_DIR,
    RE_CASES_TRAIN,
    ACTION_SCALE,
    ACTION_BIAS,
    U0,
)

THRESHOLDS = [0.0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1]


def load_case_data(re_code: int) -> Tuple:
    """Load controlled NPZ for a single Re.

    Returns (sensors, forces, actions_phys, rewards, mu).
    """
    case_dir = os.path.join(OUTPUT_DIR, f"re{re_code}")
    npz_path = os.path.join(case_dir, "controlled.npz")
    if not os.path.isfile(npz_path):
        raise FileNotFoundError(f"Missing {npz_path}")

    data = np.load(npz_path)
    sensors = data["sensors"].astype(np.float64)
    forces = data["forces"].astype(np.float64)
    actions_norm = data["actions"].astype(np.float64)
    rewards = data.get("rewards", np.zeros(sensors.shape[0])).astype(np.float64)

    actions_phys = action_to_physical(
        actions_norm, scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0)
    mu = 2.0 / re_code  # 1 / Re_D
    return sensors, forces, actions_phys, rewards, mu


def compute_trajectory_weights(rewards: np.ndarray, late_window: int = 80) -> float:
    """Compute a single quality weight for this trajectory.

    Uses the mean reward over the last ``late_window`` steps.
    Maps to weight via quantile-based scheme.
    """
    n = len(rewards)
    if n < late_window:
        late_mean = float(np.mean(rewards))
    else:
        late_mean = float(np.mean(rewards[-late_window:]))

    # Map reward to weight via sigmoid-like scheme:
    # reward 0.0 -> weight 0.1, reward 0.3 -> 0.3, reward 0.5 -> 0.6, reward 0.7 -> 0.9
    weight = 1.0 / (1.0 + np.exp(-8.0 * (late_mean - 0.4)))
    return float(np.clip(weight, 0.05, 1.0))


def build_dataset_v3(
    re_code: int,
    include_mu: bool = True,
) -> Tuple:
    """Build v3 data: dimensionless features, front-no-bias, quality-weighted.

    Returns
    -------
    Theta_front : (T, nf_f) for front model (no bias)
    Theta_other : (T, nf_o) for top/bottom model (with bias)
    Y : (T, 3) physical omegas
    W : (T,) quality weight per sample
    names : feature names (without "bias")
    """
    sensors, forces, actions_phys, rewards, mu = load_case_data(re_code)

    # Dimensionless
    dim = compute_dimensionless(sensors, forces, u0=U0, d=20.0)

    # Memory terms
    a_prev = np.zeros_like(actions_phys)
    a_prev2 = np.zeros_like(actions_phys)
    a_prev[1:] = actions_phys[:-1]
    a_prev2[2:] = actions_phys[:-2]

    # Build v3 features
    Theta_f, Theta_top, names = compute_v3_symbols(
        dim, a_prev, a_prev2, mu=mu, include_mu=include_mu)

    # Quality weight per sample: inherit trajectory weight
    traj_weight = compute_trajectory_weights(rewards)
    W = np.full(Theta_f.shape[0], traj_weight, dtype=np.float64)

    # Remove warmup (need 2 steps of memory)
    Theta_f = Theta_f[2:]
    Theta_top = Theta_top[2:]
    Y = actions_phys[2:]
    W = W[2:]

    print(f"  Re{re_code}: {Theta_f.shape[0]} samples, {Theta_f.shape[1]} feats, "
          f"mu={mu:.6f}, traj_weight={traj_weight:.4f}")
    return Theta_f, Theta_top, Y, W, names, mu


def fit_channel_weighted(
    Theta: np.ndarray,
    y: np.ndarray,
    w: np.ndarray,
    thresholds: list,
    alpha: float = 1e-4,
    max_iter: int = 25,
) -> tuple:
    """Weighted STLSQ fit."""
    import pysindy as ps

    # Weighted normalisation
    std = np.sqrt(np.average((Theta - np.average(Theta, axis=0, weights=w)) ** 2,
                              axis=0, weights=w))
    std = np.where(std < 1e-8, 1.0, std)
    Theta_s = Theta / std

    best = None
    rows = []
    for th in thresholds:
        opt = ps.STLSQ(threshold=th, alpha=alpha, max_iter=max_iter)
        opt.fit(Theta_s, y, sample_weight=w)
        coef = np.asarray(opt.coef_, dtype=np.float64).flatten() / std
        y_pred = Theta @ coef
        # Weighted R2
        y_mean = np.average(y, weights=w)
        ssr = np.sum(w * (y - y_pred) ** 2)
        sst = np.sum(w * (y - y_mean) ** 2) + 1e-12
        r2 = 1.0 - ssr / sst
        mae = float(np.average(np.abs(y - y_pred), weights=w))
        nz = int(np.sum(np.abs(coef) > 1e-8))
        entry = {"threshold": float(th), "nz": nz, "r2": r2, "mae": mae, "coef": coef}
        rows.append(entry)
        if best is None or r2 > best["r2"]:
            best = entry
    return rows, best


def build_cross_re_v3(
    train_re_codes: List[int],
    include_mu: bool = True,
) -> Tuple:
    """Stack multiple Re datasets with v3 features.

    Front model uses Theta_front (no bias).
    Top/Bottom models use Theta_other (with bias).

    Returns three stacked datasets.
    """
    all_ThetaF, all_ThetaO, all_Y, all_W, all_re = [], [], [], [], []
    names = None

    for rc in train_re_codes:
        tf, to, y, w, fn, mu = build_dataset_v3(rc, include_mu=include_mu)
        all_ThetaF.append(tf)
        all_ThetaO.append(to)
        all_Y.append(y)
        all_W.append(w)
        all_re.append(np.full(tf.shape[0], rc, dtype=np.int64))
        if names is None:
            names = fn

    ThetaF = np.vstack(all_ThetaF)
    ThetaO = np.vstack(all_ThetaO)
    Y = np.vstack(all_Y)
    W = np.concatenate(all_W)
    Re = np.concatenate(all_re)

    print(f"\n  Cross-Re: {ThetaF.shape[0]} samples, "
          f"front={ThetaF.shape[1]} feats (no bias), "
          f"other={ThetaO.shape[1]} feats (w/ bias)")
    return ThetaF, ThetaO, Y, W, names, Re


def run_cross_re_fit(
    train_re: List[int],
    tag: str = "",
    include_mu: bool = True,
) -> dict:
    """Fit all 3 cylinders independently.

    Front: no bias.
    Top/Bottom: with bias.
    """
    ThetaF, ThetaO, Y, W, names, re_labels = build_cross_re_v3(train_re, include_mu)

    cylinders = [
        {"name": "front", "theta": ThetaF, "label": "front (no bias)"},
        {"name": "bottom", "theta": ThetaO, "label": "bottom (w/ bias)"},
        {"name": "top", "theta": ThetaO, "label": "top (w/ bias)"},
    ]

    channels = []
    for ci, cyl in enumerate(cylinders):
        print(f"\n  --- {tag} {cyl['label']} ---")
        rows, best = fit_channel_weighted(cyl["theta"], Y[:, ci], W, THRESHOLDS)
        coef = best["coef"]
        print_control_law(names, coef, channel_label=f"{cyl['name']}")
        print(f"         R2={best['r2']:.6f}  MAE={best['mae']:.6f}")
        channels.append({
            "cylinder": cyl["name"],
            "has_bias": cyl["name"] != "front",
            "n_features": cyl["theta"].shape[1],
            "best": {k: float(v) if isinstance(v, (np.floating, float)) else v
                     for k, v in best.items() if k != "coef"},
            "best_coef": [float(c) for c in coef],
            "grid": [{k: float(v) for k, v in row.items() if k != "coef"}
                     for row in rows],
            "feature_names": names,
        })

    # Per-Re breakdown
    print(f"\n  --- {tag} per-Re breakdown ---")
    breakdown = {}
    for re_code in set(re_labels.tolist()):
        mask = re_labels == re_code
        Yr, Wr = Y[mask], W[mask]
        ch_b = []
        for ci, cyl in enumerate(cylinders):
            th = cyl["theta"][mask]
            coef = np.array(channels[ci]["best_coef"], dtype=np.float64)
            y_pred = th @ coef
            y_t = Yr[:, ci]
            y_mean = np.average(y_t, weights=Wr)
            ssr = np.sum(Wr * (y_t - y_pred) ** 2)
            sst = np.sum(Wr * (y_t - y_mean) ** 2) + 1e-12
            r2 = 1.0 - ssr / sst
            mae = float(np.average(np.abs(y_t - y_pred), weights=Wr))
            ch_b.append({"cylinder": cyl["name"], "r2": float(r2), "mae": mae})
        breakdown[f"re{int(re_code)}"] = ch_b
        r2s = ", ".join([f"{b['cylinder']}={b['r2']:.4f}" for b in ch_b])
        print(f"    Re{int(re_code)}: {r2s}")

    return {
        "tag": tag,
        "train_re": train_re,
        "n_samples": int(ThetaF.shape[0]),
        "n_features_front": int(ThetaF.shape[1]),
        "n_features_other": int(ThetaO.shape[1]),
        "channels": channels,
        "per_re_breakdown": breakdown,
    }


def main():
    ap = argparse.ArgumentParser(description="Phase 2 v3: dimensionless + constrained fitting")
    ap.add_argument("--cross-re", action="store_true")
    ap.add_argument("--leave-one-out", action="store_true")
    ap.add_argument("--out-dir", type=str, default=os.path.join(OUTPUT_DIR, "sindy"))
    ap.add_argument("--train-re", type=str, default="50,100,200")
    ap.add_argument("--no-mu", action="store_true")
    args = ap.parse_args()

    if not (args.cross_re or args.leave_one_out):
        print("ERROR: specify --cross-re and/or --leave-one-out")
        return 1

    train_re = [int(r) for r in args.train_re.split(",")]
    include_mu = not args.no_mu
    os.makedirs(args.out_dir, exist_ok=True)

    results = {
        "metadata": {
            "method": "v3_dimensionless_front_nobias_weighted",
            "thresholds": THRESHOLDS,
            "include_mu": include_mu,
        }
    }

    if args.cross_re:
        print("\n" + "=" * 60)
        print("v3 Cross-Re unified (dimensionless + front no-bias + weighted)")
        print("=" * 60)
        results["cross_re"] = run_cross_re_fit(
            train_re, tag="v3-cross", include_mu=include_mu)

    if args.leave_one_out:
        print("\n" + "=" * 60)
        print("v3 Leave-one-out cross-validation")
        print("=" * 60)
        loo_results = {}
        for held_out in train_re:
            train_set = [r for r in train_re if r != held_out]
            print(f"\n--- LOO: train={train_set}, test={held_out} ---")
            loo = run_cross_re_fit(
                train_set, tag=f"v3-loo-{held_out}", include_mu=include_mu)
            loo_results[f"holdout_{held_out}"] = loo
        results["leave_one_out"] = loo_results

    out_path = os.path.join(args.out_dir, "sindy_results_v3.json")
    with open(out_path, "w") as f:
        json.dump(results, f, indent=2)
    print(f"\nSaved: {out_path}")


if __name__ == "__main__":
    main()
