"""
Displacement Field Extraction for GNN Training
===============================================

This module extracts full displacement fields from Nastran OP2 files for GNN training.
Unlike the Zernike extractors that reduce to coefficients, this preserves the raw
spatial data that GNN needs to learn the physics.

Key Features:
1. Extract Z-displacement for all optical surface nodes
2. Store node coordinates for graph construction
3. Support for multiple subcases (gravity orientations)
4. HDF5 storage for efficient loading during training

Output Format (HDF5):
    /node_ids         - [n_nodes] int array
    /node_coords      - [n_nodes, 3] float array (X, Y, Z)
    /subcase_1        - [n_nodes] Z-displacement for subcase 1
    /subcase_2        - [n_nodes] Z-displacement for subcase 2
    /subcase_3        - [n_nodes] Z-displacement for subcase 3
    /subcase_4        - [n_nodes] Z-displacement for subcase 4
    /metadata         - JSON string with extraction info

Usage:
    from optimization_engine.gnn.extract_displacement_field import extract_displacement_field

    field_data = extract_displacement_field(op2_path, bdf_path)
    save_field_to_hdf5(field_data, output_path)
"""

import json
import numpy as np
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime

try:
    import h5py
    HAS_H5PY = True
except ImportError:
    HAS_H5PY = False

from pyNastran.op2.op2 import OP2
from pyNastran.bdf.bdf import BDF


def identify_optical_surface_nodes(
    node_coords: Dict[int, np.ndarray],
    r_inner: float = 100.0,
    r_outer: float = 650.0,
    z_tolerance: float = 100.0
) -> Tuple[List[int], np.ndarray]:
    """
    Identify nodes on the optical surface by spatial filtering.

    The optical surface is identified by:
    1. Radial position (between inner and outer radius)
    2. Consistent Z range (nodes on the curved mirror surface)

    Args:
        node_coords: Dictionary mapping node ID to (X, Y, Z) coordinates
        r_inner: Inner radius cutoff (central hole)
        r_outer: Outer radius limit
        z_tolerance: Maximum Z deviation from mean to include

    Returns:
        Tuple of (node_ids list, coordinates array [n, 3])
    """
    # Get all coordinates as arrays
    nids = list(node_coords.keys())
    coords = np.array([node_coords[nid] for nid in nids])

    # Calculate radial position
    r = np.sqrt(coords[:, 0]**2 + coords[:, 1]**2)

    # Initial radial filter
    radial_mask = (r >= r_inner) & (r <= r_outer)

    # Find nodes in radial range
    radial_nids = np.array(nids)[radial_mask]
    radial_coords = coords[radial_mask]

    if len(radial_coords) == 0:
        raise ValueError(f"No nodes found in radial range [{r_inner}, {r_outer}]")

    # The optical surface should have a relatively small Z range
    z_vals = radial_coords[:, 2]
    z_mean = np.mean(z_vals)

    # Filter to nodes within z_tolerance of the mean Z
    z_mask = np.abs(radial_coords[:, 2] - z_mean) < z_tolerance

    surface_nids = radial_nids[z_mask].tolist()
    surface_coords = radial_coords[z_mask]

    return surface_nids, surface_coords


def extract_displacement_field(
    op2_path: Path,
    bdf_path: Optional[Path] = None,
    r_inner: float = 100.0,
    r_outer: float = 650.0,
    subcases: Optional[List[int]] = None,
    verbose: bool = True
) -> Dict[str, Any]:
    """
    Extract full displacement field for GNN training.

    This function extracts Z-displacement from OP2 files for nodes on the optical
    surface (defined by radial position). It builds node coordinates directly from
    the OP2 data matched against BDF geometry, then filters by radial position.

    Args:
        op2_path: Path to OP2 file
        bdf_path: Path to BDF/DAT file (auto-detected if None)
        r_inner: Inner radius for surface identification (mm)
        r_outer: Outer radius for surface identification (mm)
        subcases: List of subcases to extract (default: [1, 2, 3, 4])
        verbose: Print progress messages

    Returns:
        Dictionary containing:
            - node_ids: List of node IDs on optical surface
            - node_coords: Array [n_nodes, 3] of coordinates
            - z_displacement: Dict mapping subcase -> [n_nodes] Z-displacements
            - metadata: Extraction metadata
    """
    op2_path = Path(op2_path)

    # Find BDF file
    if bdf_path is None:
        for ext in ['.dat', '.bdf']:
            candidate = op2_path.with_suffix(ext)
            if candidate.exists():
                bdf_path = candidate
                break
        if bdf_path is None:
            raise FileNotFoundError(f"No .dat or .bdf found for {op2_path}")

    if subcases is None:
        subcases = [1, 2, 3, 4]

    if verbose:
        print(f"[FIELD] Reading geometry from: {bdf_path.name}")

    # Read geometry from BDF
    bdf = BDF()
    bdf.read_bdf(str(bdf_path))
    node_geo = {int(nid): node.get_position() for nid, node in bdf.nodes.items()}

    if verbose:
        print(f"[FIELD] Total nodes in BDF: {len(node_geo)}")

    # Read OP2
    if verbose:
        print(f"[FIELD] Reading displacements from: {op2_path.name}")
    op2 = OP2()
    op2.read_op2(str(op2_path))

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

    # Extract data by iterating through OP2 nodes and matching to BDF geometry
    # This approach works even when node numbering differs between sources
    subcase_data = {}

    for key, darr in op2.displacements.items():
        isub = int(getattr(darr, 'isubcase', key))
        if isub not in subcases:
            continue

        data = darr.data
        dmat = data[0] if data.ndim == 3 else data
        ngt = darr.node_gridtype
        op2_node_ids = ngt[:, 0] if ngt.ndim == 2 else ngt

        # Build arrays of matched data
        nids = []
        X = []
        Y = []
        Z = []
        disp_z = []

        for i, nid in enumerate(op2_node_ids):
            nid_int = int(nid)
            if nid_int in node_geo:
                pos = node_geo[nid_int]
                nids.append(nid_int)
                X.append(pos[0])
                Y.append(pos[1])
                Z.append(pos[2])
                disp_z.append(float(dmat[i, 2]))  # Z component

        X = np.array(X, dtype=np.float32)
        Y = np.array(Y, dtype=np.float32)
        Z = np.array(Z, dtype=np.float32)
        disp_z = np.array(disp_z, dtype=np.float32)
        nids = np.array(nids, dtype=np.int32)

        # Filter to optical surface by radial position
        r = np.sqrt(X**2 + Y**2)
        surface_mask = (r >= r_inner) & (r <= r_outer)

        subcase_data[isub] = {
            'node_ids': nids[surface_mask],
            'coords': np.column_stack([X[surface_mask], Y[surface_mask], Z[surface_mask]]),
            'disp_z': disp_z[surface_mask],
        }

        if verbose:
            print(f"[FIELD] Subcase {isub}: {len(nids)} matched, {np.sum(surface_mask)} on surface")

    # Get common nodes across all subcases (should be the same)
    all_subcase_keys = list(subcase_data.keys())
    if not all_subcase_keys:
        raise RuntimeError("No subcases found in OP2")

    # Use first subcase to define node list
    ref_subcase = all_subcase_keys[0]
    surface_nids = subcase_data[ref_subcase]['node_ids'].tolist()
    surface_coords = subcase_data[ref_subcase]['coords']

    # Build displacement dict for all subcases
    z_displacement = {}
    for isub in subcases:
        if isub in subcase_data:
            z_displacement[isub] = subcase_data[isub]['disp_z']

    if verbose:
        print(f"[FIELD] Final surface: {len(surface_nids)} nodes")
        r_surface = np.sqrt(surface_coords[:, 0]**2 + surface_coords[:, 1]**2)
        print(f"[FIELD]   Radial range: [{r_surface.min():.1f}, {r_surface.max():.1f}] mm")

    # Build metadata
    metadata = {
        'extraction_timestamp': datetime.now().isoformat(),
        'op2_file': str(op2_path.name),
        'bdf_file': str(bdf_path.name),
        'n_nodes': len(surface_nids),
        'r_inner': r_inner,
        'r_outer': r_outer,
        'subcases': list(z_displacement.keys()),
    }

    return {
        'node_ids': surface_nids,
        'node_coords': surface_coords,
        'z_displacement': z_displacement,
        'metadata': metadata,
    }


def save_field_to_hdf5(
    field_data: Dict[str, Any],
    output_path: Path,
    compression: str = 'gzip'
) -> None:
    """
    Save displacement field data to HDF5 file.

    Args:
        field_data: Output from extract_displacement_field()
        output_path: Path to save HDF5 file
        compression: Compression algorithm ('gzip', 'lzf', or None)
    """
    if not HAS_H5PY:
        raise ImportError("h5py required for HDF5 storage: pip install h5py")

    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    with h5py.File(output_path, 'w') as f:
        # Node data
        f.create_dataset('node_ids', data=np.array(field_data['node_ids'], dtype=np.int32),
                        compression=compression)
        f.create_dataset('node_coords', data=field_data['node_coords'].astype(np.float32),
                        compression=compression)

        # Displacement for each subcase
        for subcase, z_disp in field_data['z_displacement'].items():
            f.create_dataset(f'subcase_{subcase}', data=z_disp.astype(np.float32),
                           compression=compression)

        # Metadata as JSON string
        f.attrs['metadata'] = json.dumps(field_data['metadata'])

    # Report file size
    size_kb = output_path.stat().st_size / 1024
    print(f"[FIELD] Saved to {output_path.name} ({size_kb:.1f} KB)")


def load_field_from_hdf5(hdf5_path: Path) -> Dict[str, Any]:
    """
    Load displacement field data from HDF5 file.

    Args:
        hdf5_path: Path to HDF5 file

    Returns:
        Dictionary with same structure as extract_displacement_field()
    """
    if not HAS_H5PY:
        raise ImportError("h5py required for HDF5 storage: pip install h5py")

    with h5py.File(hdf5_path, 'r') as f:
        node_ids = f['node_ids'][:].tolist()
        node_coords = f['node_coords'][:]

        # Load subcases
        z_displacement = {}
        for key in f.keys():
            if key.startswith('subcase_'):
                subcase = int(key.split('_')[1])
                z_displacement[subcase] = f[key][:]

        metadata = json.loads(f.attrs['metadata'])

    return {
        'node_ids': node_ids,
        'node_coords': node_coords,
        'z_displacement': z_displacement,
        'metadata': metadata,
    }


def save_field_to_npz(
    field_data: Dict[str, Any],
    output_path: Path
) -> None:
    """
    Save displacement field data to compressed NPZ file (fallback if no h5py).

    Args:
        field_data: Output from extract_displacement_field()
        output_path: Path to save NPZ file
    """
    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    save_dict = {
        'node_ids': np.array(field_data['node_ids'], dtype=np.int32),
        'node_coords': field_data['node_coords'].astype(np.float32),
        'metadata_json': np.array([json.dumps(field_data['metadata'])]),
    }

    # Add subcases
    for subcase, z_disp in field_data['z_displacement'].items():
        save_dict[f'subcase_{subcase}'] = z_disp.astype(np.float32)

    np.savez_compressed(output_path, **save_dict)

    size_kb = output_path.stat().st_size / 1024
    print(f"[FIELD] Saved to {output_path.name} ({size_kb:.1f} KB)")


def load_field_from_npz(npz_path: Path) -> Dict[str, Any]:
    """
    Load displacement field data from NPZ file.

    Args:
        npz_path: Path to NPZ file

    Returns:
        Dictionary with same structure as extract_displacement_field()
    """
    data = np.load(npz_path, allow_pickle=True)

    node_ids = data['node_ids'].tolist()
    node_coords = data['node_coords']
    metadata = json.loads(str(data['metadata_json'][0]))

    # Load subcases
    z_displacement = {}
    for key in data.keys():
        if key.startswith('subcase_'):
            subcase = int(key.split('_')[1])
            z_displacement[subcase] = data[key]

    return {
        'node_ids': node_ids,
        'node_coords': node_coords,
        'z_displacement': z_displacement,
        'metadata': metadata,
    }


# =============================================================================
# Convenience functions
# =============================================================================

def save_field(field_data: Dict[str, Any], output_path: Path) -> None:
    """Save field data using best available format (HDF5 preferred)."""
    output_path = Path(output_path)
    if HAS_H5PY and output_path.suffix == '.h5':
        save_field_to_hdf5(field_data, output_path)
    else:
        if output_path.suffix != '.npz':
            output_path = output_path.with_suffix('.npz')
        save_field_to_npz(field_data, output_path)


def load_field(path: Path) -> Dict[str, Any]:
    """Load field data from HDF5 or NPZ file."""
    path = Path(path)
    if path.suffix == '.h5':
        return load_field_from_hdf5(path)
    else:
        return load_field_from_npz(path)


# =============================================================================
# CLI
# =============================================================================

if __name__ == '__main__':
    import sys
    import argparse

    parser = argparse.ArgumentParser(
        description='Extract displacement field from Nastran OP2 for GNN training'
    )
    parser.add_argument('op2_path', type=Path, help='Path to OP2 file')
    parser.add_argument('-o', '--output', type=Path, help='Output path (default: same dir as OP2)')
    parser.add_argument('--r-inner', type=float, default=100.0, help='Inner radius (mm)')
    parser.add_argument('--r-outer', type=float, default=650.0, help='Outer radius (mm)')
    parser.add_argument('--format', choices=['h5', 'npz'], default='h5',
                       help='Output format (default: h5)')

    args = parser.parse_args()

    # Extract field
    field_data = extract_displacement_field(
        args.op2_path,
        r_inner=args.r_inner,
        r_outer=args.r_outer,
    )

    # Determine output path
    if args.output:
        output_path = args.output
    else:
        output_path = args.op2_path.parent / f'displacement_field.{args.format}'

    # Save
    save_field(field_data, output_path)

    # Print summary
    print("\n" + "="*60)
    print("EXTRACTION SUMMARY")
    print("="*60)
    print(f"Nodes: {len(field_data['node_ids'])}")
    print(f"Subcases: {list(field_data['z_displacement'].keys())}")
    for sc, disp in field_data['z_displacement'].items():
        print(f"  Subcase {sc}: Z range [{disp.min():.4f}, {disp.max():.4f}] mm")
