from __future__ import annotations

"""Weighted InfoNCE loss for GTA-UAV cross-view geo-localization.

Adapted from Game4Loc (https://github.com/Yux1angJi/GTA-UAV).
Uses per-sample label smoothing based on positive_weights (IoU/distance)
to handle partial overlap between drone and satellite crops.

Standard InfoNCE assumes strict 1-to-1 pairs and treats all non-diagonal
entries as negatives. In GTA-UAV, multiple satellite crops can validly
match one drone image (partial IoU overlap), causing false negatives.
WeightedInfoNCE softens this with adaptive label smoothing per sample.
"""

import math

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


@gin.configurable
class WeightedInfoNCELoss(nn.Module):
    """Weighted InfoNCE with adaptive per-sample label smoothing.

    For each sample i, eps_i = 1 - (1 - base_smoothing) / (1 + exp(-k * w_i))
    where w_i is the positive weight (e.g. IoU with matched satellite crop).
    Higher weight → lower eps → sharper target (strong positive).
    Lower weight → higher eps → softer target (weak/semi-positive).

    Args:
        temperature_init: Initial temperature (or learnable logit_scale).
        learnable_temperature: If True, temperature is learnable (CLIP-style).
        label_smoothing: Base label smoothing (used when no weights provided).
        k: Sigmoid steepness for weight → eps mapping.
        tau_min: Min clamp for learnable temperature.
        tau_max: Max clamp for learnable temperature.
    """

    def __init__(
        self,
        temperature_init: float = 0.07,
        learnable_temperature: bool = True,
        label_smoothing: float = 0.1,
        k: float = 5.0,
        tau_min: float = 0.01,
        tau_max: float = 0.1,
    ) -> None:
        super().__init__()
        self.label_smoothing = label_smoothing
        self.k = k
        self.tau_min = tau_min
        self.tau_max = tau_max
        self.learnable_temperature = learnable_temperature

        if learnable_temperature:
            self.logit_scale = nn.Parameter(
                torch.tensor(math.log(1.0 / temperature_init))
            )
        else:
            self.logit_scale = None
            self.temperature = temperature_init

    @property
    def current_temperature(self) -> float:
        if self.logit_scale is not None:
            tau = 1.0 / self.logit_scale.exp().clamp(
                min=1.0 / self.tau_max, max=1.0 / self.tau_min,
            ).item()
            return tau
        return self.temperature

    def _compute_eps(self, positive_weights: torch.Tensor | None, n: int) -> torch.Tensor | list[float]:
        """Compute per-sample label smoothing from positive weights."""
        if positive_weights is not None:
            # Higher weight → lower eps (sharper, stronger positive).
            return 1.0 - (1.0 - self.label_smoothing) / (1.0 + torch.exp(-self.k * positive_weights))
        return [self.label_smoothing] * n

    def _weighted_loss(
        self,
        sim_matrix: torch.Tensor,
        eps_all: torch.Tensor | list[float],
    ) -> torch.Tensor:
        """Weighted InfoNCE: per-sample interpolation between hard and uniform targets.

        For each row i:
          L_i = (1-eps_i) * [-sim[i,i] + logsumexp(sim[i,:])]
              + eps_i * [-mean(sim[i,:]) + logsumexp(sim[i,:])]
        """
        n = sim_matrix.shape[0]
        total_loss = torch.tensor(0.0, device=sim_matrix.device)
        for i in range(n):
            eps = eps_all[i] if isinstance(eps_all, list) else eps_all[i]
            logsumexp = torch.logsumexp(sim_matrix[i, :], dim=0)
            total_loss += (1 - eps) * (-sim_matrix[i, i] + logsumexp)
            total_loss += eps * (-sim_matrix[i, :].mean() + logsumexp)
        return total_loss / n

    def forward(
        self,
        embeddings: dict[str, torch.Tensor],
        epoch: int = 0,
        total_epochs: int = 1,
        positive_weights: torch.Tensor | None = None,
        queue_negatives: torch.Tensor | None = None,
    ) -> dict[str, torch.Tensor]:
        """Compute weighted InfoNCE loss.

        Args:
            embeddings: Dict with 'query' [B,D], 'gallery' [B,D], 'gate_q', 'gate_g'.
            positive_weights: Per-sample weight [B] (e.g. IoU with matched sat crop).
            queue_negatives: Extra negatives [Q,D] from memory bank (not used with weighted loss).
        """
        query = embeddings["query"].float()
        gallery = embeddings["gallery"].float()

        # Temperature.
        if self.learnable_temperature:
            clamped = self.logit_scale.float().clamp(
                min=math.log(1.0 / self.tau_max),
                max=math.log(1.0 / self.tau_min),
            )
            logit_scale = clamped.exp()
            tau = 1.0 / logit_scale
        else:
            logit_scale = 1.0 / self.temperature
            tau = self.temperature

        sim_q2g = logit_scale * query @ gallery.t()
        sim_g2q = sim_q2g.t()

        eps = self._compute_eps(positive_weights, query.shape[0])

        loss_q2g = self._weighted_loss(sim_q2g, eps)
        loss_g2q = self._weighted_loss(sim_g2q, eps)
        total = (loss_q2g + loss_g2q) / 2

        gate_q = embeddings.get("gate_q", 1.0)
        gate_g = embeddings.get("gate_g", 1.0)

        return {
            "total": total,
            "temperature": tau if isinstance(tau, torch.Tensor) else torch.tensor(tau, device=total.device),
            "gate_q": torch.tensor(gate_q, device=total.device) if not isinstance(gate_q, torch.Tensor) else gate_q.detach(),
            "gate_g": torch.tensor(gate_g, device=total.device) if not isinstance(gate_g, torch.Tensor) else gate_g.detach(),
        }
