from __future__ import annotations

"""Asymmetric dual encoder for CVGL caption test on GTA-UAV.

Architecture:
  Query:   DINOv3 ViT-L/16 (LVD, frozen) + LRSCLIP text (L1/L2/L3) -> GatedFusion -> query
  Gallery: DINOv3 ViT-L/16 (SAT, frozen) -> gallery
  Loss:    InfoNCE(query, gallery)

DINOv3 checkpoints use a custom key layout (not HuggingFace transformers).
LRSCLIP (DGTRS-CLIP ViT-L-14) uses open_clip layout with KPS positional embeddings.
"""

import logging
import math
import warnings
from pathlib import Path

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

LOGGER = logging.getLogger("caption_test.model")
coloredlogs.install(level="INFO", logger=LOGGER, fmt="%(asctime)s %(name)s %(levelname)s %(message)s")
from safetensors.torch import load_file as load_safetensors

from src.models.adapters import inject_lora_into_dgtrs, inject_mona_into_dinov3
from src.models.dgtrs.model import DGTRSTextEncoder, load_dgtrs_text_encoder, tokenize_dgtrs
from src.models.dual_encoder import GatedFusion, ProjectionHead


# ---------------------------------------------------------------------------
# DINOv3 ViT-L/16 — minimal implementation matching checkpoint key layout
# ---------------------------------------------------------------------------

class DINOv3Attention(nn.Module):
    """Multi-head self-attention with separate Q/K/V projections."""

    def __init__(self, dim: int = 1024, num_heads: int = 16) -> None:
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = dim // num_heads
        self.scale = self.head_dim ** -0.5
        self.q_proj = nn.Linear(dim, dim)
        self.k_proj = nn.Linear(dim, dim, bias=False)
        self.v_proj = nn.Linear(dim, dim)
        self.o_proj = nn.Linear(dim, dim)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        B, N, C = x.shape
        q = self.q_proj(x).reshape(B, N, self.num_heads, self.head_dim).permute(0, 2, 1, 3)
        k = self.k_proj(x).reshape(B, N, self.num_heads, self.head_dim).permute(0, 2, 1, 3)
        v = self.v_proj(x).reshape(B, N, self.num_heads, self.head_dim).permute(0, 2, 1, 3)
        attn = F.scaled_dot_product_attention(q, k, v)
        x = attn.permute(0, 2, 1, 3).reshape(B, N, C)
        return self.o_proj(x)


class DINOv3LayerScale(nn.Module):
    """Per-channel learnable scale (lambda)."""

    def __init__(self, dim: int) -> None:
        super().__init__()
        self.lambda1 = nn.Parameter(torch.ones(dim))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return x * self.lambda1


class DINOv3MLP(nn.Module):
    """SwiGLU-like MLP: up_proj + GELU + down_proj."""

    def __init__(self, dim: int = 1024, mlp_dim: int = 4096) -> None:
        super().__init__()
        self.up_proj = nn.Linear(dim, mlp_dim)
        self.down_proj = nn.Linear(mlp_dim, dim)
        self.act = nn.GELU()

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.down_proj(self.act(self.up_proj(x)))


class DINOv3Block(nn.Module):
    """Single DINOv3 transformer block."""

    def __init__(self, dim: int = 1024, num_heads: int = 16, mlp_dim: int = 4096) -> None:
        super().__init__()
        self.norm1 = nn.LayerNorm(dim)
        self.attention = DINOv3Attention(dim, num_heads)
        self.layer_scale1 = DINOv3LayerScale(dim)
        self.norm2 = nn.LayerNorm(dim)
        self.mlp = DINOv3MLP(dim, mlp_dim)
        self.layer_scale2 = DINOv3LayerScale(dim)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = x + self.layer_scale1(self.attention(self.norm1(x)))
        x = x + self.layer_scale2(self.mlp(self.norm2(x)))
        return x


class DINOv3Embeddings(nn.Module):
    """Patch embedding + CLS token + register tokens."""

    def __init__(
        self,
        dim: int = 1024,
        patch_size: int = 16,
        num_registers: int = 4,
    ) -> None:
        super().__init__()
        self.patch_embeddings = nn.Conv2d(3, dim, patch_size, patch_size)
        self.cls_token = nn.Parameter(torch.zeros(1, 1, dim))
        self.register_tokens = nn.Parameter(torch.zeros(1, num_registers, dim))
        self.mask_token = nn.Parameter(torch.zeros(1, 1, dim))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        B = x.shape[0]
        patches = self.patch_embeddings(x).flatten(2).transpose(1, 2)  # [B, N, D]
        N = patches.shape[1]

        cls = self.cls_token.expand(B, -1, -1)
        reg = self.register_tokens.expand(B, -1, -1)

        # DINOv3: [CLS, registers, patches]
        x = torch.cat([cls, reg, patches], dim=1)

        # Positional embedding: interpolated sincos (RoPE applied in attention
        # in original, but pretrained checkpoints bake it into weights).
        # We use a simple learned-style pos embed computed on the fly.
        pos = self._get_pos_embed(N, x.device, x.dtype)
        # pos covers patches only, skip CLS + registers
        x[:, 1 + reg.shape[1]:] = x[:, 1 + reg.shape[1]:] + pos

        return x

    def _get_pos_embed(self, n_patches: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
        # DINOv3 uses RoPE internally — no additive pos embed needed.
        # Return zeros as placeholder (weights handle positioning via RoPE).
        return torch.zeros(1, n_patches, self.cls_token.shape[-1], device=device, dtype=dtype)


class DINOv3ViT(nn.Module):
    """DINOv3 ViT-L/16 matching the checkpoint key layout.

    Checkpoint keys:
        embeddings.cls_token, embeddings.patch_embeddings.{weight,bias},
        embeddings.register_tokens, embeddings.mask_token,
        layer.{i}.attention.{q,k,v,o}_proj.{weight,bias},
        layer.{i}.layer_scale{1,2}.lambda1,
        layer.{i}.mlp.{up,down}_proj.{weight,bias},
        layer.{i}.norm{1,2}.{weight,bias},
        norm.{weight,bias}
    """

    def __init__(
        self,
        dim: int = 1024,
        num_heads: int = 16,
        mlp_dim: int = 4096,
        num_layers: int = 24,
        patch_size: int = 16,
        num_registers: int = 4,
    ) -> None:
        super().__init__()
        self.embeddings = DINOv3Embeddings(dim, patch_size, num_registers)
        self.layer = nn.ModuleList([
            DINOv3Block(dim, num_heads, mlp_dim) for _ in range(num_layers)
        ])
        self.norm = nn.LayerNorm(dim)
        self.embed_dim = dim

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """Forward pass. Returns CLS token embedding [B, dim]."""
        x = self.embeddings(x)
        for block in self.layer:
            x = block(x)
        x = self.norm(x)
        return x[:, 0]  # CLS token

    @classmethod
    def from_pretrained(cls, path: str | Path) -> DINOv3ViT:
        """Load from .pth or .safetensors checkpoint."""
        model = cls()
        path = Path(path)
        LOGGER.info("🧊 Loading DINOv3 from %s", path.name)
        if path.suffix == ".safetensors":
            state = load_safetensors(str(path))
        else:
            state = torch.load(str(path), map_location="cpu", weights_only=False)
            if "model" in state:
                state = state["model"]
            elif "state_dict" in state:
                state = state["state_dict"]
        model.load_state_dict(state, strict=False)
        n_params = sum(p.numel() for p in model.parameters())
        LOGGER.info("🧊 DINOv3 loaded: %s params", f"{n_params:,}")
        return model


# LRSCLIPTextEncoder removed — replaced by official DGTRS architecture
# in src/models/dgtrs/model.py (DGTRSTextEncoder)


# ---------------------------------------------------------------------------
# Text fusion MLP: concat L1/L2/L3 -> project to D
# ---------------------------------------------------------------------------

class TextFusionMLP(nn.Module):
    """Fuse L1/L2/L3 text embeddings via concat + MLP.

    [B, 3*text_dim] -> [B, out_dim]
    """

    def __init__(
        self,
        text_dim: int = 768,
        out_dim: int = 1024,
    ) -> None:
        super().__init__()
        self.mlp = nn.Sequential(
            nn.Linear(3 * text_dim, out_dim),
            nn.GELU(),
            nn.Linear(out_dim, out_dim),
        )

    def forward(
        self,
        z_l1: torch.Tensor,
        z_l2: torch.Tensor,
        z_l3: torch.Tensor,
    ) -> torch.Tensor:
        """Fuse three text embeddings.

        Args:
            z_l1: L1 overview [B, text_dim].
            z_l2: L2 full description [B, text_dim].
            z_l3: L3 fingerprint [B, text_dim].

        Returns:
            Fused text embedding [B, out_dim].
        """
        cat = torch.cat([z_l1, z_l2, z_l3], dim=-1)
        return self.mlp(cat)


# ---------------------------------------------------------------------------
# Main model: AsymmetricEncoder
# ---------------------------------------------------------------------------

class AsymmetricEncoder(nn.Module):
    """Asymmetric dual encoder for CVGL with text fusion on both branches.

    Query branch:   DINOv3 LVD (drone) + text(L1/L2/L3) -> GatedFusion_q -> query [1024]
    Gallery branch: DINOv3 SAT (sat)   + text(L1/L2/L3) -> GatedFusion_g -> gallery [1024]

    No projection layers — retrieval space is DINOv3 native 1024-dim.
    Text fusion MLP is shared between branches (same caption format).
    Two separate GatedFusion gates (drone/sat may weight text differently).

    For satellite images without captions, GatedFusion passes image features through
    (text_feat=None → gate acts as identity).

    Args:
        dino_web_path: Path to DINOv3 LVD checkpoint (drone encoder).
        dino_sat_path: Path to DINOv3 SAT checkpoint (satellite encoder).
        lrsclip_path: Path to DGTRS-CLIP checkpoint (text encoder).
        init_gate: Initial fusion gate (image weight).
        baseline_mode: If True, gate = 1.0 (text ignored), DGTRS not loaded.
        device: Torch device string.
    """

    DINO_DIM = 1024
    TEXT_DIM = 768

    def __init__(
        self,
        dino_web_path: str = "nn_models/DINO_WEB/dinov3-vitl16-pretrain-lvd1689m.pth",
        dino_sat_path: str = "nn_models/DINO_SAT/model.safetensors",
        lrsclip_path: str = "nn_models/LRSCLIP/DGTRS-CLIP-ViT-L-14.pt",
        init_gate: float = 0.7,
        baseline_mode: bool = False,
        mona_bottleneck: int = 64,
        lora_rank: int = 4,
        device: str = "cuda",
    ) -> None:
        super().__init__()
        self.embed_dim = self.DINO_DIM
        self.baseline_mode = baseline_mode
        self.device = device

        # Image encoders (frozen + MONA adapters).
        self.drone_encoder = DINOv3ViT.from_pretrained(dino_web_path)
        self.sat_encoder = DINOv3ViT.from_pretrained(dino_sat_path)
        self._freeze(self.drone_encoder)
        self._freeze(self.sat_encoder)
        inject_mona_into_dinov3(self.drone_encoder, bottleneck=mona_bottleneck)
        inject_mona_into_dinov3(self.sat_encoder, bottleneck=mona_bottleneck)

        # Text encoder — official DGTRS architecture (frozen + LoRA).
        if not baseline_mode:
            self.text_encoder = load_dgtrs_text_encoder(lrsclip_path)
            self._freeze(self.text_encoder)
            inject_lora_into_dgtrs(self.text_encoder, rank=lora_rank)
        else:
            self.text_encoder = None

        # Shared text fusion MLP: 3×768 -> 1024 (same format for drone & sat captions).
        if not baseline_mode:
            self.text_fusion = TextFusionMLP(
                text_dim=self.TEXT_DIM,
                out_dim=self.DINO_DIM,
            )

        # Separate gated fusion for query and gallery branches.
        self.fusion_query = GatedFusion(init_gate=init_gate, baseline_mode=baseline_mode)
        self.fusion_gallery = GatedFusion(init_gate=init_gate, baseline_mode=baseline_mode)

    @staticmethod
    def _freeze(module: nn.Module) -> None:
        for p in module.parameters():
            p.requires_grad = False
        module.eval()

    def encode_drone(self, images: torch.Tensor) -> torch.Tensor:
        """Encode drone images with MONA adapters. Returns [B, DINO_DIM]."""
        return self.drone_encoder(images)

    def encode_satellite(self, images: torch.Tensor) -> torch.Tensor:
        """Encode satellite images with MONA adapters. Returns [B, DINO_DIM]."""
        return self.sat_encoder(images)

    def encode_text_levels(
        self,
        l1_texts: list[str],
        l2_texts: list[str],
        l3_texts: list[str],
    ) -> torch.Tensor | None:
        """Encode L1/L2/L3 captions and fuse. Returns [B, 1024] or None.

        Returns None if all captions are empty (no text available).
        For mixed batches (some have captions, some don't), encodes all
        and lets GatedFusion handle per-sample gating.
        """
        # Check if any caption is non-empty.
        if all(t == "" for t in l1_texts):
            return None

        z_l1 = self._encode_single_text(l1_texts)
        z_l2 = self._encode_single_text(l2_texts)
        z_l3 = self._encode_single_text(l3_texts)
        fused = self.text_fusion(z_l1, z_l2, z_l3)
        return F.normalize(fused, dim=-1)

    def _encode_single_text(self, texts: list[str]) -> torch.Tensor:
        """Tokenize and encode a list of strings using DGTRS tokenizer."""
        tokens = tokenize_dgtrs(list(texts)).to(self.device)
        return self.text_encoder(tokens)

    def forward(
        self,
        drone_img: torch.Tensor,
        sat_img: torch.Tensor,
        caption_l1: list[str] | None = None,
        caption_l2: list[str] | None = None,
        caption_l3: list[str] | None = None,
        sat_caption_l1: list[str] | None = None,
        sat_caption_l2: list[str] | None = None,
        sat_caption_l3: list[str] | None = None,
    ) -> dict[str, torch.Tensor]:
        """Forward pass.

        Args:
            drone_img: Drone images [B, 3, 256, 256].
            sat_img: Satellite images [B, 3, 256, 256].
            caption_l1/l2/l3: Drone L1/L2/L3 captions.
            sat_caption_l1/l2/l3: Satellite L1/L2/L3 captions.

        Returns:
            Dict with 'query' [B, 1024], 'gallery' [B, 1024],
            'gate_q', 'gate_g'.
        """
        # Image features (frozen DINOv3).
        drone_feat = self.encode_drone(drone_img)
        sat_feat = self.encode_satellite(sat_img)

        # Query branch: drone + drone text.
        drone_text = None
        if (caption_l1 is not None and caption_l2 is not None
                and caption_l3 is not None and not self.baseline_mode):
            drone_text = self.encode_text_levels(caption_l1, caption_l2, caption_l3)

        query = self.fusion_query(drone_feat, drone_text)
        query = F.normalize(query, dim=-1)

        # Gallery branch: satellite + satellite text.
        sat_text = None
        if (sat_caption_l1 is not None and sat_caption_l2 is not None
                and sat_caption_l3 is not None and not self.baseline_mode):
            sat_text = self.encode_text_levels(sat_caption_l1, sat_caption_l2, sat_caption_l3)

        gallery = self.fusion_gallery(sat_feat, sat_text)
        gallery = F.normalize(gallery, dim=-1)

        return {
            "query": query,
            "gallery": gallery,
            "gate_q": self.fusion_query.gate_value,
            "gate_g": self.fusion_gallery.gate_value,
        }

    def trainable_parameters(self) -> list[nn.Parameter]:
        """Return list of parameters that require grad."""
        return [p for p in self.parameters() if p.requires_grad]

    def save_checkpoint(self, path: str | Path, **extra) -> None:
        """Save model checkpoint with metadata."""
        path = Path(path)
        path.parent.mkdir(parents=True, exist_ok=True)
        ckpt = {
            "model_state": self.state_dict(),
            "baseline_mode": self.baseline_mode,
            **extra,
        }
        tmp = path.with_suffix(path.suffix + ".tmp")
        torch.save(ckpt, tmp)
        tmp.replace(path)
        LOGGER.info("💾 Model saved to %s", path)

    @classmethod
    def load_checkpoint(
        cls,
        path: str | Path,
        dino_web_path: str = "nn_models/DINO_WEB/dinov3-vitl16-pretrain-lvd1689m.pth",
        dino_sat_path: str = "nn_models/DINO_SAT/model.safetensors",
        lrsclip_path: str = "nn_models/LRSCLIP/DGTRS-CLIP-ViT-L-14.pt",
        device: str = "cuda",
    ) -> tuple[AsymmetricEncoder, dict]:
        """Load model from checkpoint.

        First builds the model (loading frozen backbones), then loads
        the saved trainable weights on top.

        Returns:
            (model, checkpoint_dict) — model ready for eval/resume,
            checkpoint_dict has optimizer_state, epoch, etc.
        """
        path = Path(path)
        LOGGER.info("📂 Loading checkpoint from %s", path)
        ckpt = torch.load(str(path), map_location="cpu", weights_only=False)

        model = cls(
            dino_web_path=dino_web_path,
            dino_sat_path=dino_sat_path,
            lrsclip_path=lrsclip_path,
            baseline_mode=ckpt.get("baseline_mode", False),
            device=device,
        )
        model.load_state_dict(ckpt["model_state"], strict=False)
        model = model.to(device)
        LOGGER.info("✅ Checkpoint loaded (epoch=%s)", ckpt.get("epoch", "?"))
        return model, ckpt

    def train(self, mode: bool = True) -> AsymmetricEncoder:
        """Override to keep frozen encoders in eval mode."""
        super().train(mode)
        self.drone_encoder.eval()
        self.sat_encoder.eval()
        if self.text_encoder is not None:
            # Text encoder partially unfrozen — set to train mode
            # but frozen layers won't update anyway.
            self.text_encoder.train(mode)
        return self


# ---------------------------------------------------------------------------
# Image preprocessing (DINOv3: 256x256, ImageNet normalization)
# ---------------------------------------------------------------------------

_IMAGENET_MEAN = [0.485, 0.456, 0.406]
_IMAGENET_STD = [0.229, 0.224, 0.225]


def get_dino_transform(image_size: int = 256) -> torch.nn.Module:
    """Build eval/inference image transform for DINOv3 input."""
    from torchvision import transforms
    return transforms.Compose([
        transforms.Resize(image_size, interpolation=transforms.InterpolationMode.BICUBIC),
        transforms.CenterCrop(image_size),
        transforms.ToTensor(),
        transforms.Normalize(mean=_IMAGENET_MEAN, std=_IMAGENET_STD),
    ])


def get_drone_train_transform(image_size: int = 256) -> torch.nn.Module:
    """Build training augmentation for drone images.

    Includes: RandomResizedCrop, HFlip, rotation, color jitter,
    grayscale, Gaussian blur.
    """
    from torchvision import transforms
    return transforms.Compose([
        transforms.RandomResizedCrop(
            image_size, scale=(0.7, 1.0),
            interpolation=transforms.InterpolationMode.BICUBIC,
        ),
        transforms.RandomHorizontalFlip(p=0.5),
        transforms.RandomRotation(degrees=15),
        transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.1),
        transforms.RandomGrayscale(p=0.05),
        transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 2.0)),
        transforms.ToTensor(),
        transforms.Normalize(mean=_IMAGENET_MEAN, std=_IMAGENET_STD),
    ])


def get_satellite_train_transform(image_size: int = 256) -> torch.nn.Module:
    """Build training augmentation for satellite images.

    Lighter than drone: no rotation or blur (satellite is nadir/consistent).
    Includes: RandomResizedCrop, HFlip, color jitter, grayscale.
    """
    from torchvision import transforms
    return transforms.Compose([
        transforms.RandomResizedCrop(
            image_size, scale=(0.7, 1.0),
            interpolation=transforms.InterpolationMode.BICUBIC,
        ),
        transforms.RandomHorizontalFlip(p=0.5),
        transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.1),
        transforms.RandomGrayscale(p=0.05),
        transforms.ToTensor(),
        transforms.Normalize(mean=_IMAGENET_MEAN, std=_IMAGENET_STD),
    ])
