import os
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
from torchvision import models, transforms
from PIL import Image
import torch.nn.functional as F
import numpy as np
import random
import time
import psutil

def set_seed(seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed(seed)
        torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

set_seed(42)

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
IMG_DIR = os.path.join(BASE_DIR, "..", "datos_para_ai")
MODEL_SAVE_PATH = os.path.join(BASE_DIR, "..", "models", "msi_encoder_trained.pth")

# VRAM OPTIMIZATION ENGINE
BATCH_SIZE = 8            # Physical batch size (keeps VRAM safely under 3.5 GB)
ACCUMULATION_STEPS = 4    # 8 x 4 = Effective Batch Size of 32!
EPOCHS = 60               # Slightly extended to give the larger batch size room to converge
LEARNING_RATE = 0.0003    # Tuned for an effective batch size of 32
TEMPERATURE = 0.5
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

class MSIAugmentations:
    def __init__(self, size=256):  # Enforce 256 dimension consistency
        self.spatial_transform = transforms.Compose([
            transforms.Resize((size, size)),
            transforms.ToTensor()
        ])

    def apply_msi_chemical_noise(self, tensor):
        x = tensor.clone()
        
        # 1. Intensity Scaling (Global Concentration Shifts)
        scale_factor = random.uniform(0.8, 1.2)
        x = x * scale_factor
        
        # 2. Poisson Noise Simulation (Shot Noise)
        # Scaled to avoid breaking the 0.0 - 1.0 float bounds
        vals = len(torch.unique(x))
        vals = 2 ** np.ceil(np.log2(vals)) if vals > 0 else 32
        # Add soft, random Gaussian-Poisson approximation
        noise = torch.randn_like(x) * (torch.sqrt(x + 1e-5) / float(vals))
        x = x + noise

        # 3. Random Missing Values (Standard Dropout - e.g., 2% pixels lost)
        random_dropout_mask = (torch.rand_like(x) > 0.02).float()
        x = x * random_dropout_mask

        # 4. Intensity-Dependent Missing Values (Low-Signal Clipping)
        # Pixels with very low values have a higher chance of dropping out completely
        low_signal_mask = torch.rand_like(x)
        # Drop threshold scales inversely with intensity: lower values drop more easily
        drop_threshold = 0.15 * (1.0 - x) 
        x = torch.where(low_signal_mask > drop_threshold, x, torch.zeros_like(x))

        return torch.clamp(x, 0.0, 1.0)

    def __call__(self, x):
        base_tensor = self.spatial_transform(x)
        # Create two distinct chemical representations of the same underlying coordinate geometry
        return self.apply_msi_chemical_noise(base_tensor), self.apply_msi_chemical_noise(base_tensor)

class SimCLRMSIDataset(Dataset):
    def __init__(self, img_dir, transform=None):
        self.img_dir = img_dir
        self.img_names = [f for f in os.listdir(img_dir) if f.endswith('.png')]
        self.transform = transform

    def __len__(self):
        return len(self.img_names)

    def __getitem__(self, idx):
        img_path = os.path.join(self.img_dir, self.img_names[idx])
        image = Image.open(img_path).convert("L")
        if self.transform:
            xi, xj = self.transform(image)
        return xi, xj

class MSI_Structural_Encoder(nn.Module):
    def __init__(self, projection_dim=128):
        super(MSI_Structural_Encoder, self).__init__()
        
        # High-Fidelity Custom Encoder for Padded Data
        self.features = nn.Sequential(
            # Input: 256x256x1 (Resize in transform)
            nn.Conv2d(1, 32, kernel_size=5, stride=2, padding=2, bias=False),
            nn.GroupNorm(4, 32), # Replaces BatchNorm for perfect stability at batch size = 8
            nn.ReLU(),
            
            # 112x112 -> 56x56
            nn.Conv2d(32, 64, kernel_size=3, stride=2, padding=1, bias=False),
            nn.GroupNorm(8, 64),
            nn.ReLU(),
            
            # 56x56 -> 28x28 (Captures true raw pixel leaf structures)
            nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1, bias=False),
            nn.GroupNorm(16, 128),
            nn.ReLU(),
            
            # 28x28 -> 14x14
            nn.Conv2d(128, 256, kernel_size=3, stride=2, padding=1, bias=False),
            nn.GroupNorm(32, 256),
            nn.ReLU(),
            
            nn.AdaptiveAvgPool2d((1, 1)) # Yields a clean 256-dimensional vector
        )
        
        self.projector = nn.Sequential(
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, projection_dim)
        )

    def forward(self, x):
        h = self.features(x).squeeze(-1).squeeze(-1)
        h_norm = F.normalize(h, p=2, dim=1) # Projects onto the unit hypersphere
        z = self.projector(h_norm)
        return h, z

def nt_xent_loss(z1, z2, temperature=0.5):
    batch_size = z1.shape[0]
    z = torch.cat([z1, z2], dim=0)
    z = F.normalize(z, p=2, dim=1)
    
    sim_matrix = torch.mm(z, z.t()) / temperature
    mask = torch.eye(2 * batch_size, device=z.device).bool()
    sim_matrix = sim_matrix.masked_fill(mask, -9e15)
    
    targets = torch.arange(2 * batch_size, device=z.device)
    targets[:batch_size] += batch_size
    targets[batch_size:] -= batch_size
    
    return F.cross_entropy(sim_matrix, targets)

if __name__ == "__main__":
    print(f"Launching VRAM-Optimized Contrastive Pipeline on: {DEVICE}")
    print(f"Physical Batch Size: {BATCH_SIZE} | Accumulation Steps: {ACCUMULATION_STEPS} | Effective Batch Size: {BATCH_SIZE * ACCUMULATION_STEPS}")
    os.makedirs(os.path.dirname(MODEL_SAVE_PATH), exist_ok=True)
    
    aug_pipeline = MSIAugmentations(size=256)
    dataset = SimCLRMSIDataset(IMG_DIR, transform=aug_pipeline)
    loader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True, drop_last=True, num_workers=2, pin_memory=True)

    model = MSI_Structural_Encoder().to(DEVICE)
    optimizer = optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=1e-4)
    scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS, eta_min=1e-6)

    process = psutil.Process(os.getpid())
    
    # Environment tweak to combat memory fragmentation
    os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
    torch.cuda.empty_cache()

    for epoch in range(EPOCHS):
        model.train()
        total_loss = 0
        epoch_start = time.time()
        
        optimizer.zero_grad() # Initialize gradients outside the accumulation loop
        
        for batch_idx, (xi, xj) in enumerate(loader):
            xi, xj = xi.to(DEVICE), xj.to(DEVICE)
            
            _, z1 = model(xi)
            _, z2 = model(xj)
            
            # Scale loss by accumulation steps to ensure correct gradient weighting
            loss = nt_xent_loss(z1, z2, temperature=TEMPERATURE) / ACCUMULATION_STEPS
            loss.backward()
            
            total_loss += loss.item() * ACCUMULATION_STEPS
            
            # Step the optimizer only after gathering enough gradients
            if (batch_idx + 1) % ACCUMULATION_STEPS == 0 or (batch_idx + 1) == len(loader):
                optimizer.step()
                optimizer.zero_grad()
        
        scheduler.step()
        
        # Periodic cache flushing keeps VRAM overhead low
        if epoch % 5 == 0:
            torch.cuda.empty_cache()
            
        print(f"Epoch [{epoch+1}/{EPOCHS}], Loss: {total_loss / len(loader):.4f}, Time: {time.time()-epoch_start:.2f}s, RAM Used: {process.memory_info().rss / 1024 / 1024:.1f} MB")

    print(f"--> Exporting optimized 32-BS encoder weights to: {MODEL_SAVE_PATH}")
    torch.save(model.features.state_dict(), MODEL_SAVE_PATH)
    print(f"Model saved to: {MODEL_SAVE_PATH}")
