"""
Zernike Dashboard - Comprehensive Mirror Wavefront Analysis

A complete, professional dashboard for M1 mirror optimization that combines:
- Executive summary with all key metrics at a glance
- All orientation views (40°, 60°, 90°) on a single page
- MSF band analysis integrated
- Light/white theme for better readability
- Interactive comparison between subcases

This is the unified replacement for the separate zernike_wfe and msf_zernike insights.

Author: Atomizer Framework
Date: 2024-12-22
"""

from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple
import numpy as np
from math import factorial
from numpy.linalg import LinAlgError

from .base import StudyInsight, InsightConfig, InsightResult, register_insight

# Lazy imports
_plotly_loaded = False
_go = None
_make_subplots = None
_Triangulation = None
_OP2 = None
_BDF = None
_ZernikeOPDExtractor = None


def _load_dependencies():
    """Lazy load heavy dependencies."""
    global _plotly_loaded, _go, _make_subplots, _Triangulation, _OP2, _BDF, _ZernikeOPDExtractor
    if not _plotly_loaded:
        import plotly.graph_objects as go
        from plotly.subplots import make_subplots
        from matplotlib.tri import Triangulation
        from pyNastran.op2.op2 import OP2
        from pyNastran.bdf.bdf import BDF
        from ..extractors.extract_zernike_figure import ZernikeOPDExtractor
        _go = go
        _make_subplots = make_subplots
        _Triangulation = Triangulation
        _OP2 = OP2
        _BDF = BDF
        _ZernikeOPDExtractor = ZernikeOPDExtractor
        _plotly_loaded = True


# ============================================================================
# Color Themes
# ============================================================================

LIGHT_THEME = {
    'bg': '#ffffff',
    'card_bg': '#f8fafc',
    'card_border': '#e2e8f0',
    'text': '#1e293b',
    'text_muted': '#64748b',
    'accent': '#3b82f6',
    'success': '#10b981',
    'warning': '#f59e0b',
    'danger': '#ef4444',
    'grid': 'rgba(100,116,139,0.2)',
    'surface_bg': '#f1f5f9',
}

BAND_COLORS = {
    'lsf': '#3b82f6',   # Blue
    'msf': '#f59e0b',   # Amber
    'hsf': '#ef4444',   # Red
}

ANGLE_COLORS = {
    '40': '#10b981',    # Emerald
    '60': '#3b82f6',    # Blue
    '90': '#8b5cf6',    # Purple
}


# ============================================================================
# Zernike Mathematics
# ============================================================================

def noll_indices(j: int) -> Tuple[int, int]:
    """Convert Noll index to (n, m) radial/azimuthal orders."""
    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 noll_to_radial_order(j: int) -> int:
    """Get radial order n for Noll index j."""
    n, _ = noll_indices(j)
    return n


def zernike_noll(j: int, r: np.ndarray, th: np.ndarray) -> np.ndarray:
    """Evaluate Zernike polynomial j at (r, theta)."""
    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 zernike_common_name(n: int, m: int) -> str:
    """Get common name for Zernike mode."""
    names = {
        (0, 0): "Piston", (1, -1): "Tilt X", (1, 1): "Tilt Y",
        (2, 0): "Defocus", (2, -2): "Astig 45°", (2, 2): "Astig 0°",
        (3, -1): "Coma X", (3, 1): "Coma Y", (3, -3): "Trefoil X", (3, 3): "Trefoil Y",
        (4, 0): "Spherical", (4, -2): "Sec Astig X", (4, 2): "Sec Astig Y",
        (4, -4): "Quadrafoil X", (4, 4): "Quadrafoil Y",
        (5, -1): "Sec Coma X", (5, 1): "Sec Coma Y",
        (6, 0): "Sec Spherical",
    }
    return names.get((n, m), f"Z({n},{m})")


def compute_zernike_coeffs(
    X: np.ndarray,
    Y: np.ndarray,
    vals: np.ndarray,
    n_modes: int,
    chunk_size: int = 100000
) -> Tuple[np.ndarray, float]:
    """Fit Zernike coefficients to WFE data."""
    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)
    h = np.zeros((m,), dtype=np.float64)
    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


def classify_modes_by_band(
    n_modes: int,
    lsf_max: int = 10,
    msf_max: int = 50
) -> Dict[str, List[int]]:
    """Classify Zernike modes into LSF/MSF/HSF bands."""
    bands = {'lsf': [], 'msf': [], 'hsf': []}
    for j in range(1, n_modes + 1):
        n = noll_to_radial_order(j)
        if n <= lsf_max:
            bands['lsf'].append(j)
        elif n <= msf_max:
            bands['msf'].append(j)
        else:
            bands['hsf'].append(j)
    return bands


def compute_band_rss(coeffs: np.ndarray, bands: Dict[str, List[int]]) -> Dict[str, float]:
    """Compute RSS (Root Sum Square) of coefficients in each band."""
    rss = {}
    for band_name, indices in bands.items():
        band_coeffs = [coeffs[j-1] for j in indices if j-1 < len(coeffs)]
        rss[band_name] = float(np.sqrt(np.sum(np.array(band_coeffs)**2)))
    rss['total'] = float(np.sqrt(np.sum(coeffs**2)))
    # Filtered: exclude J1-J4 (piston, tip, tilt, defocus)
    filtered_coeffs = coeffs[4:] if len(coeffs) > 4 else np.array([])
    rss['filtered'] = float(np.sqrt(np.sum(filtered_coeffs**2)))
    return rss


# ============================================================================
# Default Configuration
# ============================================================================

DEFAULT_CONFIG = {
    'n_modes': 50,
    'amp': 0.5,
    'pancake': 3.0,
    'plot_downsample': 8000,
    'filter_low_orders': 4,
    'lsf_max': 10,
    'msf_max': 50,
    'disp_unit': 'mm',
    'use_opd': True,  # Use OPD method by default
}


@register_insight
class ZernikeDashboardInsight(StudyInsight):
    """
    Comprehensive Zernike Dashboard for M1 Mirror Optimization.

    Generates a single-page dashboard with:
    - Executive summary with key metrics
    - All orientations (40°, 60°, 90°) in comparison view
    - MSF band analysis
    - Interactive surface plots
    - Professional light theme
    """

    insight_type = "zernike_dashboard"
    name = "Zernike Dashboard"
    description = "Comprehensive mirror WFE dashboard with all orientations and MSF analysis"
    category = "optical"
    applicable_to = ["mirror", "optics", "wfe"]
    required_files = ["*.op2"]

    def __init__(self, study_path: Path):
        super().__init__(study_path)
        self.op2_path: Optional[Path] = None
        self.geo_path: Optional[Path] = None
        self._node_geo: Optional[Dict] = None
        self._displacements: Optional[Dict] = None

    def can_generate(self) -> bool:
        """Check if OP2 and geometry files exist."""
        search_paths = [
            self.results_path,
            self.study_path / "2_iterations",
            self.setup_path / "model",
        ]

        for search_path in search_paths:
            if not search_path.exists():
                continue
            op2_files = list(search_path.glob("**/*solution*.op2"))
            if not op2_files:
                op2_files = list(search_path.glob("**/*.op2"))
            if op2_files:
                self.op2_path = max(op2_files, key=lambda p: p.stat().st_mtime)
                break

        if self.op2_path is None:
            return False

        try:
            self.geo_path = self._find_geometry_file(self.op2_path)
            return True
        except FileNotFoundError:
            return False

    def _find_geometry_file(self, op2_path: Path) -> Path:
        """Find BDF/DAT geometry file for OP2."""
        folder = op2_path.parent
        base = op2_path.stem

        for ext in ['.dat', '.bdf']:
            cand = folder / (base + ext)
            if cand.exists():
                return cand

        for f in folder.iterdir():
            if f.suffix.lower() in ['.dat', '.bdf']:
                return f

        raise FileNotFoundError(f"No geometry file found for {op2_path}")

    def _load_data(self):
        """Load geometry and displacement data."""
        if self._node_geo is not None:
            return

        _load_dependencies()

        bdf = _BDF()
        bdf.read_bdf(str(self.geo_path))
        self._node_geo = {int(nid): node.get_position()
                         for nid, node in bdf.nodes.items()}

        op2 = _OP2()
        op2.read_op2(str(self.op2_path))

        if not op2.displacements:
            raise RuntimeError("No displacement data in OP2")

        self._displacements = {}
        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]
            isubcase = getattr(darr, 'isubcase', None)
            label = str(isubcase) if isubcase else str(key)

            self._displacements[label] = {
                'node_ids': node_ids.astype(int),
                'disp': dmat.copy()
            }

    def _build_wfe_arrays_standard(
        self,
        label: str,
        disp_unit: str = 'mm'
    ) -> Dict[str, np.ndarray]:
        """Build arrays using standard Z-only method."""
        nm_per_unit = 1e6 if disp_unit == 'mm' else 1e9

        data = self._displacements[label]
        node_ids = data['node_ids']
        dmat = data['disp']

        X, Y, WFE = [], [], []
        valid_nids = []
        dx_arr, dy_arr, dz_arr = [], [], []

        for nid, vec in zip(node_ids, dmat):
            geo = self._node_geo.get(int(nid))
            if geo is None:
                continue
            X.append(geo[0])
            Y.append(geo[1])
            wfe = vec[2] * 2.0 * nm_per_unit
            WFE.append(wfe)
            valid_nids.append(nid)
            dx_arr.append(vec[0])
            dy_arr.append(vec[1])
            dz_arr.append(vec[2])

        return {
            'X': np.array(X),
            'Y': np.array(Y),
            'WFE': np.array(WFE),
            'node_ids': np.array(valid_nids),
            'dx': np.array(dx_arr),
            'dy': np.array(dy_arr),
            'dz': np.array(dz_arr),
            'lateral_disp': np.sqrt(np.array(dx_arr)**2 + np.array(dy_arr)**2),
        }

    def _build_wfe_arrays_opd(
        self,
        label: str,
        disp_unit: str = 'mm'
    ) -> Dict[str, np.ndarray]:
        """Build arrays using OPD method (recommended)."""
        _load_dependencies()

        extractor = _ZernikeOPDExtractor(
            self.op2_path,
            bdf_path=self.geo_path,
            displacement_unit=disp_unit
        )

        opd_data = extractor._build_figure_opd_data(label)

        return {
            'X': opd_data['x_deformed'],
            'Y': opd_data['y_deformed'],
            'WFE': opd_data['wfe_nm'],
            'node_ids': opd_data['node_ids'],
            'dx': opd_data['dx'],
            'dy': opd_data['dy'],
            'dz': opd_data['dz'],
            'lateral_disp': opd_data['lateral_disp'],
            'x_original': opd_data['x_original'],
            'y_original': opd_data['y_original'],
        }

    def _build_wfe_arrays(
        self,
        label: str,
        disp_unit: str = 'mm',
        use_opd: bool = True
    ) -> Dict[str, np.ndarray]:
        """Build X, Y, WFE arrays for a subcase."""
        if use_opd:
            return self._build_wfe_arrays_opd(label, disp_unit)
        else:
            return self._build_wfe_arrays_standard(label, disp_unit)

    def _compute_relative_wfe(
        self,
        data1: Dict[str, np.ndarray],
        data2: Dict[str, np.ndarray]
    ) -> Dict[str, np.ndarray]:
        """Compute relative WFE (target - reference) for common nodes."""
        X1, Y1, WFE1, nids1 = data1['X'], data1['Y'], data1['WFE'], data1['node_ids']
        X2, Y2, WFE2, nids2 = data2['X'], data2['Y'], data2['WFE'], data2['node_ids']

        ref_map = {int(nid): (x, y, w) for nid, x, y, w in zip(nids2, X2, Y2, WFE2)}

        dx1_map = {int(nid): d for nid, d in zip(data1['node_ids'], data1['dx'])}
        dy1_map = {int(nid): d for nid, d in zip(data1['node_ids'], data1['dy'])}
        dz1_map = {int(nid): d for nid, d in zip(data1['node_ids'], data1['dz'])}
        dx2_map = {int(nid): d for nid, d in zip(data2['node_ids'], data2['dx'])}
        dy2_map = {int(nid): d for nid, d in zip(data2['node_ids'], data2['dy'])}
        dz2_map = {int(nid): d for nid, d in zip(data2['node_ids'], data2['dz'])}

        X_rel, Y_rel, WFE_rel, nids_rel = [], [], [], []
        dx_rel, dy_rel, dz_rel = [], [], []

        for nid, x, y, w in zip(nids1, X1, Y1, WFE1):
            nid = int(nid)
            if nid not in ref_map or nid not in dx2_map:
                continue

            _, _, w_ref = ref_map[nid]
            X_rel.append(x)
            Y_rel.append(y)
            WFE_rel.append(w - w_ref)
            nids_rel.append(nid)
            dx_rel.append(dx1_map[nid] - dx2_map[nid])
            dy_rel.append(dy1_map[nid] - dy2_map[nid])
            dz_rel.append(dz1_map[nid] - dz2_map[nid])

        return {
            'X': np.array(X_rel),
            'Y': np.array(Y_rel),
            'WFE': np.array(WFE_rel),
            'node_ids': np.array(nids_rel),
            'dx': np.array(dx_rel),
            'dy': np.array(dy_rel),
            'dz': np.array(dz_rel),
            'lateral_disp': np.sqrt(np.array(dx_rel)**2 + np.array(dy_rel)**2) if dx_rel else np.array([]),
        }

    def _compute_metrics(
        self,
        X: np.ndarray,
        Y: np.ndarray,
        W_nm: np.ndarray,
        n_modes: int,
        filter_orders: int,
        cfg: Dict
    ) -> Dict[str, Any]:
        """Compute comprehensive metrics including band analysis."""
        coeffs, R = compute_zernike_coeffs(X, Y, W_nm, n_modes)

        Xc = X - np.mean(X)
        Yc = Y - np.mean(Y)
        r = np.hypot(Xc / R, Yc / R)
        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_orders].dot(coeffs[:filter_orders])
        W_res_filt_j1to3 = W_nm - Z[:, :3].dot(coeffs[:3])

        # Band analysis
        bands = classify_modes_by_band(n_modes, cfg['lsf_max'], cfg['msf_max'])
        band_rss = compute_band_rss(coeffs, bands)

        # Aberration magnitudes
        aberrations = {
            'defocus': float(abs(coeffs[3])) if len(coeffs) > 3 else 0.0,
            'astigmatism': float(np.sqrt(coeffs[4]**2 + coeffs[5]**2)) if len(coeffs) > 5 else 0.0,
            'coma': float(np.sqrt(coeffs[6]**2 + coeffs[7]**2)) if len(coeffs) > 7 else 0.0,
            'trefoil': float(np.sqrt(coeffs[8]**2 + coeffs[9]**2)) if len(coeffs) > 9 else 0.0,
            'spherical': float(abs(coeffs[10])) if len(coeffs) > 10 else 0.0,
        }

        return {
            'coefficients': coeffs,
            'R': R,
            'global_rms': float(np.sqrt(np.mean(W_nm**2))),
            'filtered_rms': float(np.sqrt(np.mean(W_res_filt**2))),
            'rms_j1to3': float(np.sqrt(np.mean(W_res_filt_j1to3**2))),
            'W_res_filt': W_res_filt,
            'band_rss': band_rss,
            'aberrations': aberrations,
        }

    def _generate_html(
        self,
        all_data: Dict[str, Dict],
        cfg: Dict,
        timestamp: str
    ) -> str:
        """Generate comprehensive HTML dashboard."""
        _load_dependencies()

        theme = LIGHT_THEME

        # Extract key metrics for executive summary
        metrics_40 = all_data.get('40_vs_20', {}).get('metrics', {})
        metrics_60 = all_data.get('60_vs_20', {}).get('metrics', {})
        metrics_90 = all_data.get('90_abs', {}).get('metrics', {})

        rms_40 = metrics_40.get('filtered_rms', 0)
        rms_60 = metrics_60.get('filtered_rms', 0)
        rms_90_mfg = metrics_90.get('rms_j1to3', 0)

        # Generate HTML
        html = f'''<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Zernike Dashboard - Atomizer</title>
    <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
    <style>
        * {{
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }}

        body {{
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            background: {theme['bg']};
            color: {theme['text']};
            line-height: 1.5;
        }}

        .dashboard {{
            max-width: 1600px;
            margin: 0 auto;
            padding: 24px;
        }}

        .header {{
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 24px;
            padding-bottom: 16px;
            border-bottom: 2px solid {theme['card_border']};
        }}

        .header h1 {{
            font-size: 28px;
            font-weight: 700;
            color: {theme['accent']};
        }}

        .header .subtitle {{
            color: {theme['text_muted']};
            font-size: 14px;
        }}

        .timestamp {{
            color: {theme['text_muted']};
            font-size: 12px;
        }}

        /* Executive Summary */
        .summary-grid {{
            display: grid;
            grid-template-columns: repeat(4, 1fr);
            gap: 16px;
            margin-bottom: 24px;
        }}

        .metric-card {{
            background: {theme['card_bg']};
            border: 1px solid {theme['card_border']};
            border-radius: 12px;
            padding: 20px;
            text-align: center;
            transition: transform 0.2s, box-shadow 0.2s;
        }}

        .metric-card:hover {{
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
        }}

        .metric-card.best {{
            border-color: {theme['success']};
            background: linear-gradient(to bottom, #ecfdf5, {theme['card_bg']});
        }}

        .metric-card.warning {{
            border-color: {theme['warning']};
            background: linear-gradient(to bottom, #fffbeb, {theme['card_bg']});
        }}

        .metric-label {{
            font-size: 12px;
            font-weight: 600;
            color: {theme['text_muted']};
            text-transform: uppercase;
            letter-spacing: 0.5px;
            margin-bottom: 8px;
        }}

        .metric-value {{
            font-size: 32px;
            font-weight: 700;
            color: {theme['text']};
            font-variant-numeric: tabular-nums;
        }}

        .metric-value.success {{
            color: {theme['success']};
        }}

        .metric-value.warning {{
            color: {theme['warning']};
        }}

        .metric-value.danger {{
            color: {theme['danger']};
        }}

        .metric-unit {{
            font-size: 14px;
            color: {theme['text_muted']};
            font-weight: 400;
        }}

        .metric-target {{
            font-size: 11px;
            color: {theme['text_muted']};
            margin-top: 4px;
        }}

        /* Section Cards */
        .section {{
            background: {theme['card_bg']};
            border: 1px solid {theme['card_border']};
            border-radius: 12px;
            margin-bottom: 24px;
            overflow: hidden;
        }}

        .section-header {{
            padding: 16px 20px;
            background: {theme['surface_bg']};
            border-bottom: 1px solid {theme['card_border']};
            display: flex;
            justify-content: space-between;
            align-items: center;
        }}

        .section-title {{
            font-size: 16px;
            font-weight: 600;
            color: {theme['text']};
        }}

        .section-subtitle {{
            font-size: 12px;
            color: {theme['text_muted']};
        }}

        .section-content {{
            padding: 20px;
        }}

        /* Comparison Table */
        .comparison-table {{
            width: 100%;
            border-collapse: collapse;
            font-size: 14px;
        }}

        .comparison-table th {{
            text-align: left;
            padding: 12px 16px;
            background: {theme['surface_bg']};
            border-bottom: 1px solid {theme['card_border']};
            font-weight: 600;
            color: {theme['text']};
        }}

        .comparison-table td {{
            padding: 12px 16px;
            border-bottom: 1px solid {theme['card_border']};
        }}

        .comparison-table tr:last-child td {{
            border-bottom: none;
        }}

        .comparison-table tr:hover {{
            background: {theme['surface_bg']};
        }}

        .value-cell {{
            font-variant-numeric: tabular-nums;
            font-weight: 500;
        }}

        .value-cell.pass {{
            color: {theme['success']};
        }}

        .value-cell.warn {{
            color: {theme['warning']};
        }}

        .value-cell.fail {{
            color: {theme['danger']};
        }}

        /* Tab Navigation */
        .tab-nav {{
            display: flex;
            gap: 8px;
            margin-bottom: 16px;
        }}

        .tab-btn {{
            padding: 8px 16px;
            background: {theme['surface_bg']};
            border: 1px solid {theme['card_border']};
            border-radius: 8px;
            cursor: pointer;
            font-size: 13px;
            font-weight: 500;
            color: {theme['text_muted']};
            transition: all 0.2s;
        }}

        .tab-btn:hover {{
            background: {theme['card_border']};
        }}

        .tab-btn.active {{
            background: {theme['accent']};
            color: white;
            border-color: {theme['accent']};
        }}

        /* Plot Container */
        .plot-container {{
            border-radius: 8px;
            overflow: hidden;
        }}

        /* Band Legend */
        .band-legend {{
            display: flex;
            gap: 24px;
            justify-content: center;
            padding: 12px;
            background: {theme['surface_bg']};
            border-radius: 8px;
            margin-top: 16px;
        }}

        .band-item {{
            display: flex;
            align-items: center;
            gap: 8px;
            font-size: 13px;
        }}

        .band-dot {{
            width: 12px;
            height: 12px;
            border-radius: 50%;
        }}

        /* Grid layouts */
        .plots-grid {{
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 16px;
        }}

        .plot-card {{
            background: {theme['bg']};
            border: 1px solid {theme['card_border']};
            border-radius: 8px;
            overflow: hidden;
        }}

        .plot-header {{
            padding: 12px 16px;
            background: {theme['surface_bg']};
            border-bottom: 1px solid {theme['card_border']};
            font-weight: 600;
            font-size: 14px;
            text-align: center;
        }}

        .plot-header.angle-40 {{ color: {ANGLE_COLORS['40']}; }}
        .plot-header.angle-60 {{ color: {ANGLE_COLORS['60']}; }}
        .plot-header.angle-90 {{ color: {ANGLE_COLORS['90']}; }}

        /* Responsive */
        @media (max-width: 1200px) {{
            .summary-grid {{
                grid-template-columns: repeat(2, 1fr);
            }}
            .plots-grid {{
                grid-template-columns: 1fr;
            }}
        }}

        /* Print styles */
        @media print {{
            .dashboard {{
                max-width: 100%;
            }}
        }}
    </style>
</head>
<body>
    <div class="dashboard">
        <!-- Header -->
        <div class="header">
            <div>
                <h1>Zernike WFE Dashboard</h1>
                <div class="subtitle">M1 Mirror Optimization Analysis • OPD Method (X,Y,Z corrected)</div>
            </div>
            <div class="timestamp">Generated: {timestamp}</div>
        </div>

        <!-- Executive Summary -->
        <div class="summary-grid">
            <div class="metric-card{' best' if rms_40 <= 4.0 else ' warning' if rms_40 <= 6.0 else ''}">
                <div class="metric-label">40° vs 20° RMS</div>
                <div class="metric-value{' success' if rms_40 <= 4.0 else ' warning' if rms_40 <= 6.0 else ' danger'}">
                    {rms_40:.2f}<span class="metric-unit"> nm</span>
                </div>
                <div class="metric-target">Target: ≤ 4.0 nm</div>
            </div>

            <div class="metric-card{' best' if rms_60 <= 10.0 else ' warning' if rms_60 <= 15.0 else ''}">
                <div class="metric-label">60° vs 20° RMS</div>
                <div class="metric-value{' success' if rms_60 <= 10.0 else ' warning' if rms_60 <= 15.0 else ' danger'}">
                    {rms_60:.2f}<span class="metric-unit"> nm</span>
                </div>
                <div class="metric-target">Target: ≤ 10.0 nm</div>
            </div>

            <div class="metric-card{' best' if rms_90_mfg <= 20.0 else ' warning' if rms_90_mfg <= 30.0 else ''}">
                <div class="metric-label">90° MFG (J1-J3)</div>
                <div class="metric-value{' success' if rms_90_mfg <= 20.0 else ' warning' if rms_90_mfg <= 30.0 else ' danger'}">
                    {rms_90_mfg:.2f}<span class="metric-unit"> nm</span>
                </div>
                <div class="metric-target">Target: ≤ 20.0 nm</div>
            </div>

            <div class="metric-card">
                <div class="metric-label">Weighted Score</div>
                <div class="metric-value">
                    {self._compute_weighted_score(rms_40, rms_60, rms_90_mfg):.1f}
                </div>
                <div class="metric-target">Lower is better</div>
            </div>
        </div>

        <!-- Detailed Metrics Table -->
        <div class="section">
            <div class="section-header">
                <div>
                    <div class="section-title">Detailed Metrics Comparison</div>
                    <div class="section-subtitle">All values in nm • J1-J4 filtered (piston, tip, tilt, defocus removed)</div>
                </div>
            </div>
            <div class="section-content">
                <table class="comparison-table">
                    <thead>
                        <tr>
                            <th>Metric</th>
                            <th style="text-align:center">40° vs 20°</th>
                            <th style="text-align:center">60° vs 20°</th>
                            <th style="text-align:center">90° MFG</th>
                            <th style="text-align:center">Target</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr>
                            <td><strong>Filtered RMS (J1-J4)</strong></td>
                            <td class="value-cell{self._get_status_class(rms_40, 4.0, 6.0)}" style="text-align:center">{rms_40:.2f}</td>
                            <td class="value-cell{self._get_status_class(rms_60, 10.0, 15.0)}" style="text-align:center">{rms_60:.2f}</td>
                            <td class="value-cell" style="text-align:center">—</td>
                            <td style="text-align:center;color:{theme['text_muted']}">≤ 4 / 10 nm</td>
                        </tr>
                        <tr>
                            <td><strong>Optician Workload (J1-J3)</strong></td>
                            <td class="value-cell" style="text-align:center">—</td>
                            <td class="value-cell" style="text-align:center">—</td>
                            <td class="value-cell{self._get_status_class(rms_90_mfg, 20.0, 30.0)}" style="text-align:center">{rms_90_mfg:.2f}</td>
                            <td style="text-align:center;color:{theme['text_muted']}">≤ 20 nm</td>
                        </tr>
                        <tr>
                            <td>Astigmatism (J5+J6)</td>
                            <td class="value-cell" style="text-align:center">{metrics_40.get('aberrations', {}).get('astigmatism', 0):.2f}</td>
                            <td class="value-cell" style="text-align:center">{metrics_60.get('aberrations', {}).get('astigmatism', 0):.2f}</td>
                            <td class="value-cell" style="text-align:center">{metrics_90.get('aberrations', {}).get('astigmatism', 0):.2f}</td>
                            <td style="text-align:center;color:{theme['text_muted']}">—</td>
                        </tr>
                        <tr>
                            <td>Coma (J7+J8)</td>
                            <td class="value-cell" style="text-align:center">{metrics_40.get('aberrations', {}).get('coma', 0):.2f}</td>
                            <td class="value-cell" style="text-align:center">{metrics_60.get('aberrations', {}).get('coma', 0):.2f}</td>
                            <td class="value-cell" style="text-align:center">{metrics_90.get('aberrations', {}).get('coma', 0):.2f}</td>
                            <td style="text-align:center;color:{theme['text_muted']}">—</td>
                        </tr>
                        <tr>
                            <td>Trefoil (J9+J10)</td>
                            <td class="value-cell" style="text-align:center">{metrics_40.get('aberrations', {}).get('trefoil', 0):.2f}</td>
                            <td class="value-cell" style="text-align:center">{metrics_60.get('aberrations', {}).get('trefoil', 0):.2f}</td>
                            <td class="value-cell" style="text-align:center">{metrics_90.get('aberrations', {}).get('trefoil', 0):.2f}</td>
                            <td style="text-align:center;color:{theme['text_muted']}">—</td>
                        </tr>
                        <tr>
                            <td>Spherical (J11)</td>
                            <td class="value-cell" style="text-align:center">{metrics_40.get('aberrations', {}).get('spherical', 0):.2f}</td>
                            <td class="value-cell" style="text-align:center">{metrics_60.get('aberrations', {}).get('spherical', 0):.2f}</td>
                            <td class="value-cell" style="text-align:center">{metrics_90.get('aberrations', {}).get('spherical', 0):.2f}</td>
                            <td style="text-align:center;color:{theme['text_muted']}">—</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>

        <!-- MSF Band Analysis -->
        <div class="section">
            <div class="section-header">
                <div>
                    <div class="section-title">Spatial Frequency Band Analysis</div>
                    <div class="section-subtitle">RSS decomposition • LSF: n≤10 (M2 correctable) • MSF: n=11-50 (support print-through) • HSF: n>50</div>
                </div>
            </div>
            <div class="section-content">
                <table class="comparison-table">
                    <thead>
                        <tr>
                            <th>Band</th>
                            <th style="text-align:center">40° vs 20°</th>
                            <th style="text-align:center">60° vs 20°</th>
                            <th style="text-align:center">90° MFG</th>
                            <th>Physics</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr>
                            <td><span style="color:{BAND_COLORS['lsf']}">●</span> LSF (n ≤ 10)</td>
                            <td class="value-cell" style="text-align:center">{metrics_40.get('band_rss', {}).get('lsf', 0):.2f} nm</td>
                            <td class="value-cell" style="text-align:center">{metrics_60.get('band_rss', {}).get('lsf', 0):.2f} nm</td>
                            <td class="value-cell" style="text-align:center">{metrics_90.get('band_rss', {}).get('lsf', 0):.2f} nm</td>
                            <td style="color:{theme['text_muted']}">M2 hexapod correctable</td>
                        </tr>
                        <tr>
                            <td><span style="color:{BAND_COLORS['msf']}">●</span> MSF (n = 11-50)</td>
                            <td class="value-cell" style="text-align:center">{metrics_40.get('band_rss', {}).get('msf', 0):.2f} nm</td>
                            <td class="value-cell" style="text-align:center">{metrics_60.get('band_rss', {}).get('msf', 0):.2f} nm</td>
                            <td class="value-cell" style="text-align:center">{metrics_90.get('band_rss', {}).get('msf', 0):.2f} nm</td>
                            <td style="color:{theme['text_muted']}">Support print-through</td>
                        </tr>
                        <tr>
                            <td><span style="color:{BAND_COLORS['hsf']}">●</span> HSF (n > 50)</td>
                            <td class="value-cell" style="text-align:center">{metrics_40.get('band_rss', {}).get('hsf', 0):.2f} nm</td>
                            <td class="value-cell" style="text-align:center">{metrics_60.get('band_rss', {}).get('hsf', 0):.2f} nm</td>
                            <td class="value-cell" style="text-align:center">{metrics_90.get('band_rss', {}).get('hsf', 0):.2f} nm</td>
                            <td style="color:{theme['text_muted']}">High frequency (mesh limit)</td>
                        </tr>
                        <tr style="background:{theme['surface_bg']}">
                            <td><strong>Total RSS</strong></td>
                            <td class="value-cell" style="text-align:center"><strong>{metrics_40.get('band_rss', {}).get('total', 0):.2f} nm</strong></td>
                            <td class="value-cell" style="text-align:center"><strong>{metrics_60.get('band_rss', {}).get('total', 0):.2f} nm</strong></td>
                            <td class="value-cell" style="text-align:center"><strong>{metrics_90.get('band_rss', {}).get('total', 0):.2f} nm</strong></td>
                            <td></td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>

        <!-- Surface Plots -->
        <div class="section">
            <div class="section-header">
                <div>
                    <div class="section-title">Surface Residual Visualization</div>
                    <div class="section-subtitle">WFE residual after J1-J4 removal • Visual amplification applied for clarity</div>
                </div>
            </div>
            <div class="section-content">
                <div class="plots-grid">
                    <div class="plot-card">
                        <div class="plot-header angle-40">40° vs 20° (Relative)</div>
                        <div id="plot-40" class="plot-container"></div>
                    </div>
                    <div class="plot-card">
                        <div class="plot-header angle-60">60° vs 20° (Relative)</div>
                        <div id="plot-60" class="plot-container"></div>
                    </div>
                    <div class="plot-card">
                        <div class="plot-header angle-90">90° Manufacturing (Absolute)</div>
                        <div id="plot-90" class="plot-container"></div>
                    </div>
                </div>
            </div>
        </div>

        <!-- Zernike Coefficients Bar Chart -->
        <div class="section">
            <div class="section-header">
                <div>
                    <div class="section-title">Zernike Coefficient Magnitudes</div>
                    <div class="section-subtitle">Absolute values • Color-coded by spatial frequency band</div>
                </div>
            </div>
            <div class="section-content">
                <div id="coeff-chart" class="plot-container"></div>
                <div class="band-legend">
                    <div class="band-item">
                        <div class="band-dot" style="background:{BAND_COLORS['lsf']}"></div>
                        <span>LSF (n ≤ 10)</span>
                    </div>
                    <div class="band-item">
                        <div class="band-dot" style="background:{BAND_COLORS['msf']}"></div>
                        <span>MSF (n = 11-50)</span>
                    </div>
                    <div class="band-item">
                        <div class="band-dot" style="background:{BAND_COLORS['hsf']}"></div>
                        <span>HSF (n > 50)</span>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        // Plot data
        {self._generate_plot_js(all_data, cfg)}
    </script>
</body>
</html>'''

        return html

    def _compute_weighted_score(self, rms_40: float, rms_60: float, rms_90: float) -> float:
        """Compute weighted optimization score."""
        # Standard weights from M1 optimization
        w40 = 100 / 4.0    # Target 4 nm
        w60 = 50 / 10.0    # Target 10 nm
        w90 = 20 / 20.0    # Target 20 nm
        return w40 * rms_40 + w60 * rms_60 + w90 * rms_90

    def _get_status_class(self, value: float, target: float, warn: float) -> str:
        """Get CSS class for value status."""
        if value <= target:
            return ' pass'
        elif value <= warn:
            return ' warn'
        else:
            return ' fail'

    def _generate_plot_js(self, all_data: Dict[str, Dict], cfg: Dict) -> str:
        """Generate JavaScript for Plotly plots."""
        _load_dependencies()

        amp = cfg.get('amp', 0.5)
        downsample = cfg.get('plot_downsample', 8000)

        js_parts = []

        # Generate 3D surface plots for each angle
        for key, angle, div_id in [
            ('40_vs_20', '40', 'plot-40'),
            ('60_vs_20', '60', 'plot-60'),
            ('90_abs', '90', 'plot-90'),
        ]:
            data = all_data.get(key, {})
            X = data.get('X', np.array([]))
            Y = data.get('Y', np.array([]))
            W = data.get('metrics', {}).get('W_res_filt', np.array([]))

            if len(X) == 0:
                continue

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

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

            # Build triangulation
            try:
                tri = _Triangulation(Xp, Yp)
                i_arr = tri.triangles[:, 0].tolist()
                j_arr = tri.triangles[:, 1].tolist()
                k_arr = tri.triangles[:, 2].tolist()
            except:
                i_arr, j_arr, k_arr = [], [], []

            color = ANGLE_COLORS[angle]

            js_parts.append(f'''
        // {angle}° Plot
        Plotly.newPlot('{div_id}', [{{
            type: 'mesh3d',
            x: {Xp.tolist()},
            y: {Yp.tolist()},
            z: {res_amp.tolist()},
            i: {i_arr},
            j: {j_arr},
            k: {k_arr},
            intensity: {res_amp.tolist()},
            colorscale: 'RdBu_r',
            showscale: true,
            colorbar: {{
                title: 'WFE (nm)',
                thickness: 12,
                len: 0.6
            }},
            lighting: {{
                ambient: 0.5,
                diffuse: 0.8,
                specular: 0.3
            }},
            hovertemplate: 'X: %{{x:.1f}} mm<br>Y: %{{y:.1f}} mm<br>WFE: %{{z:.2f}} nm<extra></extra>'
        }}], {{
            margin: {{t: 10, b: 10, l: 10, r: 10}},
            scene: {{
                camera: {{eye: {{x: 1.2, y: 1.2, z: 0.8}}}},
                xaxis: {{title: 'X (mm)', showgrid: true, gridcolor: 'rgba(100,100,100,0.2)'}},
                yaxis: {{title: 'Y (mm)', showgrid: true, gridcolor: 'rgba(100,100,100,0.2)'}},
                zaxis: {{title: 'WFE (nm)', range: [{-max_amp * 3}, {max_amp * 3}], showgrid: true, gridcolor: 'rgba(100,100,100,0.2)'}},
                aspectmode: 'manual',
                aspectratio: {{x: 1, y: 1, z: 0.4}},
                bgcolor: '#f8fafc'
            }},
            paper_bgcolor: '#ffffff'
        }}, {{responsive: true}});
        ''')

        # Generate coefficient bar chart
        metrics_40 = all_data.get('40_vs_20', {}).get('metrics', {})
        coeffs = metrics_40.get('coefficients', np.array([]))
        n_modes = len(coeffs) if len(coeffs) > 0 else 50

        if len(coeffs) > 0:
            bands = classify_modes_by_band(n_modes, cfg.get('lsf_max', 10), cfg.get('msf_max', 50))
            bar_colors = []
            labels = []
            for j in range(1, n_modes + 1):
                n, m = noll_indices(j)
                labels.append(f'J{j} {zernike_common_name(n, m)}')
                if j in bands['lsf']:
                    bar_colors.append(BAND_COLORS['lsf'])
                elif j in bands['msf']:
                    bar_colors.append(BAND_COLORS['msf'])
                else:
                    bar_colors.append(BAND_COLORS['hsf'])

            coeff_abs = np.abs(coeffs).tolist()

            js_parts.append(f'''
        // Coefficient Bar Chart
        Plotly.newPlot('coeff-chart', [{{
            type: 'bar',
            x: {coeff_abs},
            y: {labels},
            orientation: 'h',
            marker: {{
                color: {bar_colors}
            }},
            hovertemplate: '%{{y}}<br>|Coeff| = %{{x:.3f}} nm<extra></extra>'
        }}], {{
            margin: {{t: 20, b: 40, l: 200, r: 20}},
            height: 800,
            xaxis: {{title: '|Coefficient| (nm)', gridcolor: 'rgba(100,100,100,0.2)'}},
            yaxis: {{autorange: 'reversed'}},
            paper_bgcolor: '#ffffff',
            plot_bgcolor: '#f8fafc'
        }}, {{responsive: true}});
        ''')

        return '\n'.join(js_parts)

    def _generate(self, config: InsightConfig) -> InsightResult:
        """Generate comprehensive Zernike dashboard."""
        self._load_data()

        cfg = {**DEFAULT_CONFIG, **config.extra}
        n_modes = cfg['n_modes']
        filter_orders = cfg['filter_low_orders']
        disp_unit = cfg['disp_unit']
        use_opd = cfg['use_opd']

        # Map subcases
        disps = self._displacements
        if '1' in disps and '2' in disps:
            sc_map = {'90': '1', '20': '2', '40': '3', '60': '4'}
        elif '90' in disps and '20' in disps:
            sc_map = {'90': '90', '20': '20', '40': '40', '60': '60'}
        else:
            available = sorted(disps.keys(), key=lambda x: int(x) if x.isdigit() else 0)
            if len(available) >= 4:
                sc_map = {'90': available[0], '20': available[1],
                          '40': available[2], '60': available[3]}
            else:
                return InsightResult(success=False,
                                     error=f"Need 4 subcases, found: {available}")

        # Validate
        for angle, label in sc_map.items():
            if label not in disps:
                return InsightResult(success=False,
                                     error=f"Subcase '{label}' (angle {angle}) not found")

        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        output_dir = config.output_dir or self.insights_path
        output_dir.mkdir(parents=True, exist_ok=True)

        all_data = {}

        # Load reference data (20°)
        data_ref = self._build_wfe_arrays(sc_map['20'], disp_unit, use_opd)

        # Process each angle
        for angle in ['40', '60']:
            data = self._build_wfe_arrays(sc_map[angle], disp_unit, use_opd)
            rel_data = self._compute_relative_wfe(data, data_ref)

            metrics = self._compute_metrics(
                rel_data['X'], rel_data['Y'], rel_data['WFE'],
                n_modes, filter_orders, cfg
            )

            all_data[f'{angle}_vs_20'] = {
                'X': rel_data['X'],
                'Y': rel_data['Y'],
                'WFE': rel_data['WFE'],
                'metrics': metrics,
            }

        # 90° absolute (manufacturing)
        data_90 = self._build_wfe_arrays(sc_map['90'], disp_unit, use_opd)
        metrics_90 = self._compute_metrics(
            data_90['X'], data_90['Y'], data_90['WFE'],
            n_modes, filter_orders, cfg
        )
        all_data['90_abs'] = {
            'X': data_90['X'],
            'Y': data_90['Y'],
            'WFE': data_90['WFE'],
            'metrics': metrics_90,
        }

        # Generate HTML
        html = self._generate_html(all_data, cfg, timestamp)

        file_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        html_path = output_dir / f"zernike_dashboard_{file_timestamp}.html"
        html_path.write_text(html, encoding='utf-8')

        # Build summary
        summary = {
            '40_vs_20_filtered_rms': all_data['40_vs_20']['metrics']['filtered_rms'],
            '60_vs_20_filtered_rms': all_data['60_vs_20']['metrics']['filtered_rms'],
            '90_mfg_rms_j1to3': all_data['90_abs']['metrics']['rms_j1to3'],
            '90_mfg_filtered_rms': all_data['90_abs']['metrics']['filtered_rms'],
            'weighted_score': self._compute_weighted_score(
                all_data['40_vs_20']['metrics']['filtered_rms'],
                all_data['60_vs_20']['metrics']['filtered_rms'],
                all_data['90_abs']['metrics']['rms_j1to3']
            ),
            'method': 'OPD' if use_opd else 'Standard',
            'n_modes': n_modes,
        }

        return InsightResult(
            success=True,
            html_path=html_path,
            summary=summary
        )
