import math
import threading
import torch
import torch.nn.functional as F
from contextlib import suppress
from torch.optim import Optimizer
from typing import TYPE_CHECKING, Optional

from .base import OptimizerCallback

if TYPE_CHECKING:
    from swift.trainers import TrainingArguments


class _MaxLogitsTracker:
    """
    Collect a per-step scalar max logits value even when training loop can't pass it into optimizer.step().

    - Eager attention: patch torch.softmax / F.softmax to capture exact softmax input max (attention scores).
    - SDPA / FlashAttention: logits not exposed; record conservative upper bound via norms:
        max(qk^T * scale) <= max||q|| * max||k|| * scale

    Note: This is a GLOBAL scalar for the whole step (not per-layer, not per-head).
    """

    _tls = threading.local()
    _enabled = False
    _patched_softmax = False
    _patched_sdpa = False
    _patched_flash = False

    _orig_torch_softmax = None
    _orig_F_softmax = None
    _orig_sdpa = None
    _orig_flash_attn_func = None

    @classmethod
    def _get_and_reset(cls) -> Optional[float]:
        v = getattr(cls._tls, 'max_logits', None)
        cls._tls.max_logits = None
        return v

    @classmethod
    def _update(cls, v: float):
        if v is None:
            return
        cur = getattr(cls._tls, 'max_logits', None)
        if cur is None or v > cur:
            cls._tls.max_logits = float(v)

    @classmethod
    def enable_softmax(cls):
        if cls._patched_softmax:
            return
        cls._patched_softmax = True

        cls._orig_torch_softmax = torch.softmax
        cls._orig_F_softmax = F.softmax

        def _maybe_capture(x: torch.Tensor, dim):
            # attention scores softmax: usually [B,H,Lq,Lk], dim=-1
            if not isinstance(x, torch.Tensor):
                return
            if x.dim() != 4:
                return
            if dim is None or not (dim == -1 or dim == x.dim() - 1):
                return

            with suppress(Exception):
                cls._update(float(x.detach().float().amax().item()))

        def _torch_softmax(x, dim=None, dtype=None):
            with suppress(Exception):
                _maybe_capture(x, dim)
            return cls._orig_torch_softmax(x, dim=dim, dtype=dtype)

        def _F_softmax(x, dim=None, _stacklevel=3, dtype=None):
            with suppress(Exception):
                _maybe_capture(x, dim)
            return cls._orig_F_softmax(x, dim=dim, _stacklevel=_stacklevel, dtype=dtype)

        torch.softmax = _torch_softmax
        F.softmax = _F_softmax

    @classmethod
    def enable_sdpa(cls):
        if cls._patched_sdpa:
            return
        cls._patched_sdpa = True

        if not hasattr(F, 'scaled_dot_product_attention'):
            return

        cls._orig_sdpa = F.scaled_dot_product_attention

        def _sdpa(query, key, value, attn_mask=None, dropout_p=0.0, is_causal=False, scale=None, enable_gqa=False):
            with suppress(Exception):
                if isinstance(query, torch.Tensor) and isinstance(key, torch.Tensor):
                    q = query.detach()
                    k = key.detach()

                    # upper bound using vector norms
                    qn = q.float().norm(p=2, dim=-1).max().item()
                    kn = k.float().norm(p=2, dim=-1).max().item()
                    d = q.size(-1)
                    s = float(scale) if scale is not None else (1.0 / math.sqrt(float(d)))
                    cls._update(qn * kn * s)

            return cls._orig_sdpa(
                query,
                key,
                value,
                attn_mask=attn_mask,
                dropout_p=dropout_p,
                is_causal=is_causal,
                scale=scale,
                enable_gqa=enable_gqa,
            )

        F.scaled_dot_product_attention = _sdpa

    @classmethod
    def enable_flash_attn(cls):
        if cls._patched_flash:
            return
        cls._patched_flash = True

        try:
            import flash_attn.flash_attn_interface as _fai
            flash_attn_func = _fai.flash_attn_func
        except Exception:
            return

        cls._orig_flash_attn_func = flash_attn_func

        def _flash_attn(q,
                        k,
                        v,
                        dropout_p=0.0,
                        softmax_scale=None,
                        causal=False,
                        window_size=(-1, -1),
                        alibi_slopes=None,
                        deterministic=False,
                        return_attn_probs=False):
            with suppress(Exception):
                if isinstance(q, torch.Tensor) and isinstance(k, torch.Tensor):
                    qn = q.detach().float().norm(p=2, dim=-1).max().item()
                    kn = k.detach().float().norm(p=2, dim=-1).max().item()
                    d = q.size(-1)
                    s = float(softmax_scale) if softmax_scale is not None else (1.0 / math.sqrt(float(d)))
                    cls._update(qn * kn * s)

            return cls._orig_flash_attn_func(
                q,
                k,
                v,
                dropout_p=dropout_p,
                softmax_scale=softmax_scale,
                causal=causal,
                window_size=window_size,
                alibi_slopes=alibi_slopes,
                deterministic=deterministic,
                return_attn_probs=return_attn_probs,
            )

        _fai.flash_attn_func = _flash_attn

    @classmethod
    def enable_all(cls):
        if cls._enabled:
            return
        cls._enabled = True
        cls.enable_softmax()
        cls.enable_sdpa()
        cls.enable_flash_attn()

    @classmethod
    def consume(cls) -> Optional[float]:
        return cls._get_and_reset()


class MuonClip(Optimizer):
    """
    MuonClip (stable version):
      - Muon-style update for apply_muon=True (2D weights): momentum buffer + Moonlight polynomial NS orthogonalization.
      - Other params (apply_muon=False): simple momentum SGD (kept minimal; you can switch to AdamW if needed).
      - QK-Clip uses a scalar max_logits (exact in eager, upper bound in sdpa/flash) and applies gamma_sqrt scaling
        to Q/K weights marked with is_qk=True.
    """

    def __init__(
        self,
        params,
        lr: float = 2e-4,
        momentum: float = 0.95,
        weight_decay: float = 0.1,
        nesterov: bool = False,
        newton_schulz_steps: int = 5,
        qk_clip_tau: float = 10000.0,
        qk_clip_enabled: bool = True,
        rms_scale_factor: float = 0.2,
    ):
        defaults = dict(
            lr=lr,
            momentum=momentum,
            weight_decay=weight_decay,
            nesterov=nesterov,
            newton_schulz_steps=newton_schulz_steps,
            qk_clip_tau=qk_clip_tau,
            qk_clip_enabled=qk_clip_enabled,
            rms_scale_factor=rms_scale_factor,
        )
        super().__init__(params, defaults)
        _MaxLogitsTracker.enable_all()

    @staticmethod
    @torch.no_grad()
    def newton_schulz(G: torch.Tensor, steps: int = 5, eps: float = 1e-7) -> torch.Tensor:
        """
        Moonlight/Muon polynomial Newton-Schulz iteration (stable).
        Works for rectangular matrices by transposing when needed.
        """
        # constants from your previous stable implementation
        a, b, c = (3.4445, -4.7750, 2.0315)

        X = G.bfloat16() / (G.norm() + eps)
        transposed = False
        if G.size(0) > G.size(1):
            X = X.T
            transposed = True

        for _ in range(steps):
            A = X @ X.T
            B = b * A + c * A @ A
            X = a * X + B @ X

        if transposed:
            X = X.T

        return X.to(G.dtype)

    def _is_qk_weight(self, group) -> bool:
        return bool(group.get('is_qk', False))

    @torch.no_grad()
    def step(self, closure=None, max_logits: Optional[float] = None):
        loss = None
        if closure is not None:
            with torch.enable_grad():
                loss = closure()

        # fallback: collect scalar max_logits from tracker if not provided
        if max_logits is None:
            max_logits = _MaxLogitsTracker.consume()

        for group in self.param_groups:
            lr = float(group['lr'])
            momentum = float(group['momentum'])
            weight_decay = float(group['weight_decay'])
            nesterov = bool(group.get('nesterov', False))
            ns_steps = int(group.get('newton_schulz_steps', 5))
            qk_clip_tau = float(group.get('qk_clip_tau', 10000.0))
            qk_clip_enabled = bool(group.get('qk_clip_enabled', True))
            apply_muon = bool(group.get('apply_muon', True))
            is_qk_group = self._is_qk_weight(group)

            for p in group['params']:
                if p.grad is None:
                    continue

                grad = p.grad
                state = self.state[p]

                if len(state) == 0:
                    state['momentum_buffer'] = torch.zeros_like(p)
                    state['step'] = 0

                buf = state['momentum_buffer']
                state['step'] += 1

                buf.mul_(momentum).add_(grad)

                # build update
                if apply_muon and p.ndim >= 2:
                    orth = self.newton_schulz(buf, steps=ns_steps)
                    n, m = p.shape[0], p.shape[1]
                    rms_scale_factor = float(group.get('rms_scale_factor', 0.2))
                    rms_scale = math.sqrt(max(n, m)) * rms_scale_factor
                    update = orth * rms_scale
                else:
                    update = buf

                if nesterov:
                    update = grad.add(update, alpha=momentum)

                # decoupled-ish weight decay
                if weight_decay != 0:
                    p.mul_(1 - lr * weight_decay)

                # QK-Clip (scalar)
                if qk_clip_enabled and is_qk_group and (max_logits is not None):
                    if max_logits > qk_clip_tau:
                        gamma = qk_clip_tau / float(max_logits)
                        gamma_sqrt = math.sqrt(gamma)
                        # scale weight and update (matches your previous stable version)
                        p.mul_(gamma_sqrt)
                        update = update * gamma_sqrt

                # apply update
                p.add_(update, alpha=-lr)

        return loss


class MuonClipOptimizerCallback(OptimizerCallback):

    def create_optimizer(self, model=None):
        args = self.args
        if model is None:
            model = self.trainer.model
        # parse args.optim_args
        optim_args = {}
        raw = getattr(args, 'optim_args', None)
        if raw:
            for mapping in raw.replace(' ', '').split(','):
                if not mapping:
                    continue
                if '=' not in mapping:
                    continue
                key, value = mapping.split('=', 1)
                if not key:
                    continue

                lv = value.lower()
                if lv in ('true', 'false'):
                    value = (lv == 'true')
                else:
                    try:
                        f = float(value)
                        value = int(f) if f.is_integer() else f
                    except ValueError:
                        pass

                optim_args[key] = value

        # resolve keys like create_muon_optimizer
        model_arch = model.model_meta.model_arch
        embed_key = getattr(model_arch, 'embedding', None) or 'embed_tokens'
        lm_head_key = getattr(model_arch, 'lm_head', None) or 'lm_head'

        # hyperparams (single-source of truth)
        lr = args.learning_rate
        weight_decay = optim_args.get('weight_decay', args.weight_decay)
        momentum = optim_args.get('momentum', 0.95)
        nesterov = optim_args.get('nesterov', False)
        newton_schulz_steps = optim_args.get('newton_schulz_steps', 5)
        qk_clip_tau = optim_args.get('qk_clip_tau', 100.0)
        qk_clip_enabled = optim_args.get('qk_clip_enabled', True)
        rms_scale_factor = optim_args.get('rms_scale_factor', 0.2)

        # collect trainable params and group them
        muon_named = []
        rest_named = []

        for name, p in model.named_parameters():
            if not p.requires_grad:
                continue

            is_muon_candidate = (p.ndim >= 2 and embed_key not in name and lm_head_key not in name)
            if is_muon_candidate:
                muon_named.append((name, p))
            else:
                rest_named.append((name, p))

        def _is_qk_name(name: str) -> bool:
            ln = name.lower()
            # qwen2.5/qwen3 common patterns
            return ('q_proj' in ln) or ('k_proj' in ln) or ('.wq' in ln) or ('.wk' in ln) or ('/wq' in ln) or ('/wk'
                                                                                                               in ln)

        qk_muon_params = []
        other_muon_params = []
        for name, p in muon_named:
            (qk_muon_params if _is_qk_name(name) else other_muon_params).append(p)

        rest_params = [p for _, p in rest_named]

        # build param groups
        base_group_config = {
            'lr': lr,
            'momentum': momentum,
            'weight_decay': weight_decay,
            'nesterov': nesterov,
            'newton_schulz_steps': newton_schulz_steps,
            'qk_clip_tau': qk_clip_tau,
            'qk_clip_enabled': qk_clip_enabled,
            'rms_scale_factor': rms_scale_factor,
        }
        param_groups = []

        if qk_muon_params:
            group = base_group_config.copy()
            group.update({
                'params': qk_muon_params,
                'apply_muon': True,
                'is_qk': True,
            })
            param_groups.append(group)

        if other_muon_params:
            group = base_group_config.copy()
            group.update({
                'params': other_muon_params,
                'apply_muon': True,
                'is_qk': False,
            })
            param_groups.append(group)

        if rest_params:
            group = base_group_config.copy()
            group.update({
                'params': rest_params,
                'apply_muon': False,
                'is_qk': False,
            })
            param_groups.append(group)

        # safety fallback
        if not param_groups:
            all_params = [p for _, p in model.named_parameters() if p.requires_grad]
            param_groups = [{
                'params': all_params,
                'lr': lr,
                'momentum': momentum,
                'weight_decay': weight_decay,
                'nesterov': nesterov,
                'newton_schulz_steps': newton_schulz_steps,
                'qk_clip_tau': qk_clip_tau,
                'qk_clip_enabled': qk_clip_enabled,
                'apply_muon': True,
                'is_qk': False,
            }]

        # Only pass supported init kwargs; real behavior comes from param_groups
        optimizer = MuonClip(
            param_groups,
            lr=lr,
            momentum=momentum,
            weight_decay=weight_decay,
            nesterov=nesterov,
            newton_schulz_steps=newton_schulz_steps,
            qk_clip_tau=qk_clip_tau,
            qk_clip_enabled=qk_clip_enabled,
            rms_scale_factor=rms_scale_factor,
        )

        return optimizer
