"""SOFIA v7.1 custom layers.

Includes:
- GGeM: Generalized Mean Pooling with per-channel learnable exponent (F11)
- CircularHarmonicPool: Formally SO(2)-invariant pooling via polar + FFT magnitude (NH2 novel)
- AltitudeFiLM: FiLM conditioning on UAV altitude (NH4 novel)
- RoPE2D: 2D Rotary Position Embedding for attention
- SqueezeExcite: standard SE block
- LayerNorm2d: channel-last LN wrapper for 2D features

All rotation-invariance and FiLM modules are NOVEL contributions of SOFIA v7.1-α.
"""

from __future__ import annotations

import math
from typing import Optional, Tuple

import torch
import torch.nn as nn
import torch.nn.functional as F


# ============================================================
# GGeM: Generalized Mean Pooling (F11)
# ============================================================

class GGeM(nn.Module):
    """Per-channel learnable Generalized Mean pooling.

    Formula:
        GGeM(F)_c = (1/HW · Σ F_{c,h,w}^{p_c})^{1/p_c}
        p_c = softplus(p_hat_c) ∈ (0, ∞)
    """

    def __init__(self, channels: int, init_p: float = 3.0, eps: float = 1e-6) -> None:
        super().__init__()
        # softplus^{-1}(init_p) = log(exp(init_p) - 1)
        hat_init = math.log(math.exp(init_p) - 1.0)
        self.hat_p = nn.Parameter(torch.full((channels,), hat_init))
        self.eps = eps

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """x: [B, C, H, W] -> [B, C]"""
        p = F.softplus(self.hat_p).view(1, -1, 1, 1)    # [1, C, 1, 1]
        x_clamped = x.clamp(min=self.eps)
        x_pow = x_clamped.pow(p)
        x_mean = x_pow.mean(dim=(2, 3), keepdim=True)
        out = x_mean.pow(1.0 / p)
        return out.flatten(1)


# ============================================================
# CHP: Circular Harmonic Pool (NH2 novel — formally SO(2)-invariant)
# ============================================================

class CircularHarmonicPool(nn.Module):
    """Formally SO(2) rotation-invariant pooling.

    Algorithm:
        1. Sample input feature map at polar grid (r, θ) via bilinear grid_sample
        2. Apply 1D real FFT along θ-axis
        3. Keep magnitudes of first N harmonics (invariant to shift = rotation)
        4. GGeM pool over rings r (per-channel-per-harmonic)
        5. Flatten to descriptor [B, C * N]

    Output is theoretically invariant to input rotation of any angle.

    See HYP Phase 4'' Section 4''.1 NH2 and Section 4''.5 for formal proof.
    """

    def __init__(
        self,
        channels: int,
        rings: int = 8,
        angles: int = 16,
        harmonics: int = 4,
        r_min: float = 0.1,
        r_max: float = 1.0,
    ) -> None:
        super().__init__()
        assert harmonics <= angles // 2 + 1, (
            f"harmonics {harmonics} cannot exceed angles//2+1 = {angles // 2 + 1}"
        )

        self.channels = channels
        self.rings = rings
        self.angles = angles
        self.harmonics = harmonics

        # GGeM over rings (per channel × per harmonic)
        self.ggem = GGeM(channels * harmonics, init_p=3.0)

        # Precompute polar grid in normalized [-1, 1] coords for grid_sample
        grid = self._make_polar_grid(rings, angles, r_min, r_max)    # [R, T, 2]
        self.register_buffer("polar_grid", grid, persistent=False)

    @staticmethod
    def _make_polar_grid(R: int, T: int, r_min: float, r_max: float) -> torch.Tensor:
        r_values = torch.linspace(r_min, r_max, R)    # [R]
        theta_values = torch.linspace(0.0, 2 * math.pi, T + 1)[:-1]    # [T]
        r_grid, theta_grid = torch.meshgrid(r_values, theta_values, indexing="ij")    # [R, T]
        x = r_grid * torch.cos(theta_grid)
        y = r_grid * torch.sin(theta_grid)
        grid = torch.stack([x, y], dim=-1)    # [R, T, 2]
        return grid

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Args:
            x: [B, C, H, W] feature map
        Returns:
            descriptor: [B, C * harmonics]
        """
        B, C, H, W = x.shape
        assert C == self.channels, (
            f"Input channels {C} != expected {self.channels}"
        )

        # 1. Polar sampling
        # grid: [B, R, T, 2]
        grid = self.polar_grid.unsqueeze(0).expand(B, -1, -1, -1)
        polar = F.grid_sample(
            x, grid,
            mode="bilinear",
            padding_mode="zeros",
            align_corners=True,
        )    # [B, C, R, T]

        # 2. 1D real FFT along angular axis
        polar_fft = torch.fft.rfft(polar, dim=-1)    # [B, C, R, T//2+1]
        polar_fft = polar_fft[..., : self.harmonics]    # [B, C, R, N]

        # 3. Magnitude (rotation invariant)
        magnitude = polar_fft.abs()    # [B, C, R, N]

        # 4. Reshape for GGeM: treat (C, N) as combined channel dim, rings as spatial
        # Shape: [B, C*N, R, 1]
        magnitude_reshaped = (
            magnitude
            .permute(0, 1, 3, 2)    # [B, C, N, R]
            .reshape(B, C * self.harmonics, self.rings, 1)
        )

        # 5. GGeM pool over rings (H=R, W=1)
        descriptor = self.ggem(magnitude_reshaped)    # [B, C*N]

        return descriptor


# ============================================================
# FiLM: Altitude-conditioned modulation (NH4 novel)
# ============================================================

class AltitudeFiLM(nn.Module):
    """FiLM modulation conditioned on scalar altitude.

    F' = γ(h) · F + β(h)  where γ,β ∈ R^C are produced by MLP.

    At altitude=None, produces identity (γ=1, β=0) via zero-init of final layer.
    """

    def __init__(
        self,
        channels: int,
        hidden_dim: int = 64,
        altitude_norm: float = 500.0,
    ) -> None:
        super().__init__()
        self.channels = channels
        self.altitude_norm = altitude_norm

        self.mlp = nn.Sequential(
            nn.Linear(1, hidden_dim),
            nn.GELU(),
            nn.Linear(hidden_dim, 2 * channels),
        )
        # Zero-init last layer → initial γ=0 before residual, β=0
        nn.init.zeros_(self.mlp[-1].weight)
        nn.init.zeros_(self.mlp[-1].bias)

    def forward(self, x: torch.Tensor, altitude: Optional[torch.Tensor] = None) -> torch.Tensor:
        """
        Args:
            x: [B, C, H, W]
            altitude: [B] or [B, 1] scalar altitude in meters (or None for neutral)
        Returns:
            [B, C, H, W]
        """
        B = x.shape[0]
        if altitude is None:
            altitude = torch.zeros(B, 1, device=x.device, dtype=x.dtype)
        elif altitude.dim() == 1:
            altitude = altitude.unsqueeze(-1)

        h_norm = altitude.to(x.dtype) / self.altitude_norm
        gamma_beta = self.mlp(h_norm)    # [B, 2C]
        gamma, beta = gamma_beta.chunk(2, dim=-1)
        # Residual form: γ = 1 + delta_γ (starts at identity)
        gamma = gamma.view(B, self.channels, 1, 1) + 1.0
        beta = beta.view(B, self.channels, 1, 1)
        return gamma * x + beta


# ============================================================
# TextFiLM: text-conditioned modulation (extension for caption fusion)
# ============================================================

class TextFiLM(nn.Module):
    """FiLM modulation conditioned on a text embedding.

    F' = γ(z) · F + β(z) where γ,β ∈ R^C are produced by an MLP
    from z ∈ R^{D_txt}. Identity at init via zero-init of last layer
    (so γ=1, β=0 before training shifts the residual).
    """

    def __init__(
        self,
        channels: int,
        text_dim: int = 1024,
        hidden_dim: int = 256,
    ) -> None:
        super().__init__()
        self.channels = channels
        self.text_dim = text_dim
        self.mlp = nn.Sequential(
            nn.Linear(text_dim, hidden_dim),
            nn.GELU(),
            nn.Linear(hidden_dim, 2 * channels),
        )
        nn.init.zeros_(self.mlp[-1].weight)
        nn.init.zeros_(self.mlp[-1].bias)

    def forward(self, x: torch.Tensor, text_emb: Optional[torch.Tensor] = None) -> torch.Tensor:
        """
        Args:
            x: [B, C, H, W]
            text_emb: [B, D_txt] or None for no-op (identity)
        Returns:
            [B, C, H, W]
        """
        if text_emb is None:
            return x
        B = x.shape[0]
        gamma_beta = self.mlp(text_emb.to(x.dtype))    # [B, 2C]
        gamma, beta = gamma_beta.chunk(2, dim=-1)
        gamma = gamma.view(B, self.channels, 1, 1) + 1.0
        beta = beta.view(B, self.channels, 1, 1)
        return gamma * x + beta


# ============================================================
# RoPE 2D
# ============================================================

class RoPE2D(nn.Module):
    """2D Rotary Position Embedding.

    Splits head_dim into two halves: first half gets x-position encoding,
    second half gets y-position encoding. For each half, applies standard
    1D RoPE rotation.

    Reference: RoFormer (B49) adapted for 2D.
    """

    def __init__(self, head_dim: int, max_resolution: int = 64, base: float = 10000.0) -> None:
        super().__init__()
        assert head_dim % 2 == 0, "head_dim must be even for RoPE"
        assert head_dim % 4 == 0, "head_dim must be divisible by 4 for 2D RoPE"
        self.head_dim = head_dim
        self.half_dim = head_dim // 2    # dedicated to each axis
        self.max_resolution = max_resolution

        # Frequencies for each axis (half_dim per axis, sin+cos pairs)
        freqs = 1.0 / (base ** (torch.arange(0, self.half_dim, 2).float() / self.half_dim))
        self.register_buffer("freqs", freqs, persistent=False)

    def _make_embeds(self, H: int, W: int, device: torch.device) -> Tuple[torch.Tensor, torch.Tensor]:
        """Produce cos/sin embeddings for HW tokens in raster order."""
        y_pos = torch.arange(H, device=device, dtype=torch.float32)
        x_pos = torch.arange(W, device=device, dtype=torch.float32)

        freqs_y = torch.einsum("i,j->ij", y_pos, self.freqs)    # [H, half_dim/2]
        freqs_x = torch.einsum("i,j->ij", x_pos, self.freqs)    # [W, half_dim/2]

        # Expand to full grid: [H, W, half_dim/2] each
        freqs_y = freqs_y.unsqueeze(1).expand(-1, W, -1)    # [H, W, half_dim/2]
        freqs_x = freqs_x.unsqueeze(0).expand(H, -1, -1)    # [H, W, half_dim/2]

        # Concatenate: x-axis freqs into first half, y-axis into second half
        # Each half is [H, W, half_dim/2]; we pair-up for complex rotation
        freqs_combined_x = torch.cat([freqs_x, freqs_x], dim=-1)    # [H, W, half_dim]
        freqs_combined_y = torch.cat([freqs_y, freqs_y], dim=-1)    # [H, W, half_dim]

        freqs_full = torch.cat([freqs_combined_x, freqs_combined_y], dim=-1)    # [H, W, head_dim]
        cos = freqs_full.cos().reshape(H * W, -1)
        sin = freqs_full.sin().reshape(H * W, -1)
        return cos, sin

    @staticmethod
    def _rotate_half(x: torch.Tensor) -> torch.Tensor:
        """Rotate: (x1, x2) -> (-x2, x1)."""
        x1, x2 = x.chunk(2, dim=-1)
        return torch.cat([-x2, x1], dim=-1)

    def forward(self, q: torch.Tensor, k: torch.Tensor, H: int, W: int) -> Tuple[torch.Tensor, torch.Tensor]:
        """
        Args:
            q, k: [B, heads, HW, head_dim]
            H, W: spatial dims for position computation
        Returns:
            q_rot, k_rot with positional encoding applied
        """
        cos, sin = self._make_embeds(H, W, q.device)
        cos = cos.to(q.dtype).unsqueeze(0).unsqueeze(0)    # [1, 1, HW, head_dim]
        sin = sin.to(q.dtype).unsqueeze(0).unsqueeze(0)
        q_rot = (q * cos) + (self._rotate_half(q) * sin)
        k_rot = (k * cos) + (self._rotate_half(k) * sin)
        return q_rot, k_rot


# ============================================================
# SE: Squeeze-Excite
# ============================================================

class SqueezeExcite(nn.Module):
    """Standard Squeeze-Excite channel attention."""

    def __init__(self, channels: int, reduction: int = 16) -> None:
        super().__init__()
        hidden = max(1, channels // reduction)
        self.pool = nn.AdaptiveAvgPool2d(1)
        self.fc = nn.Sequential(
            nn.Conv2d(channels, hidden, 1),
            nn.SiLU(inplace=True),
            nn.Conv2d(hidden, channels, 1),
            nn.Sigmoid(),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        s = self.pool(x)
        s = self.fc(s)
        return x * s


# ============================================================
# LayerNorm2d: LN over channels for (B, C, H, W) layout
# ============================================================

class LayerNorm2d(nn.Module):
    """LayerNorm over C dimension for 4D tensors."""

    def __init__(self, channels: int, eps: float = 1e-6) -> None:
        super().__init__()
        self.norm = nn.LayerNorm(channels, eps=eps)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # [B, C, H, W] → [B, H, W, C] → LN → [B, C, H, W]
        x = x.permute(0, 2, 3, 1)
        x = self.norm(x)
        x = x.permute(0, 3, 1, 2).contiguous()
        return x


if __name__ == "__main__":
    # Smoke test
    torch.manual_seed(0)

    # GGeM test
    g = GGeM(64)
    x = torch.randn(2, 64, 8, 8)
    out = g(x)
    print(f"GGeM out: {out.shape}")    # [2, 64]

    # CHP test — verify rotation invariance
    chp = CircularHarmonicPool(32, rings=8, angles=16, harmonics=4)
    x = torch.randn(1, 32, 16, 16)
    out1 = chp(x)
    # Rotate x by 90° and verify invariance (approximately)
    x_rot = torch.rot90(x, k=1, dims=(-2, -1))
    out2 = chp(x_rot)
    diff = (out1 - out2).abs().max().item()
    print(f"CHP rotation-invariance max diff: {diff:.4e} (should be small)")
    print(f"CHP out shape: {out1.shape}")    # [1, 128]

    # FiLM test
    film = AltitudeFiLM(64)
    x = torch.randn(2, 64, 8, 8)
    altitudes = torch.tensor([150.0, 300.0])
    out = film(x, altitudes)
    print(f"FiLM out: {out.shape}")    # [2, 64, 8, 8]

    # RoPE2D test
    rope = RoPE2D(32)
    q = torch.randn(2, 4, 64, 32)    # [B, heads, HW, head_dim]
    k = torch.randn(2, 4, 64, 32)
    q_r, k_r = rope(q, k, 8, 8)
    print(f"RoPE out: q={q_r.shape}, k={k_r.shape}")
