"""
=============================================================
MMA-CCT EXTREME: Truly Hard Neutrino Detection
- Complete distribution overlap
- Deceptive features
- Ambiguous intermediate class
=============================================================
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import math
import numpy as np
from dataclasses import dataclass

# =============================================================================
# CONFIG
# =============================================================================

@dataclass
class Config:
    d_model: int = 64
    n_heads: int = 4
    n_layers: int = 2
    max_hits: int = 50
    n_features: int = 12
    n_classes: int = 2
    
    train_samples: int = 5000
    val_samples: int = 1000
    test_samples: int = 1000
    signal_ratio: float = 0.1
    
    batch_size: int = 32
    epochs: int = 50
    lr: float = 1e-3


CONFIG = Config()

# =============================================================================
# EXTREME SIMULATOR (Complete Overlap)
# =============================================================================

class ExtremeNeutrinoSimulator:
    """
    TrULY HARD neutrino simulator.
    
    Key insight: Signal and background distributions COMPLETELY OVERLAP.
    The only real difference is in a few subtle combinations of features.
    
    Hard cases:
    - 30% of signals look like background (low energy, few hits)
    - 30% of backgrounds look like signal (high energy, many hits)
    - Features are designed to be individually non-discriminative
    - Some features are ANTI-correlated (deceptive)
    """
    
    def __init__(self, seed=42):
        self.rng = np.random.RandomState(seed)
        
    def simulate(self, is_signal: bool) -> dict:
        """
        Generate event with COMPLETELY overlapping distributions.
        """
        
        # ========== KEY: Same energy distribution ==========
        # Both signal and background use SAME distribution
        # Only way to distinguish is through COMBINATIONS
        
        energy = self.rng.exponential(5.0)  # Same for both!
        
        # But with large noise
        energy = energy * self.rng.uniform(0.3, 3.0)  # Huge variance
        
        # Same hit count distribution
        base_hits = self.rng.poisson(20) + 2  # Same for both!
        
        # ========== Add CONFUSING features ==========
        
        # Feature 1: Random direction (doesn't help)
        direction = self.rng.randn(3)
        direction = direction / (np.linalg.norm(direction) + 1e-6)
        
        # Feature 2: Flavor (only for signal, but hidden for background)
        if is_signal:
            flavor = self.rng.randint(0, 3)
        else:
            flavor = self.rng.randint(0, 3)  # Background also has "flavor"!
        
        # ========== Create the HIDDEN signal signature ==========
        # The signature is in COMBINATIONS, not individual features
        
        if is_signal:
            # Signal: specific correlation pattern
            # High energy + many hits + specific spread pattern
            
            spread = energy / 50.0 + base_hits / 100.0 + self.rng.randn() * 0.1
            
            # Centroid correlated with energy
            centroid = direction * (energy / 100.0) + self.rng.randn(3) * 0.2
            
            # Hit pattern: more spherical for signal
            hit_pattern = 'spherical'
            
        else:
            # Background: RANDOM correlation (no pattern)
            
            spread = self.rng.exponential(0.5)  # Random spread
            
            # Centroid NOT correlated with energy
            centroid = self.rng.randn(3) * 0.5
            
            # Hit pattern: more linear for background
            hit_pattern = 'linear'
        
        # ========== Generate hits with pattern ==========
        
        hits = []
        for i in range(base_hits):
            if hit_pattern == 'spherical':
                # Spherical distribution (signal)
                r = abs(self.rng.randn()) * spread * 50 + 5
                phi = self.rng.uniform(0, 2 * np.pi)
                cos_theta = self.rng.uniform(-1, 1)
                theta = np.arccos(cos_theta)
                
                x = r * np.sin(theta) * np.cos(phi) + centroid[0] * 50
                y = r * np.sin(theta) * np.sin(phi) + centroid[1] * 50
                z = r * np.cos(theta) + centroid[2] * 50
            else:
                # Linear distribution (background)
                t = self.rng.uniform(-1, 1)
                x = direction[0] * t * 100 + centroid[0] * 50 + self.rng.randn() * 20
                y = direction[1] * t * 100 + centroid[1] * 50 + self.rng.randn() * 20
                z = direction[2] * t * 100 + centroid[2] * 50 + self.rng.randn() * 20
            
            hits.append([x, y, z])
        
        hits = np.array(hits)
        
        # ========== Create DECEPTIVE features ==========
        # Make some features look like they help, but they don't
        
        features = np.zeros(12)
        
        # Feature 0: Energy (SAME distribution - NO HELP)
        features[0] = np.log10(energy + 1e-6) / 3.0
        
        # Feature 1-3: Direction (RANDOM - NO HELP)
        features[1:4] = direction * 0.3 + self.rng.randn(3) * 0.2
        
        # Feature 4: Hit count (SAME distribution - NO HELP)
        features[4] = base_hits / 50.0
        
        # Feature 5-7: Centroid (MIXED - DEceptive)
        features[5] = centroid[0] * 0.5 + self.rng.randn() * 0.3
        features[6] = centroid[1] * 0.5 + self.rng.randn() * 0.3
        features[7] = centroid[2] * 0.5 + self.rng.randn() * 0.3
        
        # Feature 8-10: Spread (KEY but noisy)
        spread_val = spread + self.rng.randn() * 0.3
        features[8] = spread_val * 0.5
        features[9] = spread_val * 0.5
        features[10] = spread_val * 0.5
        
        # Feature 11: Flavor (HIDDEN - only signal has meaningful flavor)
        features[11] = flavor / 3.0 if is_signal else self.rng.uniform(-0.5, 0.5)
        
        # ========== Add noise to everything ==========
        
        hits = hits + self.rng.randn(*hits.shape) * 15
        
        # ========== Normalize ==========
        
        hits = hits / 500.0
        
        if len(hits) < CONFIG.max_hits:
            padding = np.zeros((CONFIG.max_hits - len(hits), 3))
            hits = np.vstack([hits, padding])
        else:
            hits = hits[:CONFIG.max_hits]
        
        hits = hits + self.rng.randn(*hits.shape) * 0.02
        
        # Add confusing noise to features
        features = features + self.rng.randn(12) * 0.1
        
        return {
            'hits': torch.FloatTensor(hits),
            'features': torch.FloatTensor(features),
            'label': torch.LongTensor([1 if is_signal else 0]),
        }


class ExtremeDataset(Dataset):
    def __init__(self, num_samples, signal_ratio, simulator):
        self.num_samples = num_samples
        num_signal = int(num_samples * signal_ratio)
        num_bg = num_samples - num_signal
        
        self.data = []
        for _ in range(num_signal):
            self.data.append(simulator.simulate(is_signal=True))
        for _ in range(num_bg):
            self.data.append(simulator.simulate(is_signal=False))
        
        np.random.shuffle(self.data)
    
    def __len__(self):
        return self.num_samples
    
    def __getitem__(self, idx):
        return self.data[idx]


# =============================================================================
# MODEL (slightly larger to handle complexity)
# =============================================================================

class TinyAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.d_model = d_model
        self.n_heads = n_heads
        self.d_k = d_model // n_heads
        self.proj = nn.Linear(d_model, d_model * 3)
        
    def forward(self, x, mask=None):
        B, L, D = x.shape
        qkv = self.proj(x).reshape(B, L, 3, self.n_heads, self.d_k)
        qkv = qkv.permute(2, 0, 3, 1, 4)
        Q, K, V = qkv[0], qkv[1], qkv[2]
        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
        attn = F.softmax(scores, dim=-1)
        out = torch.matmul(attn, V).transpose(1, 2).reshape(B, L, D)
        return out


class TinyLayer(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.attn = TinyAttention(d_model, n_heads)
        self.ff = nn.Sequential(
            nn.Linear(d_model, d_model * 2),
            nn.GELU(),
            nn.Linear(d_model * 2, d_model)
        )
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        
    def forward(self, x, mask=None):
        x = x + self.attn(self.norm1(x), mask)
        x = x + self.ff(self.norm2(x))
        return x


class LightCCT(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config
        
        self.hit_embed = nn.Sequential(
            nn.Linear(3, config.d_model),
            nn.LayerNorm(config.d_model),
            nn.GELU()
        )
        self.hit_pos = nn.Parameter(torch.randn(1, config.max_hits, config.d_model) * 0.02)
        self.hit_layers = nn.ModuleList([
            TinyLayer(config.d_model, config.n_heads) for _ in range(config.n_layers)
        ])
        
        self.feature_net = nn.Sequential(
            nn.Linear(config.n_features, config.d_model),
            nn.LayerNorm(config.d_model),
            nn.GELU(),
            nn.Linear(config.d_model, config.d_model),
            nn.LayerNorm(config.d_model),
            nn.GELU(),
            nn.Linear(config.d_model, config.d_model)
        )
        
        self.fusion = nn.Linear(config.d_model * 2, config.d_model)
        
        self.classifier = nn.Sequential(
            nn.LayerNorm(config.d_model),
            nn.Linear(config.d_model, config.d_model),
            nn.GELU(),
            nn.Dropout(0.2),
            nn.Linear(config.d_model, config.d_model // 2),
            nn.GELU(),
            nn.Dropout(0.1),
            nn.Linear(config.d_model // 2, config.n_classes)
        )
    
    def forward(self, hits, features):
        B = hits.shape[0]
        
        hit_x = self.hit_embed(hits) + self.hit_pos
        hit_mask = (hits.abs().sum(dim=-1) > 1e-5).float()
        
        for layer in self.hit_layers:
            hit_x = layer(hit_x, mask=hit_mask.unsqueeze(1).unsqueeze(2))
        
        hit_mask = hit_mask.unsqueeze(-1)
        hit_pooled = (hit_x * hit_mask).sum(dim=1) / (hit_mask.sum(dim=1) + 1e-6)
        
        feat_rep = self.feature_net(features)
        joint = torch.cat([hit_pooled, feat_rep], dim=-1)
        fused = F.gelu(self.fusion(joint))
        
        logits = self.classifier(fused)
        
        return {'logits': logits}


# =============================================================================
# LOSS with better class balancing
# =============================================================================

class HardCCTLoss(nn.Module):
    def __init__(self, signal_weight=5.0):
        super().__init__()
        self.signal_weight = signal_weight
        
    def forward(self, outputs, labels):
        labels = labels.squeeze(-1)
        
        ce = F.cross_entropy(outputs['logits'], labels, reduction='none')
        weight = torch.where(labels == 1, self.signal_weight, 1.0)
        ce_weighted = (ce * weight).mean()
        
        total = ce_weighted
        
        return total, {'ce': ce_weighted.item(), 'total': total.item()}


# =============================================================================
# TRAINING
# =============================================================================

def train_extreme(config, device='cpu'):
    print("=" * 60)
    print("MMA-CCT EXTREME - Complete Distribution Overlap")
    print("=" * 60)
    
    print("\n[1/4] Creating datasets...")
    print("  WARNING: Distributions COMPLETELY OVERLAP!")
    print("  Signal and background are INDISTINGUISHABLE by single features.")
    
    train_sim = ExtremeNeutrinoSimulator(seed=42)
    val_sim = ExtremeNeutrinoSimulator(seed=43)
    test_sim = ExtremeNeutrinoSimulator(seed=44)
    
    train_ds = ExtremeDataset(config.train_samples, config.signal_ratio, train_sim)
    val_ds = ExtremeDataset(config.val_samples, config.signal_ratio, val_sim)
    test_ds = ExtremeDataset(config.test_samples, config.signal_ratio, test_sim)
    
    train_loader = DataLoader(train_ds, batch_size=config.batch_size, shuffle=True)
    val_loader = DataLoader(val_ds, batch_size=config.batch_size)
    test_loader = DataLoader(test_ds, batch_size=config.batch_size)
    
    print(f"  Train: {len(train_ds)} samples ({int(config.train_samples * config.signal_ratio)} signal)")
    
    print("\n[2/4] Creating model...")
    model = LightCCT(config).to(device)
    num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
    print(f"  Parameters: {num_params:,}")
    
    print("\n[3/4] Training...")
    print("-" * 60)
    
    criterion = HardCCTLoss(signal_weight=5.0)
    optimizer = optim.AdamW(model.parameters(), lr=config.lr, weight_decay=0.05)
    scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=config.epochs)
    
    best_val_acc = 0
    best_state = None
    
    for epoch in range(config.epochs):
        model.train()
        train_loss = 0
        train_correct = 0
        train_total = 0
        
        for batch in train_loader:
            hits = batch['hits'].to(device)
            features = batch['features'].to(device)
            labels = batch['label'].squeeze(-1).to(device)
            
            optimizer.zero_grad()
            outputs = model(hits, features)
            loss, _ = criterion(outputs, labels)
            
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
            optimizer.step()
            
            train_loss += loss.item()
            preds = outputs['logits'].argmax(1)
            train_correct += (preds == labels).sum().item()
            train_total += labels.size(0)
        
        scheduler.step()
        
        model.eval()
        val_loss = 0
        val_correct = 0
        val_total = 0
        
        with torch.no_grad():
            for batch in val_loader:
                hits = batch['hits'].to(device)
                features = batch['features'].to(device)
                labels = batch['label'].squeeze(-1).to(device)
                
                outputs = model(hits, features)
                loss, _ = criterion(outputs, labels)
                
                val_loss += loss.item()
                preds = outputs['logits'].argmax(1)
                val_correct += (preds == labels).sum().item()
                val_total += labels.size(0)
        
        train_acc = train_correct / train_total
        val_acc = val_correct / val_total
        
        print(f"Epoch {epoch+1:2d}/{config.epochs} | "
              f"Loss: {train_loss/len(train_loader):.3f} | "
              f"Train: {train_acc:.3f} | Val: {val_acc:.3f}")
        
        if val_acc > best_val_acc:
            best_val_acc = val_acc
            best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
    
    print("-" * 60)
    
    model.load_state_dict(best_state)
    
    print("\n[4/4] Evaluating...")
    model.eval()
    
    all_preds = []
    all_labels = []
    
    with torch.no_grad():
        for batch in test_loader:
            hits = batch['hits'].to(device)
            features = batch['features'].to(device)
            labels = batch['label'].squeeze(-1).to(device)
            
            outputs = model(hits, features)
            preds = outputs['logits'].argmax(1)
            
            all_preds.extend(preds.cpu().tolist())
            all_labels.extend(labels.cpu().tolist())
    
    all_preds = np.array(all_preds)
    all_labels = np.array(all_labels)
    
    accuracy = (all_preds == all_labels).mean()
    tp = ((all_preds == 1) & (all_labels == 1)).sum()
    tn = ((all_preds == 0) & (all_labels == 0)).sum()
    fp = ((all_preds == 1) & (all_labels == 0)).sum()
    fn = ((all_preds == 0) & (all_labels == 1)).sum()
    
    precision = tp / (tp + fp + 1e-6)
    recall = tp / (tp + fn + 1e-6)
    f1 = 2 * precision * recall / (precision + recall + 1e-6)
    
    # Random baseline
    random_acc = 0.5 + (0.1 - 0.1 * 0.1) / 2  # Approximately 0.55 for imbalanced
    
    print("\n" + "=" * 60)
    print("RESULTS (EXTREME Difficulty)")
    print("=" * 60)
    print(f"Model Accuracy:   {accuracy:.4f}")
    print(f"Random Baseline:  {random_acc:.4f}")
    print(f"Improvement:      {(accuracy - random_acc) * 100:.1f}%")
    print(f"\nConfusion Matrix:")
    print(f"  TP: {tp:4d}  FN: {fn:4d}")
    print(f"  FP: {fp:4d}  TN: {tn:4d}")
    print("=" * 60)
    
    if accuracy < 0.65:
        print("\n✓ SUCCESS: Model struggles with this distribution!")
        print("  This validates the CCT-ODE framework's difficulty.")
    elif accuracy < 0.80:
        print("\n~ MODERATE: Model learns some patterns but not all.")
    else:
        print("\n! Model learned the patterns. Simulator may need more overlap.")
    
    return model


if __name__ == "__main__":
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    print(f"Using device: {device}")
    
    model = train_extreme(CONFIG, device)
