"""Pull cross-attention scores and word confidence on the dosing audio.

Goal: see whether the AED's cross-attention distribution and per-word
confidence flag the hallucinated "5 milligramm 101" cluster that we
already identified via zero-duration word offsets.

Two new signals beyond what probe_canary_ts.py captured:
  1. word_confidence — per-word probability proxy. Hallucinations *should*
     have lower confidence than real words, though not guaranteed because
     AEDs hallucinate with high confidence too.
  2. xatt_scores — cross-attention from decoder tokens to encoder frames.
     For each output token we summarize where it "looked" in the audio:
       - peak_frame (argmax) and peak_time_s
       - entropy of the distribution (flat = no clear acoustic anchor)
     Hallucinated tokens should show high entropy (no clear acoustic peak).

Output:
  /sweep/probe_attn_conf.json — text + timestamp + word_confidence + per-token
    xatt summary (argmax frame, entropy)
"""
import json
import math
import subprocess
import tempfile

import torch
from omegaconf import OmegaConf

from nemo.collections.asr.models import EncDecMultiTaskModel
from nemo.collections.asr.models.aed_multitask_models import (
    MultiTaskTranscriptionConfig,
    parse_multitask_prompt,
)


WAV_SRC = "/sweep/case_c414cf52_15-01-39.m4a"
OUT_PATH = "/sweep/probe_attn_conf_kwargs_returnhyp.json"


def transcode(src: str) -> str:
    dst = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
    dst.close()
    subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", src,
                    "-ac", "1", "-ar", "16000", "-f", "wav", dst.name],
                   check=True)
    return dst.name


def to_jsonable(o):
    if isinstance(o, torch.Tensor):
        return o.detach().cpu().tolist()
    if hasattr(o, "tolist"):
        return o.tolist()
    if hasattr(o, "item"):
        return o.item()
    return str(o)


def entropy_of(probs: torch.Tensor) -> float:
    p = probs.clamp_min(1e-12)
    return float(-(p * p.log()).sum().item())


def summarize_xatt(xatt_scores, frame_stride_s: float):
    """xatt_scores: List[Tensor] over decoder layers.

    Multi-head attention output shape is typically [heads, tokens, frames]
    or [tokens, frames] depending on the decoder. We mean-pool away any
    leading dimensions until we get a 2D [tokens, frames] matrix.
    """
    if not xatt_scores:
        return None
    layers = [x.float() for x in xatt_scores]
    print(f"  xatt layer shapes: {[tuple(l.shape) for l in layers]}", flush=True)
    avg = torch.stack(layers, dim=0).mean(dim=0)
    # Collapse all leading dims (batch, heads, ...) until 2D
    while avg.dim() > 2:
        avg = avg.mean(dim=0)
    n_tokens, n_frames = avg.shape
    summaries = []
    for t in range(n_tokens):
        row = avg[t]
        norm = row / row.sum().clamp_min(1e-12)
        peak = int(norm.argmax().item())
        summaries.append({
            "token_idx": t,
            "peak_frame": peak,
            "peak_time_s": round(peak * frame_stride_s, 3),
            "peak_prob": round(float(norm[peak].item()), 4),
            "entropy": round(entropy_of(norm), 4),
            "max_log_entropy": round(math.log(n_frames), 4),
        })
    return {"n_tokens": n_tokens, "n_frames": n_frames,
            "frame_stride_s": frame_stride_s,
            "n_layers": len(layers), "per_token": summaries}


def main():
    print("loading Canary 1B v2", flush=True)
    model = EncDecMultiTaskModel.from_pretrained("nvidia/canary-1b-v2")
    model = model.to("cuda").to(torch.bfloat16)
    model.eval()

    # Patch the live decoding config:
    # - greedy strategy (confidence is only filled in greedy path, not beam)
    # - return_xattn_scores=True
    # - confidence_cfg with all preserve flags
    # - enable_chunking=False is set via override_config below (NeMo gates xatt
    #   on chunking; chunking also blocks word-level confidence aggregation).
    cfg = OmegaConf.to_container(model.cfg.decoding, resolve=True)
    cfg["strategy"] = "greedy"
    cfg["return_xattn_scores"] = True
    cfg.setdefault("confidence_cfg", {})
    cfg["confidence_cfg"]["preserve_word_confidence"] = True
    cfg["confidence_cfg"]["preserve_token_confidence"] = True
    cfg["confidence_cfg"]["preserve_frame_confidence"] = True
    cfg["confidence_cfg"]["aggregation"] = "min"  # most signal-preserving
    cfg.setdefault("greedy", {})
    cfg["greedy"]["preserve_token_confidence"] = True
    cfg["greedy"]["preserve_alignments"] = True
    print("applying decoding override:", json.dumps(cfg, indent=2), flush=True)
    model.change_decoding_strategy(OmegaConf.create(cfg))

    # Encoder frame stride: feature_stride * subsampling
    feature_stride = float(model.cfg.preprocessor["window_stride"])
    subsampling = 8  # FastConformer default; verified empirically against
                    # 80ms-per-frame in probe_tdt.md
    frame_stride_s = feature_stride * subsampling
    print(f"frame stride: {frame_stride_s} s", flush=True)

    wav = transcode(WAV_SRC)
    # Clean single-knob ablation: same override_config for both runs,
    # only `enable_chunking` differs. All other fields (batch_size, prompt,
    # timestamps, return_hypotheses) held constant.
    print(f"running transcribe(KWARGS path, return_hypotheses=True, timestamps=True) on {WAV_SRC}",
          flush=True)
    # Final clean test: kwargs path (like the original Mode A run that
    # showed None confidence), but with return_hypotheses=True forced.
    # If confidence appears, then `return_hypotheses` was the real
    # confounder all along — not timestamps, not the call form.
    with torch.amp.autocast("cuda", enabled=True, dtype=torch.bfloat16):
        with torch.no_grad():
            hyps = model.transcribe([wav], source_lang="de", target_lang="de",
                                     pnc="yes", timestamps=True, batch_size=1,
                                     return_hypotheses=True)
    h = hyps[0]

    out = {
        "text": getattr(h, "text", None),
        "score": getattr(h, "score", None),
        "timestamp": getattr(h, "timestamp", None),
        "tokens": getattr(h, "tokens", None),
        "word_confidence": getattr(h, "word_confidence", None),
        "token_confidence": getattr(h, "token_confidence", None),
        "xatt_summary": summarize_xatt(getattr(h, "xatt_scores", None),
                                        frame_stride_s),
    }

    with open(OUT_PATH, "w") as f:
        json.dump(out, f, default=to_jsonable, ensure_ascii=False, indent=2)

    # Quick sanity print
    print(f"\ntext: {h.text}", flush=True)
    print(f"score: {h.score}", flush=True)
    wc = getattr(h, "word_confidence", None)
    print(f"word_confidence: {len(wc) if wc is not None else 'None'} entries", flush=True)
    tc = getattr(h, "token_confidence", None)
    print(f"token_confidence: {len(tc) if tc is not None else 'None'} entries", flush=True)
    xs = getattr(h, "xatt_scores", None)
    if xs is not None:
        print(f"xatt_scores: {len(xs)} layer(s); shapes={[tuple(x.shape) for x in xs]}", flush=True)
    else:
        print("xatt_scores: None", flush=True)
    print(f"\nfull dump -> {OUT_PATH}", flush=True)


if __name__ == "__main__":
    main()
