# -*- coding: utf-8 -*-
#!/usr/bin/env python3
"""
Zernike per Subcase (relative to 20 deg) -> HTML + CSVs (HEADLESS) + dual-reference RMS (vs 20 deg and vs 90 deg)

- Auto-targets OP2:
    * default: m1_optimization_baseline_Isogrid_fem1_i_fem1_sim1_test2-solution_1.op2 in CWD
    * or: pass --op2 FULLPATH
- Auto-loads matching .dat/.bdf (same-basename preferred; else first found)
- Extracts subcases by label/ID (expects 90,20,40,60)
- Uses 20 deg as the reference for plots/pages and the *_Rel20 fields (vs 20 deg)
- Also computes RMS relative to 90 deg and exports them:
    * EXP:    RMS_<ang>_Global_Rel20, RMS_<ang>_Filtered_Rel20, RMS_<ang>_Global_Rel90, RMS_<ang>_Filtered_Rel90
    * LOG:    Rel_GlobalRMS_nm_<ang>_vs20, Rel_FilteredRMS_nm_<ang>_vs20, Rel_GlobalRMS_nm_<ang>_vs90, Rel_FilteredRMS_nm_<ang>_vs90
- Outputs:
    * Combined nodal CSV (abs 20 deg + relative others vs 20 deg)
    * One HTML for 20 deg (absolute) and one HTML per other subcase (relative to 20 deg),
      with each relative page's RMS table also showing the absolute RMS for that angle
    * One HTML for 90 deg (absolute) with manufacturing metrics (numeric only)
    * Zernike coeff CSVs (|coeff| per page; with labels and optional bar chart)
    * RMS summary CSV (ABS for all; REL20 for non-reference)
    * EXP file with 90/20/40/60 ABS; REL20 (vs 20 deg); and REL90 (vs 90 deg)
    * Appends a row to Results/RMS_log.csv (ABS & REL vs 20 deg & REL vs 90 deg)
- HTML auto-open controlled by OPEN_HTML flag

Requires: numpy pandas plotly pyNastran matplotlib
"""

import os, webbrowser, re, argparse, sys
import numpy as np
import pandas as pd
from math import factorial
from numpy.linalg import LinAlgError
from datetime import datetime

# Best-effort UTF-8 console (ignored if not supported)
try:
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
except Exception:
    pass

# Try messagebox for friendlier fatal popup if available; otherwise print-only
try:
    from tkinter import messagebox  # optional
except Exception:
    messagebox = None

# Plotly / mesh
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from matplotlib.tri import Triangulation

# Nastran IO
from pyNastran.op2.op2 import OP2
from pyNastran.bdf.bdf import BDF

# ============ MONKEY PATCH FOR UNKNOWN NASTRAN VERSIONS ============
import pyNastran.op2.op2_interface.version as pynastran_version

_original_parse = pynastran_version.parse_nastran_version

def patched_parse_nastran_version(data, version, encoding, log):
    try:
        return _original_parse(data, version, encoding, log)
    except RuntimeError as e:
        if 'unknown version' in str(e):
            version_str = str(e).split('=')[1].strip("')")
            log.warning(f'Unknown Nastran version {version_str}, attempting to parse anyway')
            return 'msc', version_str  # Assume MSC Nastran format
        raise

pynastran_version.parse_nastran_version = patched_parse_nastran_version
print("[INFO] Applied pyNastran version compatibility patch", flush=True)
# ===================================================================


# ---------------- Config ----------------
N_MODES = 50
AMP = 2.0           # visual scale for residual plot
PANCAKE = 10.0      # flattens Z range for viewing
PLOT_DOWNSAMPLE = 10000
FILTER_LOW_ORDERS = 4   # piston, tip, tilt, defocus
SHOW_ZERNIKE_BAR = True # adds a horizontal bar chart of |coeff| with labels

REQUIRED_SUBCASES = [90, 20, 40, 60]

# Labels
POLISH_LABEL = "90"     # polishing orientation (horizontal)
POLISH_TITLE = "90 deg" # for plot/table titles
REF_LABEL = "20"        # reference subcase label as string
REF_TITLE = "20 deg"    # for plot/table titles

# Displacement unit in OP2 -> nm scale for WFE = 2*Disp_Z
DISP_SRC_UNIT = "mm"            # "mm" or "m"
NM_PER_UNIT = 1e6 if DISP_SRC_UNIT == "mm" else 1e9  # 1 mm = 1e6 nm, 1 m = 1e9 nm

# Name of fixed OP2 (used when --op2 is not supplied)
OP2_FIXED_NAME = "assy_m1_assyfem1_sim1-solution_1.op2"

# Auto-open HTML reports?
OPEN_HTML = False
# ----------------------------------------


# --------- Zernike utilities ----------
def noll_indices(j: int):
    if j < 1:
        raise ValueError("Noll index j must be >= 1")
    count = 0
    n = 0
    while True:
        if n == 0:
            ms = [0]
        elif n % 2 == 0:
            ms = [0] + [m for k in range(1, n//2 + 1) for m in (-2*k, 2*k)]
        else:
            ms = [m for k in range(0, (n+1)//2) for m in (-(2*k+1), (2*k+1))]
        for m in ms:
            count += 1
            if count == j:
                return n, m
        n += 1


def zernike_noll(j, r, th):
    n, m = noll_indices(j)
    R = np.zeros_like(r)
    for s in range((n-abs(m))//2 + 1):
        c = ((-1)**s * factorial(n-s) /
             (factorial(s) *
              factorial((n+abs(m))//2 - s) *
              factorial((n-abs(m))//2 - s)))
        R += c * r**(n-2*s)
    if m == 0:
        return R
    return R * (np.cos(m*th) if m > 0 else np.sin(-m*th))

def compute_zernike_coeffs_chunked(X, Y, vals, n_modes, chunk_size=100000):
    Xc, Yc = X - np.mean(X), Y - np.mean(Y)
    R = float(np.max(np.hypot(Xc, Yc)))
    r = np.hypot(Xc/R, Yc/R).astype(np.float32)
    th = np.arctan2(Yc, Xc).astype(np.float32)
    mask = (r <= 1.0) & ~np.isnan(vals)
    if not np.any(mask):
        raise RuntimeError("No valid points inside unit disk.")
    idx = np.nonzero(mask)[0]
    m = int(n_modes)
    G = np.zeros((m, m), dtype=np.float64)  # Z^T Z
    h = np.zeros((m,), dtype=np.float64)    # Z^T v)
    v = vals.astype(np.float64)

    for start in range(0, len(idx), chunk_size):
        sl = idx[start:start+chunk_size]
        r_b, th_b, v_b = r[sl], th[sl], v[sl]
        Zb = np.column_stack([zernike_noll(j, r_b, th_b).astype(np.float32)
                              for j in range(1, m+1)])
        G += (Zb.T @ Zb).astype(np.float64)
        h += (Zb.T @ v_b).astype(np.float64)
    try:
        coeffs = np.linalg.solve(G, h)
    except LinAlgError:
        coeffs = np.linalg.lstsq(G, h, rcond=None)[0]
    return coeffs, R
# --------------------------------------


# ------------- IO helpers -------------
def find_dat_or_bdf(op2_path: str):
    folder = os.path.dirname(op2_path)
    base = os.path.splitext(os.path.basename(op2_path))[0]
    for ext in (".dat", ".bdf"):
        cand = os.path.join(folder, base + ext)
        if os.path.exists(cand):
            return cand
    for name in os.listdir(folder):
        if name.lower().endswith((".dat", ".bdf")):
            return os.path.join(folder, name)
    raise FileNotFoundError("No .dat or .bdf found in folder; geometry required.")

def pick_files_cli():
    ap = argparse.ArgumentParser()
    ap.add_argument("--op2", default=None, help="Full path to OP2 (optional)")
    ap.add_argument("--simdir", default=None, help="Directory to search (optional)")
    args = ap.parse_args()

    if args.op2:
        op2_path = args.op2
    else:
        base_dir = args.simdir or os.getcwd()
        op2_path = os.path.join(base_dir, OP2_FIXED_NAME)

    if not os.path.exists(op2_path):
        raise FileNotFoundError(f"OP2 not found: {op2_path}")

    dat_path = find_dat_or_bdf(op2_path)
    return op2_path, dat_path

def read_geometry(dat_path):
    bdf = BDF()
    bdf.read_bdf(dat_path)
    node_geo = {int(nid): node.get_position() for nid, node in bdf.nodes.items()}
    return node_geo

def _extract_first_int(text: str):
    if not isinstance(text, str):
        return None
    m = re.search(r'-?\d+', text)
    return int(m.group(0)) if m else None

def read_displacements_by_subcase(op2_path):
    """
    Return dict with keys {'90','20','40','60'}.
    Priority: subtitle numeric match, else isubcase numeric match.
    If exact set not found:
      - If 4+ numeric subcases exist, remap the 4 smallest to 90,20,40,60 (logged)
      - Else: raise with a detailed dump.
    Also writes an index CSV next to the OP2 for inspection.
    """
    want = REQUIRED_SUBCASES[:]  # [90,20,40,60]
    out_exact = {}
    debug_rows = []

    op2 = OP2()
    op2.read_op2(op2_path)
    if not op2.displacements:
        raise RuntimeError("No displacement subcases found in OP2.")

    for key, darr in op2.displacements.items():
        data = darr.data
        dmat = data[0] if data.ndim == 3 else (data if data.ndim == 2 else None)
        if dmat is None:
            continue

        ngt = darr.node_gridtype.astype(int)
        node_ids = ngt if ngt.ndim == 1 else ngt[:, 0]

        subtitle_raw = getattr(darr, 'subtitle', None)
        isub_raw = getattr(darr, 'isubcase', None)
        isub_int = int(isub_raw) if isinstance(isub_raw, int) else None
        sub_from_text = _extract_first_int(subtitle_raw) if isinstance(subtitle_raw, str) else None

        debug_rows.append({
            "table_key": str(key),
            "isubcase": isub_int,
            "subtitle": str(subtitle_raw) if subtitle_raw is not None else "",
            "subtitle_int": sub_from_text if sub_from_text is not None else "",
            "nnodes": int(len(node_ids)),
            "_node_ids": node_ids.astype(int),
            "_dmat": dmat.copy(),
        })

        if isinstance(sub_from_text, int) and sub_from_text in want:
            out_exact[str(sub_from_text)] = {"node_ids": node_ids.astype(int), "disp": dmat.copy()}
            continue
        if isinstance(isub_int, int) and isub_int in want:
            out_exact[str(isub_int)] = {"node_ids": node_ids.astype(int), "disp": dmat.copy()}

    try:
        folder = os.path.dirname(op2_path)
        base = os.path.splitext(os.path.basename(op2_path))[0]
        idx_path = os.path.join(folder, f"{base}_op2_displ_index.csv")
        pd.DataFrame([{k: v for k, v in r.items() if not k.startswith('_')} for r in debug_rows])\
          .to_csv(idx_path, index=False)
        print(f"[DEBUG] Wrote displacement index: {idx_path}", flush=True)
    except Exception as e:
        print(f"[WARN] Could not write displacement index CSV: {e}", flush=True)

    if all(str(x) in out_exact for x in want):
        print("[INFO] Found exact subcases 90, 20, 40 and 60 via labels.", flush=True)
        return out_exact

    numerics = []
    for r in debug_rows:
        n = r.get("subtitle_int")
        if not isinstance(n, int):
            n = r.get("isubcase") if isinstance(r.get("isubcase"), int) else None
        if isinstance(n, int):
            numerics.append((n, r))
    numerics = sorted(numerics, key=lambda t: t[0])

    if len(numerics) >= 4:
        chosen = numerics[:4]
        mapping = dict(zip([c[0] for c in chosen], want))
        out = {}
        for n, r in chosen:
            lbl = str(mapping[n])
            out[lbl] = {"node_ids": r["_node_ids"], "disp": r["_dmat"]}
        print("[WARN] Exact 90/20/40/60 not found; remapped smallest numeric subcases "
              f"{[c[0] for c in chosen]} -> {want}", flush=True)
        return out

    print("\n[DEBUG] Displacement entries found (dump):", flush=True)
    if debug_rows:
        header = f'{"table_key":<18} {"isubcase":>7}  {"subtitle":<40} {"subtitle_int":>12} {"nnodes":>8}'
        print(header); print("-"*len(header))
        for r in debug_rows:
            print(f'{r["table_key"]:<18} {str(r["isubcase"]):>7}  {r["subtitle"]:<40} '
                  f'{str(r["subtitle_int"]):>12} {r["nnodes"]:>8}', flush=True)
    missing = [x for x in want if str(x) not in out_exact]
    raise RuntimeError(f"Required subcases missing: {missing}. "
                       "Ensure SUBTITLEs (or isubcase IDs) are exactly 90, 20, 40 and 60.")
# --------------------------------------


# ------------- Core logic -------------
def build_dataframe_for_subcase(label, node_ids, dmat, node_geo):
    rows = []
    for nid, vec in zip(node_ids, dmat):
        x0, y0, z0 = node_geo.get(int(nid), (np.nan,)*3)
        rows.append({
            "Subcase": label,
            "NodeLabel": int(nid),
            "X0": x0, "Y0": y0, "Z0": z0,
            "Disp_Z": float(vec[2]),
            "DispUnit": DISP_SRC_UNIT
        })
    df = pd.DataFrame(rows)
    if df[["X0", "Y0"]].isna().any().any():
        raise RuntimeError("Geometry missing (X0,Y0) for some nodes. Ensure .dat/.bdf matches the OP2.")
    return df

def compute_relative_dz(df_abs_ref, df_abs_ang):
    a = df_abs_ref.set_index("NodeLabel")
    b = df_abs_ang.set_index("NodeLabel")
    common = a.index.intersection(b.index)

    b2 = b.loc[common].copy()
    b2["Rel_Disp_Z"] = b2["Disp_Z"] - a.loc[common, "Disp_Z"]
    b2.reset_index(inplace=True)
    out = df_abs_ang.merge(b2[["NodeLabel", "Rel_Disp_Z"]], on="NodeLabel", how="inner")
    out["Rel_WFE_nm"] = 2.0 * out["Rel_Disp_Z"] * NM_PER_UNIT
    return out

def compute_rms_pair_only(X, Y, W_nm):
    coeffs, Rmax = compute_zernike_coeffs_chunked(X, Y, W_nm, N_MODES)
    Xc = X - np.mean(X); Yc = Y - np.mean(Y)
    r = np.hypot(Xc/Rmax, Yc/Rmax); th = np.arctan2(Yc, Xc)
    Z = np.column_stack([zernike_noll(j, r, th) for j in range(1, N_MODES+1)])
    W_res_filt = W_nm - Z[:, :FILTER_LOW_ORDERS].dot(coeffs[:FILTER_LOW_ORDERS])
    global_rms = float(np.sqrt((W_nm ** 2).mean()))
    filtered_rms = float(np.sqrt((W_res_filt ** 2).mean()))
    return global_rms, filtered_rms

def analyze_manufacturing_metrics(X, Y, W_nm):
    """
    Manufacturing-related metrics at any angle. Returns individual mode magnitudes
    and filtered RMS with different filter levels. (Numeric only.)
    """
    coeffs, Rmax = compute_zernike_coeffs_chunked(X, Y, W_nm, N_MODES)
    Xc = X - np.mean(X); Yc = Y - np.mean(Y)
    r = np.hypot(Xc/Rmax, Yc/Rmax); th = np.arctan2(Yc, Xc)
    Z = np.column_stack([zernike_noll(j, r, th) for j in range(1, N_MODES+1)])

    W_res_filt3 = W_nm - Z[:, :3].dot(coeffs[:3])  # includes defocus
    rms_filt3 = float(np.sqrt((W_res_filt3 ** 2).mean()))

    W_res_filt4 = W_nm - Z[:, :4].dot(coeffs[:4])  # operational metric
    rms_filt4 = float(np.sqrt((W_res_filt4 ** 2).mean()))

    astig_rms = float(np.sqrt(coeffs[4]**2 + coeffs[5]**2))   # J5+J6
    coma_rms  = float(np.sqrt(coeffs[6]**2 + coeffs[7]**2))   # J7+J8
    trefoil_rms = float(np.sqrt(coeffs[8]**2 + coeffs[9]**2)) # J9+J10

    return {
        "rms_filter_j1to3": rms_filt3,
        "rms_filter_j1to4": rms_filt4,
        "defocus_nm": float(abs(coeffs[3])),
        "astigmatism_rms": astig_rms,
        "coma_rms": coma_rms,
        "trefoil_rms": trefoil_rms,
        "spherical_nm": float(abs(coeffs[10]))
    }


def zernike_common_name(n: int, m: int) -> str:
    names = {
        (0, 0): "Piston",
        (1, -1): "Tilt X",
        (1,  1): "Tilt Y",
        (2,  0): "Defocus",
        (2, -2): "Astig 45 deg",
        (2,  2): "Astig 0 deg",
        (3, -1): "Coma X",
        (3,  1): "Coma Y",
        (3, -3): "Trefoil X",
        (3,  3): "Trefoil Y",
        (4,  0): "Primary Spherical",
        (4, -2): "Secondary Astig X",
        (4,  2): "Secondary Astig Y",
        (4, -4): "Quadrafoil X",
        (4,  4): "Quadrafoil Y",
        (5, -1): "Secondary Coma X",
        (5,  1): "Secondary Coma Y",
        (5, -3): "Secondary Trefoil X",
        (5,  3): "Secondary Trefoil Y",
        (5, -5): "Pentafoil X",
        (5,  5): "Pentafoil Y",
        (6,  0): "Secondary Spherical",
    }
    return names.get((n, m), f"Z(n={n}, m={m})")

def zernike_label_for_j(j: int) -> str:
    n, m = noll_indices(j)
    return f"J{j:02d} - {zernike_common_name(n, m)} (n={n}, m={m})"

def zernike_report(label, X, Y, W_nm, folder, base, is_relative: bool,
                   abs_pair=None, ref_title="20 deg", mfg_data=None, mfg_data_vs_20=None):
    coeffs, Rmax = compute_zernike_coeffs_chunked(X, Y, W_nm, N_MODES)
    Xc = X - np.mean(X); Yc = Y - np.mean(Y)
    r = np.hypot(Xc/Rmax, Yc/Rmax); th = np.arctan2(Yc, Xc)
    Z = np.column_stack([zernike_noll(j, r, th) for j in range(1, N_MODES+1)])
    W_res_filt = W_nm - Z[:, :FILTER_LOW_ORDERS].dot(coeffs[:FILTER_LOW_ORDERS])

    global_rms = float(np.sqrt((W_nm ** 2).mean()))
    filtered_rms = float(np.sqrt((W_res_filt ** 2).mean()))

    # Downsample for display only
    n = len(X)
    if n > PLOT_DOWNSAMPLE:
        rng = np.random.default_rng(42)
        sel = rng.choice(n, size=PLOT_DOWNSAMPLE, replace=False)
        Xp, Yp, Wp = X[sel], Y[sel], W_res_filt[sel]
    else:
        Xp, Yp, Wp = X, Y, W_res_filt

    res_amp = AMP * Wp
    max_amp = float(np.max(np.abs(res_amp))) if res_amp.size else 1.0

    # Triangulate the downsampled set
    mesh_traces = []
    try:
        tri = Triangulation(Xp, Yp)
        if tri.triangles is not None and len(tri.triangles) > 0:
            i, j, k = tri.triangles.T
            mesh_traces.append(go.Mesh3d(
                x=Xp, y=Yp, z=res_amp,
                i=i, j=j, k=k,
                intensity=res_amp,
                opacity=0.85, showscale=False
            ))
    except Exception:
        pass

    mesh_traces.append(go.Scatter3d(
        x=Xp, y=Yp, z=res_amp,
        mode='markers',
        marker=dict(size=2),
        showlegend=False
    ))

    labels = [zernike_label_for_j(j) for j in range(1, N_MODES+1)]
    coeff_abs = np.abs(coeffs)

    title_rel = f" (relative to {ref_title})" if is_relative else " (absolute)"

    # Layout
    if mfg_data is not None and mfg_data_vs_20 is not None:
        if SHOW_ZERNIKE_BAR:
            fig = make_subplots(
                rows=5, cols=1,
                specs=[[{"type":"scene"}],
                       [{"type":"table"}],
                       [{"type":"table"}],
                       [{"type":"table"}],
                       [{"type":"xy"}]],
                row_heights=[0.40, 0.12, 0.12, 0.18, 0.18],
                vertical_spacing=0.025,
                subplot_titles=[
                    f"<b>Surface Residual & RMS of WFE - Subcase {label}{title_rel}</b>",
                    "<b>RMS Metrics (Absolute 90 deg)</b>",
                    "<b>Mode Magnitudes at 90 deg</b>",
                    "<b>Pre-Correction (90 deg - 20 deg)</b>",
                    "<b>|Zernike Coefficients| (nm)</b>"
                ]
            )
        else:
            fig = make_subplots(
                rows=5, cols=1,
                specs=[[{"type":"scene"}],
                       [{"type":"table"}],
                       [{"type":"table"}],
                       [{"type":"table"}],
                       [{"type":"table"}]],
                row_heights=[0.40, 0.12, 0.12, 0.18, 0.18],
                vertical_spacing=0.025,
                subplot_titles=[
                    f"<b>Surface Residual & RMS of WFE - Subcase {label}{title_rel}</b>",
                    "<b>RMS Metrics (Absolute 90 deg)</b>",
                    "<b>Mode Magnitudes at 90 deg</b>",
                    "<b>Pre-Correction (90 deg - 20 deg)</b>",
                    f"<b>Zernike Coefficients ({N_MODES} modes)</b>"
                ]
            )
    elif SHOW_ZERNIKE_BAR:
        fig = make_subplots(
            rows=4, cols=1,
            specs=[[{"type":"scene"}],
                   [{"type":"table"}],
                   [{"type":"table"}],
                   [{"type":"xy"}]],
            row_heights=[0.52, 0.12, 0.24, 0.12],
            vertical_spacing=0.035,
            subplot_titles=[
                f"<b>Surface Residual & RMS of WFE - Subcase {label}{title_rel}</b>",
                "<b>RMS Metrics</b>",
                f"<b>Zernike Coefficients ({N_MODES} modes)</b>",
                "<b>|Zernike Coefficients| (nm)</b>"
            ]
        )
    else:
        fig = make_subplots(
            rows=3, cols=1,
            specs=[[{"type":"scene"}],
                   [{"type":"table"}],
                   [{"type":"table"}]],
            row_heights=[0.60, 0.14, 0.26],
            vertical_spacing=0.03,
            subplot_titles=[
                f"<b>Surface Residual & RMS of WFE - Subcase {label}{title_rel}</b>",
                "<b>RMS Metrics</b>",
                f"<b>Zernike Coefficients ({N_MODES} modes)</b>"
            ]
        )

    for tr in mesh_traces:
        fig.add_trace(tr, row=1, col=1)

    scene = fig.layout.scene
    scene.camera = dict(eye=dict(x=0.5, y=0.5, z=0.5))
    scene.zaxis  = dict(range=[-max_amp * PANCAKE, max_amp * PANCAKE])

    # RMS table
    if is_relative and abs_pair is not None:
        abs_global, abs_filtered = abs_pair
        fig.add_trace(go.Table(
            header=dict(values=["<b>Metric</b>", "<b>Relative (nm)</b>", "<b>Absolute (nm)</b>"], align="left"),
            cells=dict(values=[
                ["Global RMS","Filtered RMS (modes 1-4 removed)"],
                [f"{global_rms:.2f}", f"{filtered_rms:.2f}"],
                [f"{abs_global:.2f}", f"{abs_filtered:.2f}"],
            ], align="left")
        ), row=2, col=1)
    elif mfg_data is not None and mfg_data_vs_20 is not None:
        # 90 deg Absolute RMS (numeric only)
        fig.add_trace(go.Table(
            header=dict(values=["<b>Metric</b>","<b>Value (nm)</b>"], align="left"),
            cells=dict(values=[
                ["Global RMS", "Filtered RMS (J1-J4)"],
                [f"{global_rms:.2f}", f"{filtered_rms:.2f}"]
            ], align="left")
        ), row=2, col=1)

        # Mode magnitudes at 90 deg (absolute) — numeric only
        fig.add_trace(go.Table(
            header=dict(values=["<b>Mode</b>","<b>Value (nm)</b>"], align="left"),
            cells=dict(values=[
                ["Filtered RMS (J1-J3, with defocus)",
                 "Astigmatism (J5+J6)",
                 "Coma (J7+J8)",
                 "Trefoil (J9+J10)",
                 "Spherical (J11)"],
                [f"{mfg_data['rms_filter_j1to3']:.2f}",
                 f"{mfg_data['astigmatism_rms']:.2f}",
                 f"{mfg_data['coma_rms']:.2f}",
                 f"{mfg_data['trefoil_rms']:.2f}",
                 f"{mfg_data['spherical_nm']:.2f}"]
            ], align="left")
        ), row=3, col=1)

        # Pre-correction 90 deg - 20 deg — numeric only
        fig.add_trace(go.Table(
            header=dict(values=["<b>Mode</b>", "<b>Correction (nm)</b>"], align="left"),
            cells=dict(values=[
                ["Total RMS (J1-J3 filter)",
                 "Defocus (J4)",
                 "Astigmatism (J5+J6)",
                 "Coma (J7+J8)"],
                [f"{mfg_data_vs_20['rms_filter_j1to3']:.2f}",
                 f"{mfg_data_vs_20['defocus_nm']:.2f}",
                 f"{mfg_data_vs_20['astigmatism_rms']:.2f}",
                 f"{mfg_data_vs_20['coma_rms']:.2f}"]
            ], align="left")
        ), row=4, col=1)

    elif mfg_data is not None:
        # Simple manufacturing table (compat) — numeric only
        fig.add_trace(go.Table(
            header=dict(values=["<b>Metric</b>","<b>Value (nm)</b>"], align="left"),
            cells=dict(values=[
                ["Global RMS",
                 "Filtered RMS (J1-J4)",
                 "Filtered RMS (J1-J3, with defocus)",
                 "Defocus (J4)",
                 "Astigmatism (J5+J6)",
                 "Coma (J7+J8)",
                 "Trefoil (J9+J10)",
                 "Spherical (J11)"],
                [f"{global_rms:.2f}",
                 f"{filtered_rms:.2f}",
                 f"{mfg_data['rms_filter_j1to3']:.2f}",
                 f"{mfg_data['defocus_nm']:.2f}",
                 f"{mfg_data['astigmatism_rms']:.2f}",
                 f"{mfg_data['coma_rms']:.2f}",
                 f"{mfg_data['trefoil_rms']:.2f}",
                 f"{mfg_data['spherical_nm']:.2f}"]
            ], align="left")
        ), row=2, col=1)
    else:
        # Standard absolute table
        fig.add_trace(go.Table(
            header=dict(values=["<b>Metric</b>","<b>Value (nm)</b>"], align="left"),
            cells=dict(values=[
                ["Global RMS","Filtered RMS (modes 1-4 removed)"],
                [f"{global_rms:.2f}", f"{filtered_rms:.2f}"]
            ], align="left")
        ), row=2, col=1)

    # Zernike coefficients
    if mfg_data is not None and mfg_data_vs_20 is not None:
        pass  # manufacturing case: bar chart only
    elif SHOW_ZERNIKE_BAR:
        fig.add_trace(go.Table(
            header=dict(values=["<b>Noll j</b>","<b>Label</b>","<b>|Coeff| (nm)</b>"], align="left"),
            cells=dict(values=[
                list(range(1, N_MODES+1)),
                labels,
                list(np.round(coeff_abs, 3))
            ], align="left")
        ), row=3, col=1)
    else:
        fig.add_trace(go.Table(
            header=dict(values=["<b>Noll j</b>","<b>Label</b>","<b>|Coeff| (nm)</b>"], align="left"),
            cells=dict(values=[
                list(range(1, N_MODES+1)),
                labels,
                list(np.round(coeff_abs, 3))
            ], align="left")
        ), row=3, col=1)

    if SHOW_ZERNIKE_BAR:
        bar_row = 5 if (mfg_data is not None and mfg_data_vs_20 is not None) else 4
        fig.add_trace(
            go.Bar(
                x=coeff_abs.tolist(),
                y=labels,
                orientation='h',
                hovertemplate="%{y}<br>|Coeff| = %{x:.3f} nm<extra></extra>",
                showlegend=False
            ),
            row=bar_row, col=1
        )

    fig.update_layout(
        width=1400,
        height=1400 if (mfg_data is not None and mfg_data_vs_20 is not None)
                else (1200 if SHOW_ZERNIKE_BAR else 1000),
        margin=dict(t=60,b=20)
    )

    # Save
    suffix = f"_sub{label}"
    html_path = os.path.join(folder, f"{base}{suffix}.html")
    fig.write_html(html_path, include_plotlyjs='cdn')

    pd.DataFrame({
        "Noll_j": list(range(1, N_MODES+1)),
        "Label": labels,
        "Coeff_nm": [float(c) for c in coeffs],
        "CoeffAbs_nm": [float(abs(c)) for c in coeffs]
    }).to_csv(os.path.join(folder, f"{base}{suffix}_zcoeffs.csv"), index=False)

    if OPEN_HTML:
        try:
            webbrowser.open('file://' + os.path.abspath(html_path))
        except Exception:
            pass

    return {
        "global_rms_nm": global_rms,
        "filtered_rms_nm": filtered_rms,
        "html_path": html_path
    }
# --------------------------------------


def write_exp_file(folder, abs_by_angle, rel20_by_angle, rel90_by_angle, mfg_90=None, mfg_90_vs_20=None):
    """
    Writes Iteration_results_expression.exp in [MilliMeter].
    For each angle a in {90,20,40,60}, writes:
      - RMS_a_Global, RMS_a_Filtered                (absolute)
      - RMS_a_Global_Rel20, RMS_a_Filtered_Rel20    (relative to 20 deg)
      - RMS_a_Global_Rel90,  RMS_a_Filtered_Rel90   (relative to 90 deg)
    """
    required = ["90", "20", "40", "60"]
    out_path = os.path.join(folder, "Iteration_results_expression.exp")
    nm_to_mm = lambda x: float(x) * 1e-6

    def _missing(dct):
        return [a for a in required if a not in dct]

    miss_abs = _missing(abs_by_angle)
    miss_r20 = _missing(rel20_by_angle)
    miss_r90 = _missing(rel90_by_angle)
    if miss_abs or miss_r20 or miss_r90:
        raise RuntimeError(f"EXP export aborted. Missing sets -> "
                           f"abs:{miss_abs}, rel20:{miss_r20}, rel90:{miss_r90}.")

    lines = ["// Version:  3"]
    for ang in required:
        gA, fA = abs_by_angle[ang]
        g20, f20 = rel20_by_angle[ang]
        g90, f90 = rel90_by_angle[ang]
        lines.append(f"[MilliMeter]RMS_{ang}_Global={nm_to_mm(gA):.7f}")
        lines.append(f"[MilliMeter]RMS_{ang}_Filtered={nm_to_mm(fA):.7f}")
        lines.append(f"[MilliMeter]RMS_{ang}_Global_Rel20={nm_to_mm(g20):.7f}")
        lines.append(f"[MilliMeter]RMS_{ang}_Filtered_Rel20={nm_to_mm(f20):.7f}")
        lines.append(f"[MilliMeter]RMS_{ang}_Global_Rel90={nm_to_mm(g90):.7f}")
        lines.append(f"[MilliMeter]RMS_{ang}_Filtered_Rel90={nm_to_mm(f90):.7f}")

    # Manufacturing metrics for 90 deg (numeric only)
    if mfg_90_vs_20 and mfg_90:
        lines.append(f"[MilliMeter]RMS_90_Optician_Workload={nm_to_mm(mfg_90_vs_20['rms_filter_j1to3']):.7f}")
        lines.append(f"[MilliMeter]RMS_90_Astig_Abs={nm_to_mm(mfg_90['astigmatism_rms']):.7f}")
        lines.append(f"[MilliMeter]RMS_90_Coma_Abs={nm_to_mm(mfg_90['coma_rms']):.7f}")

    with open(out_path, "w", encoding="utf-8-sig", newline="\n") as f:
        f.write("\n".join(lines) + "\n")
    print(f"[OK] Wrote EXP: {out_path}")


def main():
    op2_path, dat_path = pick_files_cli()
    folder = os.path.dirname(op2_path)
    base = os.path.splitext(os.path.basename(op2_path))[0]

    results_dir = os.environ.get("ZERNIKE_RESULTS_DIR") or os.path.join(folder, "Results")
    os.makedirs(results_dir, exist_ok=True)

    env_run_id = os.environ.get("ZERNIKE_RUN_ID")
    if env_run_id:
        run_id = env_run_id
        try:
            run_dt = datetime.strptime(env_run_id, "%Y%m%d_%H%M%S")
        except Exception:
            run_dt = datetime.now()
    else:
        run_dt = datetime.now()
        run_id = run_dt.strftime("%Y%m%d_%H%M%S")

    base_ts = f"{base}_{run_id}"

    node_geo = read_geometry(dat_path)
    subdisp = read_displacements_by_subcase(op2_path)  # dict: '90','20','40','60'

    # ABS for 20 deg (reference for pages)
    if REF_LABEL not in subdisp:
        raise RuntimeError(f"Reference subcase '{REF_LABEL}' not found after mapping. "
                           "Check *_op2_displ_index.csv for labels.")

    df_ref_abs = build_dataframe_for_subcase(
        REF_LABEL,
        subdisp[REF_LABEL]["node_ids"],
        subdisp[REF_LABEL]["disp"],
        node_geo
    )
    df_ref_abs["WFE_nm"] = 2.0 * df_ref_abs["Disp_Z"] * NM_PER_UNIT
    Xref = df_ref_abs["X0"].to_numpy()
    Yref = df_ref_abs["Y0"].to_numpy()
    Wref = df_ref_abs["WFE_nm"].to_numpy()
    abs_ref_global, abs_ref_filtered = compute_rms_pair_only(Xref, Yref, Wref)

    # ABS for 90 deg (manufacturing reference; used for REL vs 90)
    if POLISH_LABEL not in subdisp:
        raise RuntimeError(f"Subcase '{POLISH_LABEL}' not found after mapping.")
    df90_abs = build_dataframe_for_subcase(
        POLISH_LABEL,
        subdisp[POLISH_LABEL]["node_ids"],
        subdisp[POLISH_LABEL]["disp"],
        node_geo
    )
    df90_abs["WFE_nm"] = 2.0 * df90_abs["Disp_Z"] * NM_PER_UNIT
    X90 = df90_abs["X0"].to_numpy()
    Y90 = df90_abs["Y0"].to_numpy()
    W90 = df90_abs["WFE_nm"].to_numpy()
    abs90_global, abs90_filtered = compute_rms_pair_only(X90, Y90, W90)

    combined_rows = []
    def emit_rows(df, label, is_relative):
        if is_relative:
            for row in df.itertuples(index=False):
                combined_rows.append({
                    "Subcase": label,
                    "NodeLabel": row.NodeLabel,
                    "X0": row.X0, "Y0": row.Y0, "Z0": row.Z0,
                    "Rel_Disp_Z": getattr(row, "Rel_Disp_Z"),
                    "WFE_nm": getattr(row, "Rel_WFE_nm"),
                    "DispUnit": DISP_SRC_UNIT,
                    "RelativeToRef": True,
                    "RefAngleDeg": REF_LABEL
                })
        else:
            for row in df.itertuples(index=False):
                combined_rows.append({
                    "Subcase": label,
                    "NodeLabel": row.NodeLabel,
                    "X0": row.X0, "Y0": row.Y0, "Z0": row.Z0,
                    "Disp_Z": row.Disp_Z,
                    "WFE_nm": row.WFE_nm,
                    "DispUnit": DISP_SRC_UNIT,
                    "RelativeToRef": False,
                    "RefAngleDeg": ""
                })

    # Emit absolute 20 deg
    emit_rows(df_ref_abs, REF_LABEL, is_relative=False)

    metrics_summary = [{
        "Subcase": f"sub{REF_LABEL}",
        "Abs_GlobalRMS_nm": abs_ref_global,
        "Abs_FilteredRMS_nm": abs_ref_filtered,
        "Rel20_GlobalRMS_nm": np.nan,   # reference has no relative (vs 20 deg)
        "Rel20_FilteredRMS_nm": np.nan
    }]

    abs_values_by_angle   = {REF_LABEL: (abs_ref_global, abs_ref_filtered), POLISH_LABEL: (abs90_global, abs90_filtered)}
    rel20_values_by_angle = {REF_LABEL: (0.0, 0.0)}  # 20 vs 20 = 0
    rel90_values_by_angle = {POLISH_LABEL: (0.0, 0.0)}  # 90 vs 90 = 0

    # Process all angles (ABS; REL vs 20 deg for pages)
    all_angles = [str(a) for a in REQUIRED_SUBCASES]
    for ang in [a for a in all_angles if a != REF_LABEL]:
        if ang not in subdisp:
            raise RuntimeError(f"Subcase '{ang}' not found after mapping. "
                            "Check *_op2_displ_index.csv for labels.")

        if ang == POLISH_LABEL:
            abs_global, abs_filtered = abs_values_by_angle[ang]
            X_abs, Y_abs = X90, Y90
        else:
            df_abs = build_dataframe_for_subcase(ang, subdisp[ang]["node_ids"], subdisp[ang]["disp"], node_geo)
            W_abs_nm = 2.0 * df_abs["Disp_Z"].to_numpy() * NM_PER_UNIT
            X_abs = df_abs["X0"].to_numpy(); Y_abs = df_abs["Y0"].to_numpy()
            abs_global, abs_filtered = compute_rms_pair_only(X_abs, Y_abs, W_abs_nm)
            abs_values_by_angle[ang] = (abs_global, abs_filtered)

        # REL vs 20
        if ang == POLISH_LABEL:
            df_rel20 = compute_relative_dz(df_ref_abs, df90_abs)
        else:
            df_rel20 = compute_relative_dz(df_ref_abs, df_abs)
            emit_rows(df_rel20, ang, is_relative=True)  # Only emit CSV rows for 40/60

        X_rel20 = df_rel20["X0"].to_numpy(); Y_rel20 = df_rel20["Y0"].to_numpy()
        W_rel20 = df_rel20["Rel_WFE_nm"].to_numpy()

        # HTML for 40 and 60 only
        if ang != POLISH_LABEL:
            m_rel20 = zernike_report(
                ang, X_rel20, Y_rel20, W_rel20, results_dir, base_ts,
                is_relative=True, abs_pair=(abs_global, abs_filtered), ref_title=REF_TITLE
            )
            rel20_values_by_angle[ang] = (m_rel20["global_rms_nm"], m_rel20["filtered_rms_nm"])

            metrics_summary.append({
                "Subcase": f"sub{ang}",
                "Abs_GlobalRMS_nm": abs_global,
                "Abs_FilteredRMS_nm": abs_filtered,
                "Rel20_GlobalRMS_nm": m_rel20["global_rms_nm"],
                "Rel20_FilteredRMS_nm": m_rel20["filtered_rms_nm"]
            })
        else:
            g20, f20 = compute_rms_pair_only(X_rel20, Y_rel20, W_rel20)
            rel20_values_by_angle[ang] = (g20, f20)

    # REL vs 90 for all angles (metrics only)
    for ang in all_angles:
        df_abs = build_dataframe_for_subcase(ang, subdisp[ang]["node_ids"], subdisp[ang]["disp"], node_geo)
        df_rel90 = compute_relative_dz(df90_abs, df_abs)
        X_rel90 = df_rel90["X0"].to_numpy(); Y_rel90 = df_rel90["Y0"].to_numpy()
        W_rel90 = df_rel90["Rel_WFE_nm"].to_numpy()
        g90, f90 = compute_rms_pair_only(X_rel90, Y_rel90, W_rel90)
        rel90_values_by_angle[ang] = (g90, f90)

    # Manufacturing metrics for 90
    mfg_metrics_90 = None
    mfg_metrics_90_vs_20 = None

    if POLISH_LABEL in subdisp:
        mfg_metrics_90 = analyze_manufacturing_metrics(X90, Y90, W90)

        df90_rel20 = compute_relative_dz(df_ref_abs, df90_abs)
        X90_rel = df90_rel20["X0"].to_numpy()
        Y90_rel = df90_rel20["Y0"].to_numpy()
        W90_rel = df90_rel20["Rel_WFE_nm"].to_numpy()

        mfg_metrics_90_vs_20 = analyze_manufacturing_metrics(X90_rel, Y90_rel, W90_rel)

        # Special HTML for 90 (numeric only)
        _m90 = zernike_report(
            POLISH_LABEL, X90, Y90, W90, results_dir, base_ts,
            is_relative=False,
            mfg_data=mfg_metrics_90,
            mfg_data_vs_20=mfg_metrics_90_vs_20
        )

    # HTML for 20 deg relative to 90 deg
    df_20_rel90 = compute_relative_dz(df90_abs, df_ref_abs)
    X_20_rel90 = df_20_rel90["X0"].to_numpy()
    Y_20_rel90 = df_20_rel90["Y0"].to_numpy()
    W_20_rel90 = df_20_rel90["Rel_WFE_nm"].to_numpy()

    _m_ref = zernike_report(
        REF_LABEL, X_20_rel90, Y_20_rel90, W_20_rel90, results_dir, base_ts,
        is_relative=True, abs_pair=(abs_ref_global, abs_ref_filtered), ref_title=POLISH_TITLE
    )

    # Combined nodal CSV
    comb_df = pd.DataFrame(combined_rows)
    comb_path = os.path.join(results_dir, f"{base_ts}_all_subcases_nodal.csv")
    comb_df.to_csv(comb_path, index=False)

    # Summary CSV (REL20 naming)
    summary_path = os.path.join(results_dir, f"{base_ts}_RMS_summary.csv")
    pd.DataFrame(metrics_summary, columns=[
        "Subcase",
        "Abs_GlobalRMS_nm","Abs_FilteredRMS_nm",
        "Rel20_GlobalRMS_nm","Rel20_FilteredRMS_nm"
    ]).to_csv(summary_path, index=False)

    # EXP export
    write_exp_file(results_dir, abs_values_by_angle, rel20_values_by_angle, rel90_values_by_angle,
                   mfg_90=mfg_metrics_90, mfg_90_vs_20=mfg_metrics_90_vs_20)

    # Persistent RMS log
    log_path = os.path.join(results_dir, "RMS_log.csv")
    row = {
        "RunID": run_id,
        "RunDateTimeLocal": run_dt.strftime("%Y-%m-%d %H:%M:%S"),
        "OP2_Base": base,
        "DispUnit": DISP_SRC_UNIT,
        "ReferenceAngleDeg": REF_LABEL
    }
    for ang in [str(a) for a in REQUIRED_SUBCASES]:
        gA, fA = abs_values_by_angle.get(ang, (np.nan, np.nan))
        gR20, fR20 = rel20_values_by_angle.get(ang, (np.nan, np.nan))
        gR90, fR90 = rel90_values_by_angle.get(ang, (np.nan, np.nan))
        row[f"Abs_GlobalRMS_nm_{ang}"]         = gA
        row[f"Abs_FilteredRMS_nm_{ang}"]       = fA
        row[f"Rel_GlobalRMS_nm_{ang}_vs20"]    = gR20
        row[f"Rel_FilteredRMS_nm_{ang}_vs20"]  = fR20
        row[f"Rel_GlobalRMS_nm_{ang}_vs90"]    = gR90
        row[f"Rel_FilteredRMS_nm_{ang}_vs90"]  = fR90

    if mfg_metrics_90 and mfg_metrics_90_vs_20:
        row["Mfg_90_OpticianWorkload_nm"] = mfg_metrics_90_vs_20['rms_filter_j1to3']
        row["Mfg_90_Astig_Abs_nm"] = mfg_metrics_90['astigmatism_rms']
        row["Mfg_90_Coma_Abs_nm"] = mfg_metrics_90['coma_rms']

    pd.DataFrame([row]).to_csv(
        log_path,
        mode="a",
        header=not os.path.exists(log_path),
        index=False
    )

    # Console output (numeric only; ASCII-safe)
    if mfg_metrics_90 and mfg_metrics_90_vs_20:
        print("\n" + "="*60)
        print("MANUFACTURING METRICS (90 deg horizontal polishing)")
        print("="*60)

        print(f"\n90 deg Absolute (surface shape on test stand):")
        print(f"  Filtered RMS (J1-J4): {mfg_metrics_90['rms_filter_j1to4']:.1f} nm")
        print(f"  Astigmatism (J5+J6):  {mfg_metrics_90['astigmatism_rms']:.1f} nm")
        print(f"  Coma (J7+J8):         {mfg_metrics_90['coma_rms']:.1f} nm")
        print(f"  Trefoil (J9+J10):     {mfg_metrics_90['trefoil_rms']:.1f} nm")

        print(f"\n90 deg - 20 deg (pre-correction to polish in):")
        print(f"  Total RMS (J1-J3):    {mfg_metrics_90_vs_20['rms_filter_j1to3']:.1f} nm")
        print(f"  Defocus (J4):         {mfg_metrics_90_vs_20['defocus_nm']:.1f} nm")
        print(f"  Astigmatism (J5+J6):  {mfg_metrics_90_vs_20['astigmatism_rms']:.1f} nm")
        print(f"  Coma (J7+J8):         {mfg_metrics_90_vs_20['coma_rms']:.1f} nm")
        print("="*60 + "\n")

    print("[OK] Wrote:")
    print("  -", comb_path)
    print("  -", summary_path)
    print("  -", log_path, "(appended)")

    for r in metrics_summary:
        tag = r["Subcase"]
        if tag == f"sub{REF_LABEL}":
            print(f"  - {base_ts}_{tag}.html  (ABS Global={r['Abs_GlobalRMS_nm']:.2f} nm, "
                  f"ABS Filtered={r['Abs_FilteredRMS_nm']:.2f} nm)")
        else:
            print(f"  - {base_ts}_{tag}.html  (REL(vs20) Global={r['Rel20_GlobalRMS_nm']:.2f} nm, "
                  f"REL(vs20) Filtered={r['Rel20_FilteredRMS_nm']:.2f} nm | "
                  f"ABS Global={r['Abs_GlobalRMS_nm']:.2f} nm, "
                  f"ABS Filtered={r['Abs_FilteredRMS_nm']:.2f} nm)")

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        msg = f"Fatal error: {e}"
        print(msg)
        if messagebox:
            try:
                messagebox.showerror("Fatal error", str(e))
            except Exception:
                pass
