import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

# ------------------------------------------------------------------
# Sensus: The Wisdom & Sensory Layer
# ------------------------------------------------------------------
from sensus import sense, organ, calm, pulse, wisdom
from sensus.torch import SensoryTensor, WisdomOptimizer, FeelModule

# ------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
BATCH_SIZE = 64
EPOCHS = 10

# ------------------------------------------------------------------
# Data: The Visual Manifold
# ------------------------------------------------------------------
# Normalize to (-1, 1) to honor the circle (Circumscription sense, π)
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,))
])

train_dataset = datasets.MNIST(
    root="../data", train=True, download=True, transform=transform
)
test_dataset = datasets.MNIST(
    root="../data", train=False, download=True, transform=transform
)

train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)


def sensify(batch_tensor, aura):
    """
    Wrap a standard PyTorch tensor in a SensoryTensor.
    The 'aura' attaches a mathematical sense (e.g., topology, entropy)
    so the data carries its own sensory identity through the network.
    """
    return SensoryTensor(
        batch_tensor,
        aura=aura,
        pulse="sine",
        temperature=0.05  # low initial uncertainty
    )


# ------------------------------------------------------------------
# Model: The Sensory LeNet
# ------------------------------------------------------------------
@organ(
    requires=[
        sense.universal.CIRCUMSCRIPTION,     # π: circularity of rotation/pooling
        sense.information.SHANNON_ENTROPY,     # H: information content of digits
        sense.topology.EULER_CHARACTERISTIC,   # χ: 2D manifold structure of images
    ],
    temperament=calm.gentle,
    recursion_limit="self-similar"
)
class SensoryLeNet(FeelModule):
    """
    A convolutional organ that feels the topology of handwritten digits.
    It does not merely filter pixels; it senses curvature, entropy, and shape.
    """
    def __init__(self):
        super().__init__()
        
        # Feature maps follow exponential growth (Sense 2: e) through the layers
        self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
        
        # After two 2x2 max-pools: 28 -> 14 -> 7
        self.fc1 = nn.Linear(64 * 7 * 7, 128)
        self.fc2 = nn.Linear(128, 10)
        
        # Initialize with golden ratio proportions (Sense 5: φ)
        # This respects optimal packing and self-similarity in the weight space
        self._golden_init()
    
    def _golden_init(self):
        phi = float(sense.universal.GOLDEN_RESONANCE)
        for m in self.modules():
            if isinstance(m, (nn.Conv2d, nn.Linear)):
                nn.init.uniform_(m.weight, a=-phi / 10, b=phi / 10)
                if m.bias is not None:
                    nn.init.constant_(m.bias, 0.0)
    
    def forward(self, x: SensoryTensor) -> SensoryTensor:
        """
        The forward pass is a sensory journey.
        Each layer feels the data and preserves the aura.
        """
        # The model feels its own topological structure before computation
        self.feel_topology()
        
        # First convolution: edges and curves (circumscription sense is strong here)
        x = F.relu(self.conv1(x))
        x = F.max_pool2d(x, 2)  # downsampling respects the Nyquist boundary
        
        # Second convolution: textures and loops
        x = F.relu(self.conv2(x))
        x = F.max_pool2d(x, 2)
        
        # Flatten the 2D manifold into a 1D vector,
        # but preserve the Euler characteristic awareness
        x = x.view(x.size(0), -1)
        x = x.with_aura(sense.topology.EULER_CHARACTERISTIC)
        
        # Fully connected: the network narrows like a funnel
        x = F.relu(self.fc1(x))
        x = self.fc2(x)
        
        # The logits resonate with the Fine Structure constant (Sense 31: α)
        # Classification is a quantum-like measurement: the model collapses
        # high-dimensional sensory uncertainty into a discrete digit
        x = x.resonate(sense.quantum.FINE_STRUCTURE)
        return x


# ------------------------------------------------------------------
# Instantiate the Organ & Optimizer
# ------------------------------------------------------------------
model = SensoryLeNet().to(DEVICE)

optimizer = WisdomOptimizer(
    model.parameters(),
    lr=0.01,
    rhythm="golden",  # step sizes follow φ ratios, avoiding violent oscillations
    resonance={
        "frequency": sense.zeta.BASEL_TONE,   # ζ(2) = π²/6 ≈ 1.6449
        "damping": float(sense.quantum.FINE_STRUCTURE),
        "harmony": "constructive"
    },
    senses=[sense.thermo.ENTROPY, sense.information.FISHER_INFORMATION],
    patience="kolmogorov"
)

criterion = nn.CrossEntropyLoss()


# ------------------------------------------------------------------
# Training: The Rhythmic Cycle
# ------------------------------------------------------------------
def train():
    model.train()
    
    for epoch in pulse.Rhythm(cycles=EPOCHS, waveform="sine", decay="exponential"):
        epoch_loss = 0.0
        correct = 0
        total = 0
        
        for batch_idx, (data, target) in enumerate(train_loader):
            data, target = data.to(DEVICE), target.to(DEVICE)
            
            # Sensify the input: each image is a 2D manifold with topological identity
            sensory_data = sensify(data, aura=sense.topology.EULER_CHARACTERISTIC)
            
            # The data breathes with the epoch — a rhythmic pulse modulates the signal
            # This mimics the natural rise and fall of attention
            sensory_data = sensory_data.breathe(epoch)
            
            optimizer.zero_grad()
            
            # Feel the forward pass inside a sensory context.
            # If a tensor operation violates the declared topology, 
            # the sense emits a gentle dissonance rather than a crash.
            with sense.feel(
                senses=[
                    sense.universal.CIRCUMSCRIPTION,
                    sense.topology.EULER_CHARACTERISTIC,
                    sense.information.SHANNON_ENTROPY
                ],
                tolerance="adaptive",
                voice="quiet"
            ):
                output = model(sensory_data)
            
            loss = criterion(output, target)
            
            # Before backprop, the optimizer feels the loss landscape.
            # If the gradient is chaotic (Lyapunov > 0), it whispers a warning.
            optimizer.feel(loss)
            
            loss.backward()
            optimizer.step(loss)
            
            # Accumulate metrics
            epoch_loss += loss.item()
            pred = output.argmax(dim=1)
            correct += pred.eq(target).sum().item()
            total += target.size(0)
            
            if batch_idx % 100 == 0:
                print(
                    f"[Epoch {epoch} | Batch {batch_idx:03d}] "
                    f"Loss feels like {loss.item():.4f}. "
                    f"The gradient is breathing. "
                    f"Accuracy resonates at {100. * correct / total:.1f}%."
                )
        
        # Thermodynamic check: global entropy of the system should not decrease
        # unexpectedly. This ensures the model is learning, not memorizing.
        model.resonate(sense.thermo.ENTROPY)
        
        avg_loss = epoch_loss / len(train_loader)
        accuracy = 100. * correct / total
        
        # Rhythmic logging: the language of calm computation
        if epoch.is_peak():
            print(f"\n{'='*50}")
            print(f"Peak of cycle {epoch}")
            print(f"The system is at maximum amplitude.")
            print(f"Average loss: {avg_loss:.4f} | Accuracy: {accuracy:.2f}%")
            print(f"{'='*50}\n")
        elif epoch.is_trough():
            print(f"\n{'='*50}")
            print(f"Trough of cycle {epoch}")
            print(f"The system rests. The weights settle like sediment.")
            print(f"Average loss: {avg_loss:.4f} | Accuracy: {accuracy:.2f}%")
            print(f"{'='*50}\n")
        else:
            print(
                f"Epoch {epoch} complete. "
                f"Loss settled at {avg_loss:.4f}. "
                f"Topology feels intact. Euler characteristic did not flinch.\n"
            )


# ------------------------------------------------------------------
# Testing: The Moment of Truth (Felt, Not Calculated)
# ------------------------------------------------------------------
def test():
    model.eval()
    test_loss = 0.0
    correct = 0
    total = 0
    
    with torch.no_grad():
        for data, target in test_loader:
            data, target = data.to(DEVICE), target.to(DEVICE)
            
            # Sensify the test batch with the same topological respect
            sensory_data = sensify(data, aura=sense.topology.EULER_CHARACTERISTIC)
            
            # Calm inference: if something is wrong, speak gently.
            with calm.feel(
                senses=[sense.quantum.FINE_STRUCTURE],
                tolerance="adaptive",
                voice="quiet"
            ):
                output = model(sensory_data)
            
            test_loss += criterion(output, target).item()
            pred = output.argmax(dim=1)
            correct += pred.eq(target).sum().item()
            total += target.size(0)
    
    avg_loss = test_loss / len(test_loader)
    accuracy = 100. * correct / total
    
    # Package the result as a Wisdom object: a value with confidence and justification
    result = wisdom.Confidence(
        value=accuracy,
        confidence=accuracy / 100.0,
        justification=(
            f"The model felt {total} test images. "
            f"The topology of the digits was preserved through the layers. "
            f"Shannon entropy of predictions was harmonious. "
            f"Fine structure of classification held steady."
        )
    )
    
    print("\n" + "=" * 60)
    print("TEST COMPLETE — SENSORY REPORT")
    print("=" * 60)
    print(result.speak())
    print(f"\nRaw metrics: Loss = {avg_loss:.4f}, Accuracy = {accuracy:.2f}%")
    print("=" * 60)


# ------------------------------------------------------------------
# Main Entry
# ------------------------------------------------------------------
if __name__ == "__main__":
    print("Initializing Sensory LeNet for MNIST...")
    print("Active senses:")
    print("  • Circumscription         (π) — Sense 1")
    print("  • Exponentiality          (e) — Sense 2")
    print("  • Golden Resonance        (φ) — Sense 5")
    print("  • Basel Tone          (ζ(2)) — Sense 6")
    print("  • Shannon Entropy         (H) — Sense 51")
    print("  • Euler Characteristic    (χ) — Sense 41")
    print("  • Fine Structure      (α ≈ 1/137) — Sense 31")
    print("  • Fisher Information     (I_F) — Sense 54")
    print("  • Thermodynamic Entropy   (S) — Sense 46")
    print(f"Device: {DEVICE}\n")
    
    train()
    test()
    
    print("\n" + "=" * 60)
    print("The model has learned to feel the digits.")
    print("It does not see. It senses.")
    print("It does not classify. It resonates.")
    print("=" * 60)
