# analysis_crossre/scripts/diagnose_equivariance.py
"""Phase A2-A3: diagnose PPO control-law equivariance under G operator.

Usage::

    conda run -n pycuda_3_10 python diagnose_equivariance.py --re 100 --device 0

    conda run -n pycuda_3_10 python diagnose_equivariance.py --re all --device 0

Output per Re: ``output/analysis_crossre/diagnostic/equivariance_re{re}.json``
"""
from __future__ import annotations

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

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 LegacyCelerisLab import utils as legacy_utils  # noqa: E402

from utils import (
    action_to_physical,
    compute_dimensionless,
    apply_G_x,
    apply_G_alpha,
    load_ppo_model,
    nu_from_re,
    load_legacy_configs,
    build_karman_cloak_env,
    add_pinball,
    build_observation,
    scale_action,
)
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_LABEL_MAP,
)

DATA_TYPE = np.float32


def diagnose_one_re(re_code: int, ppo_device: int, cfd_device: int, output_root: str) -> dict:
    """Run equivariance diagnosis for one Re case."""
    os.makedirs(output_root, exist_ok=True)

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

    # Build full environment (dist + sensors + pinball)
    cuda_cfg, field_cfg = load_legacy_configs(CONFIG_DIR)
    field_cfg = field_cfg._replace(viscosity=float(nu))
    ff = FlowField(field_cfg, cuda_cfg, device_id=cfd_device)

    # Stabilize and get to controlled state
    target_states, _ = build_karman_cloak_env(
        ff, u0=U0, l0=20.0, sample_interval=SAMPLE_INTERVAL,
        fifo_len=FIFO_LEN, data_type=DATA_TYPE,
    )
    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,
    )

    # Load PPO model
    model_path = None
    for rc, mn in RE_CASES_TRAIN:
        if rc == re_code:
            model_path = os.path.join(MODEL_DIR, "old", f"{mn}.zip")
            break
    if model_path is None or not os.path.isfile(model_path):
        return {"re_code": re_code, "error": f"No model for Re{re_code}"}

    model = load_ppo_model(model_path, device=f"cuda:{ppo_device}")
    model.set_random_seed(0)

    # Collect rollout data with PPO
    ff.restore_ddf()
    ff.apply_ddf()

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

    n_steps = 150
    obs_hist = np.zeros((n_steps, 12), dtype=np.float64)
    alpha_hist = np.zeros((n_steps, 3), dtype=np.float64)
    obs = np.zeros(S_DIM, dtype=np.float32)

    for step in range(n_steps):
        action, _ = model.predict(obs, deterministic=True)
        action = action.astype(np.float32).flatten()

        # Convert to physical
        action_arr = scale_action(
            action, scale=ACTION_SCALE, bias=ACTION_BIAS,
            u0=U0, n_total_bodies=7,
        )
        ff.context.push()
        try:
            ff.run(SAMPLE_INTERVAL, action_arr)
        finally:
            ff.context.pop()

        obs_slice = ff.obs.copy()[2:14]
        fifo.append(obs_slice)
        alpha = action_to_physical(
            action.reshape(1, 3), scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0,
        ).flatten()

        obs_hist[step] = obs_slice
        alpha_hist[step] = alpha
        obs = build_observation(obs_slice, norm)

    del ff

    # ---- Equivariance diagnosis ----
    dim = compute_dimensionless(obs_hist[:, 0:6], obs_hist[:, 6:12], u0=U0, d=20.0)

    # Compute memory terms
    a_prev = np.zeros_like(alpha_hist)
    a_prev2 = np.zeros_like(alpha_hist)
    a_prev[1:] = alpha_hist[:-1]
    a_prev2[2:] = alpha_hist[:-2]

    # Diagnostic 1: front bias check (mean of alpha_F)
    mean_alpha_F = float(np.mean(alpha_hist[:, 0]))
    std_alpha_F = float(np.std(alpha_hist[:, 0]))
    front_bias_score = abs(mean_alpha_F) / (std_alpha_F + 1e-12)

    # Diagnostic 2: check front equivariance
    # For each point, compute PPO(Gx) by feeding G-transformed obs through model
    eq_front_errors = []
    eq_exchange_b_errors = []
    eq_exchange_t_errors = []
    eq_front_noise_floor = []

    for t in range(2, n_steps):
        # Get original obs and Gx
        Gx = apply_G_x(
            dim["u_hat_B"][t:t+1], dim["u_hat_C"][t:t+1], dim["u_hat_T"][t:t+1],
            dim["v_hat_B"][t:t+1], dim["v_hat_C"][t:t+1], dim["v_hat_T"][t:t+1],
            dim["Cd_F"][t:t+1], dim["Cd_T"][t:t+1], dim["Cd_B"][t:t+1],
            dim["Cl_F"][t:t+1], dim["Cl_T"][t:t+1], dim["Cl_B"][t:t+1],
            a_prev[t:t+1, 0], a_prev[t:t+1, 2], a_prev[t:t+1, 1],
            a_prev2[t:t+1, 0] - a_prev[t:t+1, 0],
            a_prev2[t:t+1, 2] - a_prev[t:t+1, 2],
            a_prev2[t:t+1, 1] - a_prev[t:t+1, 1],
        )

        # Build Gx observation for PPO: we need the normalized obs
        # The Gx in raw sensor/force space requires inverting the dimensionless transform
        # Actually easier: compute what PPO would predict for the G state
        # by transforming the raw obs and feeding it

        # Build raw obs corresponding to Gx
        raw_Gx = np.zeros(12, dtype=np.float64)
        # Sensors: reorder + sign flip
        # Original raw: [s0_ux, s0_uy, s1_ux, s1_uy, s2_ux, s2_uy] = top, center, bottom
        # G: bottom->top, center->center, top->bottom
        raw_Gx[0] = obs_hist[t, 4]   # s0_ux <- s2_ux (bottom -> top, streamwise no sign)
        raw_Gx[1] = -obs_hist[t, 5]  # s0_uy <- -s2_uy (bottom -> top, cross sign flip)
        raw_Gx[2] = obs_hist[t, 2]   # s1_ux maintains (center)
        raw_Gx[3] = -obs_hist[t, 3]  # s1_uy = -s1_uy (center cross sign flip)
        raw_Gx[4] = obs_hist[t, 0]   # s2_ux <- s0_ux (top -> bottom)
        raw_Gx[5] = -obs_hist[t, 1]  # s2_uy <- -s0_uy (top -> bottom, cross sign flip)
        # Forces: reorder + sign
        # ordering: [front_fx, front_fy, bottom_fx, bottom_fy, top_fx, top_fy]
        # G: front_fx -> front_fx (no sign), front_fy -> -front_fy
        #    bottom <-> top
        raw_Gx[6] = obs_hist[t, 6]    # front_fx unchanged
        raw_Gx[7] = -obs_hist[t, 7]   # front_fy sign flip
        raw_Gx[8] = obs_hist[t, 10]   # bottom_fx <- top_fx
        raw_Gx[9] = -obs_hist[t, 11]  # bottom_fy <- -top_fy
        raw_Gx[10] = obs_hist[t, 8]   # top_fx <- bottom_fx
        raw_Gx[11] = -obs_hist[t, 9]  # top_fy <- -bottom_fy

        # Build normalized PPO observation from Gx
        obs_Gx = build_observation(raw_Gx, norm)

        # Predict action for Gx
        action_Gx, _ = model.predict(obs_Gx, deterministic=True)
        action_Gx = action_Gx.astype(np.float32).flatten()
        alpha_Gx = action_to_physical(
            action_Gx.reshape(1, 3), scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0,
        ).flatten()

        # What equivariance says Gx should produce (with CORRECTED G)
        # G([aF, aT, aB]) = [-aF, -aB, -aT]
        alpha_Gx_expected = apply_G_alpha(alpha_hist[t])

        # Front error: PPO(Gx)[0] should == G(PPO(x))[0] = -aF(x)
        eq_front_errors.append(abs(float(alpha_Gx[0]) - float(alpha_Gx_expected[0])))

        # Rear error (CORRECTED): PPO(Gx)[1] should == G(PPO(x))[1] = -aT(x)
        # PPO(Gx)[2] should == G(PPO(x))[2] = -aB(x)
        # Previously this incorrectly checked alpha_B(x) == alpha_T(Gx)
        eq_exchange_b_errors.append(abs(float(alpha_Gx[1]) - float(alpha_Gx_expected[1])))
        eq_exchange_t_errors.append(abs(float(alpha_Gx[2]) - float(alpha_Gx_expected[2])))

        # Noise floor: difference between same-state replicate predictions
        # (we approximate by checking prediction consistency)
        action2, _ = model.predict(obs_Gx, deterministic=True)
        alpha_Gx2 = action_to_physical(
            action2.reshape(1, 3), scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0,
        ).flatten()
        eq_front_noise_floor.append(abs(float(alpha_Gx2[0]) - float(alpha_Gx[0])))

    eq_front_errors = np.array(eq_front_errors)
    eq_exchange_b = np.array(eq_exchange_b_errors)
    eq_exchange_t = np.array(eq_exchange_t_errors)
    eq_noise = np.array(eq_front_noise_floor)

    # Scale equivariance errors by action range for relative measure
    alpha_range = float(np.max(np.abs(alpha_hist[2:])))
    rel_front_err = float(np.mean(eq_front_errors) / (alpha_range + 1e-12))
    rel_exchange_b_err = float(np.mean(eq_exchange_b) / (alpha_range + 1e-12))
    rel_exchange_t_err = float(np.mean(eq_exchange_t) / (alpha_range + 1e-12))

    # Combined rear error (max of bottom and top)
    rel_exchange_err = max(rel_exchange_b_err, rel_exchange_t_err)

    # Diagnostic 3: cross-correlation between alpha_T and -alpha_B
    if len(alpha_hist) > 10:
        # After initial transient
        tail = n_steps // 2
        corr_TB = float(np.corrcoef(alpha_hist[tail:, 2], -alpha_hist[tail:, 1])[0, 1])
    else:
        corr_TB = float("nan")

    result = {
        "re_code": re_code,
        "mu": mu,
        "n_samples": n_steps,
        "alpha_range": alpha_range,
        "front_bias": {
            "mean_alpha_F": mean_alpha_F,
            "std_alpha_F": std_alpha_F,
            "bias_over_std": front_bias_score,
            "bias_significant": front_bias_score > 2.0,
        },
        "equivariance_front": {
            "mean_abs_error": float(np.mean(eq_front_errors)),
            "max_abs_error": float(np.max(eq_front_errors)),
            "relative_error": rel_front_err,
            "noise_floor": float(np.mean(eq_noise)),
            "signal_to_noise": float(np.mean(eq_front_errors) / (np.mean(eq_noise) + 1e-12)),
        },
        "equivariance_rear_bottom": {
            "mean_abs_error": float(np.mean(eq_exchange_b)),
            "max_abs_error": float(np.max(eq_exchange_b)),
            "relative_error": rel_exchange_b_err,
        },
        "equivariance_rear_top": {
            "mean_abs_error": float(np.mean(eq_exchange_t)),
            "max_abs_error": float(np.max(eq_exchange_t)),
            "relative_error": rel_exchange_t_err,
        },
        "top_bottom_correlation": {
            "corr_alphaT_vs_negAlphaB": corr_TB,
        },
        "equivariance_verdict": "PASS" if (rel_front_err < 0.20 and rel_exchange_err < 0.20) else "REVIEW",
    }

    print(f"  Front bias: mean_alpha_F={mean_alpha_F:.6f} |bias|/std={front_bias_score:.3f}")
    print(f"  Front equiv err: mean={np.mean(eq_front_errors):.6f} rel={rel_front_err:.3%}")
    print(f"  Rear-bot err:    mean={np.mean(eq_exchange_b):.6f} rel={rel_exchange_b_err:.3%}")
    print(f"  Rear-top err:    mean={np.mean(eq_exchange_t):.6f} rel={rel_exchange_t_err:.3%}")
    print(f"  T vs -B corr: {corr_TB:.4f}")
    print(f"  Verdict: {result['equivariance_verdict']}")

    with open(os.path.join(output_root, f"equivariance_re{re_code}.json"), "w") as f:
        json.dump(result, f, indent=2)
    print(f"  Saved to {output_root}/equivariance_re{re_code}.json")

    return result


def main():
    ap = argparse.ArgumentParser(description="Equivariance diagnosis for PPO cloak control")
    ap.add_argument("--re", type=str, default="all",
                    help='Re case: 50,100,200,400, or "all"')
    ap.add_argument("--device", type=int, default=0, help="GPU device for PPO model")
    ap.add_argument("--cfd-device", type=int, default=2, help="GPU device for CFD simulation")
    args = ap.parse_args()

    if args.re.lower() == "all":
        re_list = [rc for rc, _ in RE_CASES_TRAIN]
    else:
        re_list = [int(args.re)]

    # Store device args for use in diagnose_one_re
    device_id = args.device
    cfd_device = args.cfd_device

    diag_root = os.path.join(OUTPUT_DIR, "diagnostic")
    os.makedirs(diag_root, exist_ok=True)

    all_results = []
    for re_code in re_list:
        res = diagnose_one_re(re_code, device_id, cfd_device, diag_root)
        all_results.append(res)

    summary = {
        "summary": {
            "equivariance_verdicts": {r["re_code"]: r.get("equivariance_verdict", "ERROR")
                                      for r in all_results}
        },
        "details": all_results,
    }
    with open(os.path.join(diag_root, "equivariance_summary.json"), "w") as f:
        json.dump(summary, f, indent=2)
    print(f"\nSummary saved to {diag_root}/equivariance_summary.json")


if __name__ == "__main__":
    main()
