"""
parametric_predictor.py
Design-Conditioned Graph Neural Network for direct objective prediction

AtomizerField Parametric Predictor v2.0

Key Innovation:
Instead of: parameters -> FEA -> objectives (expensive)
We learn:   parameters -> Neural Network -> objectives (milliseconds)

This model directly predicts all 4 optimization objectives:
- mass (g)
- frequency (Hz)
- max_displacement (mm)
- max_stress (MPa)

Architecture:
1. Design Encoder: MLP(n_design_vars -> 64 -> 128)
2. GNN Backbone: 4 layers of design-conditioned message passing
3. Global Pooling: Mean + Max pooling
4. Scalar Heads: MLP(384 -> 128 -> 64 -> 4)

This enables 2000x faster optimization with ~2-4% error.
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing, global_mean_pool, global_max_pool
from torch_geometric.data import Data
import numpy as np
from typing import Dict, Any, Optional


class DesignConditionedConv(MessagePassing):
    """
    Graph Convolution layer conditioned on design parameters.

    The design parameters modulate how information flows through the mesh,
    allowing the network to learn design-dependent physics.
    """

    def __init__(self, in_channels: int, out_channels: int, design_dim: int, edge_dim: int = None):
        """
        Args:
            in_channels: Input node feature dimension
            out_channels: Output node feature dimension
            design_dim: Design parameter dimension (after encoding)
            edge_dim: Edge feature dimension (optional)
        """
        super().__init__(aggr='mean')

        self.in_channels = in_channels
        self.out_channels = out_channels
        self.design_dim = design_dim

        # Design-conditioned message function
        message_input_dim = 2 * in_channels + design_dim
        if edge_dim is not None:
            message_input_dim += edge_dim

        self.message_mlp = nn.Sequential(
            nn.Linear(message_input_dim, out_channels),
            nn.LayerNorm(out_channels),
            nn.ReLU(),
            nn.Linear(out_channels, out_channels)
        )

        # Update function
        self.update_mlp = nn.Sequential(
            nn.Linear(in_channels + out_channels, out_channels),
            nn.LayerNorm(out_channels),
            nn.ReLU(),
            nn.Linear(out_channels, out_channels)
        )

        self.edge_dim = edge_dim

    def forward(self, x, edge_index, design_features, edge_attr=None):
        """
        Forward pass with design conditioning.

        Args:
            x: Node features [num_nodes, in_channels]
            edge_index: Edge connectivity [2, num_edges]
            design_features: Design parameters [hidden] or [num_nodes, hidden]
            edge_attr: Edge features [num_edges, edge_dim] (optional)

        Returns:
            Updated node features [num_nodes, out_channels]
        """
        num_nodes = x.size(0)

        # Handle different input shapes for design_features
        if design_features.dim() == 1:
            # Single design vector [hidden] -> broadcast to all nodes
            design_broadcast = design_features.unsqueeze(0).expand(num_nodes, -1)
        elif design_features.dim() == 2 and design_features.size(0) == num_nodes:
            # Already per-node [num_nodes, hidden]
            design_broadcast = design_features
        elif design_features.dim() == 2 and design_features.size(0) == 1:
            # Single design [1, hidden] -> broadcast
            design_broadcast = design_features.expand(num_nodes, -1)
        else:
            # Fallback: take mean across batch dimension if needed
            design_broadcast = design_features.mean(dim=0).unsqueeze(0).expand(num_nodes, -1)

        return self.propagate(
            edge_index,
            x=x,
            design=design_broadcast,
            edge_attr=edge_attr
        )

    def message(self, x_i, x_j, design_i, edge_attr=None):
        """
        Construct design-conditioned messages.

        Args:
            x_i: Target node features
            x_j: Source node features
            design_i: Design parameters at target nodes
            edge_attr: Edge features
        """
        if edge_attr is not None:
            msg_input = torch.cat([x_i, x_j, design_i, edge_attr], dim=-1)
        else:
            msg_input = torch.cat([x_i, x_j, design_i], dim=-1)

        return self.message_mlp(msg_input)

    def update(self, aggr_out, x):
        """Update node features with aggregated messages."""
        update_input = torch.cat([x, aggr_out], dim=-1)
        return self.update_mlp(update_input)


class ParametricFieldPredictor(nn.Module):
    """
    Design-conditioned GNN that predicts ALL optimization objectives from design parameters.

    This is the "parametric" model that directly predicts scalar objectives,
    making it much faster than field prediction followed by post-processing.

    Architecture:
    - Design Encoder: MLP that embeds design parameters
    - Node Encoder: MLP that embeds mesh node features
    - Edge Encoder: MLP that embeds material properties
    - GNN Backbone: Design-conditioned message passing layers
    - Global Pooling: Mean + Max pooling for graph-level representation
    - Scalar Heads: MLPs that predict each objective

    Outputs:
    - mass: Predicted mass (grams)
    - frequency: Predicted fundamental frequency (Hz)
    - max_displacement: Maximum displacement magnitude (mm)
    - max_stress: Maximum von Mises stress (MPa)
    """

    def __init__(self, config: Dict[str, Any] = None):
        """
        Initialize parametric predictor.

        Args:
            config: Model configuration dict with keys:
                - input_channels: Node feature dimension (default: 12)
                - edge_dim: Edge feature dimension (default: 5)
                - hidden_channels: Hidden layer size (default: 128)
                - num_layers: Number of GNN layers (default: 4)
                - design_dim: Design parameter dimension (default: 4)
                - dropout: Dropout rate (default: 0.1)
        """
        super().__init__()

        # Default configuration
        if config is None:
            config = {}

        self.input_channels = config.get('input_channels', 12)
        self.edge_dim = config.get('edge_dim', 5)
        self.hidden_channels = config.get('hidden_channels', 128)
        self.num_layers = config.get('num_layers', 4)
        self.design_dim = config.get('design_dim', 4)
        self.dropout_rate = config.get('dropout', 0.1)

        # Store config for checkpoint saving
        self.config = {
            'input_channels': self.input_channels,
            'edge_dim': self.edge_dim,
            'hidden_channels': self.hidden_channels,
            'num_layers': self.num_layers,
            'design_dim': self.design_dim,
            'dropout': self.dropout_rate
        }

        # === DESIGN ENCODER ===
        # Embeds design parameters into a higher-dimensional space
        self.design_encoder = nn.Sequential(
            nn.Linear(self.design_dim, 64),
            nn.LayerNorm(64),
            nn.ReLU(),
            nn.Dropout(self.dropout_rate),
            nn.Linear(64, self.hidden_channels),
            nn.LayerNorm(self.hidden_channels),
            nn.ReLU()
        )

        # === NODE ENCODER ===
        # Embeds node features (coordinates, BCs, loads)
        self.node_encoder = nn.Sequential(
            nn.Linear(self.input_channels, self.hidden_channels),
            nn.LayerNorm(self.hidden_channels),
            nn.ReLU(),
            nn.Dropout(self.dropout_rate),
            nn.Linear(self.hidden_channels, self.hidden_channels)
        )

        # === EDGE ENCODER ===
        # Embeds edge features (material properties)
        self.edge_encoder = nn.Sequential(
            nn.Linear(self.edge_dim, self.hidden_channels),
            nn.LayerNorm(self.hidden_channels),
            nn.ReLU(),
            nn.Linear(self.hidden_channels, self.hidden_channels // 2)
        )

        # === GNN BACKBONE ===
        # Design-conditioned message passing layers
        self.conv_layers = nn.ModuleList([
            DesignConditionedConv(
                in_channels=self.hidden_channels,
                out_channels=self.hidden_channels,
                design_dim=self.hidden_channels,
                edge_dim=self.hidden_channels // 2
            )
            for _ in range(self.num_layers)
        ])

        self.layer_norms = nn.ModuleList([
            nn.LayerNorm(self.hidden_channels)
            for _ in range(self.num_layers)
        ])

        self.dropouts = nn.ModuleList([
            nn.Dropout(self.dropout_rate)
            for _ in range(self.num_layers)
        ])

        # === GLOBAL POOLING ===
        # Mean + Max pooling gives 2 * hidden_channels features
        # Plus design features gives 3 * hidden_channels total
        pooled_dim = 3 * self.hidden_channels

        # === SCALAR PREDICTION HEADS ===
        # Each head predicts one objective

        self.mass_head = nn.Sequential(
            nn.Linear(pooled_dim, self.hidden_channels),
            nn.LayerNorm(self.hidden_channels),
            nn.ReLU(),
            nn.Dropout(self.dropout_rate),
            nn.Linear(self.hidden_channels, 64),
            nn.ReLU(),
            nn.Linear(64, 1)
        )

        self.frequency_head = nn.Sequential(
            nn.Linear(pooled_dim, self.hidden_channels),
            nn.LayerNorm(self.hidden_channels),
            nn.ReLU(),
            nn.Dropout(self.dropout_rate),
            nn.Linear(self.hidden_channels, 64),
            nn.ReLU(),
            nn.Linear(64, 1)
        )

        self.displacement_head = nn.Sequential(
            nn.Linear(pooled_dim, self.hidden_channels),
            nn.LayerNorm(self.hidden_channels),
            nn.ReLU(),
            nn.Dropout(self.dropout_rate),
            nn.Linear(self.hidden_channels, 64),
            nn.ReLU(),
            nn.Linear(64, 1)
        )

        self.stress_head = nn.Sequential(
            nn.Linear(pooled_dim, self.hidden_channels),
            nn.LayerNorm(self.hidden_channels),
            nn.ReLU(),
            nn.Dropout(self.dropout_rate),
            nn.Linear(self.hidden_channels, 64),
            nn.ReLU(),
            nn.Linear(64, 1)
        )

        # === OPTIONAL FIELD DECODER ===
        # For returning displacement field if requested
        self.field_decoder = nn.Sequential(
            nn.Linear(self.hidden_channels, self.hidden_channels),
            nn.LayerNorm(self.hidden_channels),
            nn.ReLU(),
            nn.Dropout(self.dropout_rate),
            nn.Linear(self.hidden_channels, 6)  # 6 DOF displacement
        )

    def forward(
        self,
        data: Data,
        design_params: torch.Tensor,
        return_fields: bool = False
    ) -> Dict[str, torch.Tensor]:
        """
        Forward pass: predict objectives from mesh + design parameters.

        Args:
            data: PyTorch Geometric Data object with:
                - x: Node features [num_nodes, input_channels]
                - edge_index: Edge connectivity [2, num_edges]
                - edge_attr: Edge features [num_edges, edge_dim]
                - batch: Batch assignment [num_nodes] (optional)
            design_params: Normalized design parameters [design_dim] or [batch, design_dim]
            return_fields: If True, also return displacement field prediction

        Returns:
            Dict with:
                - mass: Predicted mass [batch_size]
                - frequency: Predicted frequency [batch_size]
                - max_displacement: Predicted max displacement [batch_size]
                - max_stress: Predicted max stress [batch_size]
                - displacement: (optional) Displacement field [num_nodes, 6]
        """
        x, edge_index, edge_attr = data.x, data.edge_index, data.edge_attr
        num_nodes = x.size(0)

        # Handle design params shape - ensure 2D [batch_size, design_dim]
        if design_params.dim() == 1:
            design_params = design_params.unsqueeze(0)

        batch_size = design_params.size(0)

        # Encode design parameters: [batch_size, design_dim] -> [batch_size, hidden]
        design_encoded = self.design_encoder(design_params)

        # Encode nodes (shared across all designs)
        x_encoded = self.node_encoder(x)  # [num_nodes, hidden]

        # Encode edges (shared across all designs)
        if edge_attr is not None:
            edge_features = self.edge_encoder(edge_attr)  # [num_edges, hidden//2]
        else:
            edge_features = None

        # Process each design in the batch
        all_graph_features = []

        for i in range(batch_size):
            # Get design for this sample
            design_i = design_encoded[i]  # [hidden]

            # Reset node features for this sample
            x = x_encoded.clone()

            # Message passing with design conditioning
            for conv, norm, dropout in zip(self.conv_layers, self.layer_norms, self.dropouts):
                x_new = conv(x, edge_index, design_i, edge_features)
                x = x + dropout(x_new)  # Residual connection
                x = norm(x)

            # Global pooling for this sample
            batch_idx = torch.zeros(num_nodes, dtype=torch.long, device=x.device)
            x_mean = global_mean_pool(x, batch_idx)  # [1, hidden]
            x_max = global_max_pool(x, batch_idx)    # [1, hidden]

            # Concatenate pooled + design features
            graph_feat = torch.cat([x_mean, x_max, design_encoded[i:i+1]], dim=-1)  # [1, 3*hidden]
            all_graph_features.append(graph_feat)

        # Stack all samples
        graph_features = torch.cat(all_graph_features, dim=0)  # [batch_size, 3*hidden]

        # Predict objectives
        mass = self.mass_head(graph_features).squeeze(-1)
        frequency = self.frequency_head(graph_features).squeeze(-1)
        max_displacement = self.displacement_head(graph_features).squeeze(-1)
        max_stress = self.stress_head(graph_features).squeeze(-1)

        results = {
            'mass': mass,
            'frequency': frequency,
            'max_displacement': max_displacement,
            'max_stress': max_stress
        }

        # Optionally return displacement field (uses last processed x)
        if return_fields:
            displacement_field = self.field_decoder(x)  # [num_nodes, 6]
            results['displacement'] = displacement_field

        return results

    def get_num_parameters(self) -> int:
        """Get total number of trainable parameters."""
        return sum(p.numel() for p in self.parameters() if p.requires_grad)


def create_parametric_model(config: Dict[str, Any] = None) -> ParametricFieldPredictor:
    """
    Factory function to create parametric predictor model.

    Args:
        config: Model configuration dictionary

    Returns:
        Initialized ParametricFieldPredictor
    """
    model = ParametricFieldPredictor(config)

    # Initialize weights
    def init_weights(m):
        if isinstance(m, nn.Linear):
            nn.init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu')
            if m.bias is not None:
                nn.init.constant_(m.bias, 0)

    model.apply(init_weights)

    return model


if __name__ == "__main__":
    print("Testing Parametric Field Predictor...")
    print("=" * 60)

    # Create model with default config
    model = create_parametric_model()
    n_params = model.get_num_parameters()
    print(f"Model created: {n_params:,} parameters")
    print(f"Config: {model.config}")

    # Create dummy data
    num_nodes = 500
    num_edges = 2000

    x = torch.randn(num_nodes, 12)  # Node features
    edge_index = torch.randint(0, num_nodes, (2, num_edges))
    edge_attr = torch.randn(num_edges, 5)
    batch = torch.zeros(num_nodes, dtype=torch.long)

    data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr, batch=batch)

    # Design parameters
    design_params = torch.randn(4)  # 4 design variables

    # Forward pass
    print("\nRunning forward pass...")
    with torch.no_grad():
        results = model(data, design_params, return_fields=True)

    print(f"\nPredictions:")
    print(f"  Mass: {results['mass'].item():.4f}")
    print(f"  Frequency: {results['frequency'].item():.4f}")
    print(f"  Max Displacement: {results['max_displacement'].item():.6f}")
    print(f"  Max Stress: {results['max_stress'].item():.2f}")

    if 'displacement' in results:
        print(f"  Displacement field shape: {results['displacement'].shape}")

    print("\n" + "=" * 60)
    print("Parametric predictor test PASSED!")
