# analysis_crossre/scripts/phase3_validate.py
"""Phase 3: closed-loop validation using cross-Re SINDy control law.

Usage::

    conda run -n pycuda_3_10 python phase3_validate.py \\
        --device 2 --out-dir output/analysis_crossre/sindy_val

    conda run -n pycuda_3_10 python phase3_validate.py \\
        --validate-re 35,70,150 --device 2

    conda run -n pycuda_3_10 python phase3_validate.py \\
        --baseline-only --validate-re 35 --device 2
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import time
from collections import deque

import numpy as np

_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _PROJ not in sys.path:
    sys.path.insert(0, _PROJ)
from LegacyCelerisLab import FlowField  # noqa: E402

from utils import (
    nu_from_re,
    load_legacy_configs,
    build_karman_cloak_env,
    add_pinball,
    build_observation,
    scale_action,
    action_to_physical,
    compute_dimensionless,
    compute_v3_symbols,
    save_vorticity_png,
    vorticity_from_ddf,
    compute_similarity,
)
from cfg import (
    CONFIG_DIR,
    OUTPUT_DIR,
    MODEL_DIR,
    SAMPLE_INTERVAL,
    FIFO_LEN,
    CONV_LEN,
    S_DIM,
    A_DIM,
    ACTION_SCALE,
    ACTION_BIAS,
    U0,
    RE_CASES_TRAIN,
    RE_CASES_VALIDATION,
    RE_LABEL_MAP,
)

DATA_TYPE = np.float32


def load_cross_re_coef(sindy_results_path: str, threshold: float) -> dict:
    """Load v3 cross-Re coefficients.

    Returns dict ``{cylinder_name: {"coef": np.ndarray, "feat_names": list, "has_bias": bool}}``
    """
    with open(sindy_results_path) as f:
        data = json.load(f)

    cross = data["cross_re"]
    coefs = {}
    for ch_entry in cross["channels"]:
        name = ch_entry["cylinder"]
        feat_names = ch_entry["feature_names"]
        coef_full = np.array(ch_entry["best_coef"], dtype=np.float64)
        has_bias = ch_entry["has_bias"]

        scale = np.max(np.abs(coef_full))
        if scale > 0 and threshold > 0:
            mask = np.abs(coef_full) / scale >= threshold
        else:
            mask = np.ones_like(coef_full, dtype=bool)
        coef = coef_full * mask

        nz = int(np.sum(mask))
        print(f"  {name}: total={len(coef_full)} nz={nz} threshold={threshold} "
              f"R2={ch_entry['best']['r2']:.4f}")

        coefs[name] = {"coef": coef, "feat_names": feat_names,
                       "has_bias": has_bias, "nz": nz, "r2": ch_entry["best"]["r2"]}
    return coefs


def predict_omega_v3(
    obs_slice: np.ndarray,
    actions_prev: np.ndarray,
    actions_prev2: np.ndarray,
    coefs: dict,
    mu: float,
    u0: float = 0.01,
) -> np.ndarray:
    """Predict physical omega using v3 dimensionless features.

    Front: no bias term.
    Bottom/Top: with bias term.
    All 3 independently (no exchange symmetry constraint).

    Parameters
    ----------
    obs_slice : (12,)  raw [sensor(6), force(6)] in lattice units
    actions_prev : (3,)  omega(t-1)
    actions_prev2 : (3,)  omega(t-2)
    """
    sensors = obs_slice[0:6].astype(np.float64).reshape(1, 6)
    forces = obs_slice[6:12].astype(np.float64).reshape(1, 6)
    a_prev = actions_prev.astype(np.float64).reshape(1, 3)
    a_prev2 = actions_prev2.astype(np.float64).reshape(1, 3)

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

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

    # Predict
    omega = np.zeros(3, dtype=np.float64)
    omega[0] = float(Theta_f[0] @ coefs["front"]["coef"])  # front (no bias)
    omega[1] = float(Theta_top[0] @ coefs["bottom"]["coef"])  # bottom
    omega[2] = float(Theta_top[0] @ coefs["top"]["coef"])  # top

    return omega


def run_sindy_controlled(
    re_code: int,
    coefs: dict,
    device_id: int,
    output_root: str,
    *,
    n_steps: int = 150,
) -> dict:
    """Run closed-loop validation with SINDy control law."""
    os.makedirs(output_root, exist_ok=True)

    nu = nu_from_re(re_code, u0=U0)
    mu = 2.0 / re_code  # 1 / Re_D
    label = RE_LABEL_MAP.get(re_code, f"Re{re_code}")
    print(f"\n{'='*60}")
    print(f"SINDy Validation: {label}   nu={nu:.6f}   mu={mu:.6f}")
    print(f"{'='*60}")

    # Build environment (same as Phase 1)
    cuda_cfg, field_cfg = load_legacy_configs(CONFIG_DIR)
    field_cfg = field_cfg._replace(viscosity=float(nu))

    # Phase 1: dist + sensors + target
    ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
    target_states, _ = build_karman_cloak_env(
        ff, u0=U0, l0=20.0, sample_interval=SAMPLE_INTERVAL,
        fifo_len=FIFO_LEN, data_type=DATA_TYPE,
    )

    # Phase 2: pinball + norm
    norm = add_pinball(
        ff, l0=20.0, u0=U0, sample_interval=SAMPLE_INTERVAL,
        fifo_len=FIFO_LEN, data_type=DATA_TYPE,
        action_bias=ACTION_BIAS,
    )
    np.savez(os.path.join(output_root, "target.npz"), target_states=target_states)

    # --- Uncontrolled rollout ---
    print("  uncontrolled rollout ...")
    ff.restore_ddf()
    ff.apply_ddf()
    sens_unc, forc_unc = [], []
    for _ in range(n_steps):
        ff.run(SAMPLE_INTERVAL, np.zeros(7, dtype=DATA_TYPE))
        obs_slice = ff.obs.copy()[2:14]
        sens_unc.append(obs_slice[0:6])
        forc_unc.append(obs_slice[6:12])
    np.savez(os.path.join(output_root, "uncontrolled.npz"),
             sensors=np.array(sens_unc, dtype=np.float32),
             forces=np.array(forc_unc, dtype=np.float32))

    # Uncontrolled vorticity
    omega_unc = vorticity_from_ddf(ff, u0=U0)
    save_vorticity_png(os.path.join(output_root, "vorticity_uncontrolled.png"),
                       omega_unc, title=f"{label} uncontrolled")

    # --- SINDy controlled rollout ---
    print(f"  SINDy controlled rollout ({n_steps} steps) ...")
    ff.restore_ddf()
    ff.apply_ddf()

    # Bias FIFO
    fifo = deque(maxlen=FIFO_LEN)
    bias_action = scale_action(
        np.zeros(3, dtype=np.float32),
        scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0, n_total_bodies=7,
    )
    for _ in range(FIFO_LEN):
        ff.run(SAMPLE_INTERVAL, bias_action)
        fifo.append(ff.obs.copy()[2:14])

    sens_sc, forc_sc, omega_sc = [], [], []
    omega_bias = action_to_physical(
        np.zeros((1, 3), dtype=np.float32),
        scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0,
    ).flatten()
    actions_prev = omega_bias.copy()
    actions_prev2 = omega_bias.copy()

    for step in range(n_steps):
        obs_slice = fifo[-1] if len(fifo) > 0 else np.zeros(12, dtype=np.float32)

        omega_pred = predict_omega_v3(obs_slice, actions_prev, actions_prev2, coefs, mu, u0=U0)
        omega_sc.append(omega_pred.copy())

        # Convert action to legacy array and apply
        norm_action = (omega_pred / U0 - np.array(ACTION_BIAS, dtype=np.float64)) / ACTION_SCALE
        norm_action = np.clip(norm_action, -1.0, 1.0).astype(np.float32)
        action_arr = scale_action(
            norm_action,
            scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0, n_total_bodies=7,
        )
        ff.run(SAMPLE_INTERVAL, action_arr)

        obs_slice_new = ff.obs.copy()[2:14]
        fifo.append(obs_slice_new)
        sens_sc.append(obs_slice_new[0:6])
        forc_sc.append(obs_slice_new[6:12])
        actions_prev = omega_pred

    sens_sc_arr = np.array(sens_sc, dtype=np.float32)
    forc_sc_arr = np.array(forc_sc, dtype=np.float32)
    omega_sc_arr = np.array(omega_sc, dtype=np.float32)
    np.savez(os.path.join(output_root, "sindy_controlled.npz"),
             sensors=sens_sc_arr, forces=forc_sc_arr,
             omegas=omega_sc_arr)

    # Vorticity
    omega_vort = vorticity_from_ddf(ff, u0=U0)
    save_vorticity_png(os.path.join(output_root, "vorticity_sindy_controlled.png"),
                       omega_vort, title=f"{label} SINDy-controlled")

    # Similarity
    sim = compute_similarity(target_states, sens_sc_arr, CONV_LEN)
    print(f"  SINDy similarity: {sim:.4f}")

    del ff
    result = {"re_code": re_code, "mu": mu,
              "sindy_similarity": sim,
              "n_steps": n_steps}
    with open(os.path.join(output_root, "result.json"), "w") as f:
        json.dump(result, f, indent=2)
    return result


def main():
    ap = argparse.ArgumentParser(description="Phase 3: cross-Re SINDy validation")
    ap.add_argument("--sindy-results", type=str,
                    default=os.path.join(OUTPUT_DIR, "sindy", "sindy_results_v3.json"),
                    help="Path to Phase 2 SINDy results JSON (v3 dimensionless)")
    ap.add_argument("--validate-re", type=str, default="35,70,150",
                    help="Comma-separated validation Re codes")
    ap.add_argument("--device", type=int, default=0, help="GPU device ID")
    ap.add_argument("--steps", type=int, default=150,
                    help="Number of inference steps")
    ap.add_argument("--threshold", type=float, default=0.002,
                    help="SINDy sparsity threshold (default: 0.002)")
    ap.add_argument("--out-dir", type=str,
                    default=os.path.join(OUTPUT_DIR, "sindy_val"),
                    help="Output root for validation results")
    args = ap.parse_args()

    validate_re = [int(r) for r in args.validate_re.split(",")]
    os.makedirs(args.out_dir, exist_ok=True)

    # Load cross-Re coefficients
    print(f"\nLoading cross-Re coefficients from {args.sindy_results}")
    coefs = load_cross_re_coef(args.sindy_results, args.threshold)
    for name in ["front", "bottom", "top"]:
        print(f"  {name}: nz={coefs[name]['nz']}, R2={coefs[name]['r2']:.4f}, "
              f"threshold={args.threshold}")

    t_start = time.time()

    # Run for each validation Re
    for re_code in validate_re:
        out_dir = os.path.join(args.out_dir, f"re{re_code}")
        result = run_sindy_controlled(
            re_code, coefs, args.device, out_dir, n_steps=args.steps,
        )
        print(f"  Done: Re{re_code}  -> {out_dir}")

    elapsed = time.time() - t_start
    print(f"\nTotal time: {elapsed:.1f}s")
    return 0


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